From e3049858049ddb08928c88bbe4ae463569c883a4 Mon Sep 17 00:00:00 2001 From: LLL Date: Sat, 20 Jun 2026 17:47:04 +0800 Subject: [PATCH 1/2] feat: add /model pro|flash shortcuts and CLI model set command - Add 'pro' and 'flash' aliases to canonical_model_name in tui config - Add 'codewhale model set' CLI subcommand with pro/flash shortcuts - Supports deepseek-v4-pro, deepseek-v4-flash, and future deepseek models --- crates/cli/src/lib.rs | 52 +++++++++++----- crates/tui/src/config.rs | 129 +++++++++++++++++++-------------------- 2 files changed, 101 insertions(+), 80 deletions(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index a84498407..06eea3247 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -443,7 +443,7 @@ enum AuthCommand { Set { #[arg(long, value_enum)] provider: ProviderArg, - /// Inline value (discouraged — appears in shell history). + /// Inline value (discouraged �appears in shell history). #[arg(long)] api_key: Option, /// Read the key from stdin instead of prompting. @@ -505,6 +505,10 @@ enum ModelCommand { #[arg(long, value_enum)] provider: Option, }, + /// Set the default model (e.g. "pro", "flash", "deepseek-v4-pro"). + Set { + model: String, + }, } #[derive(Debug, Args)] @@ -635,7 +639,7 @@ pub fn run_cli() -> std::process::ExitCode { // Use the full anyhow chain so callers see the underlying // cause (e.g. the actual TOML parse error with line/column) // instead of just the top-level context message. The bare - // `{err}` Display impl drops the chain — see #767, where + // `{err}` Display impl drops the chain �see #767, where // users hit "failed to parse config at " with no // hint that the real error was a stray BOM or unbalanced // quote a few lines down. @@ -754,7 +758,7 @@ fn run() -> Result<()> { Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command), Some(Commands::McpServer) => run_mcp_server_command(&mut store), Some(Commands::Config(args)) => run_config_command(&mut store, args.command), - Some(Commands::Model(args)) => run_model_command(args.command, runtime_overrides.provider), + Some(Commands::Model(args)) => run_model_command(&mut store, args.command, runtime_overrides.provider), Some(Commands::Thread(args)) => run_thread_command(args.command), Some(Commands::Sandbox(args)) => run_sandbox_command(args.command), Some(Commands::AppServer(args)) => { @@ -1067,7 +1071,7 @@ fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec, ) -> Result<()> { @@ -1519,6 +1524,25 @@ fn run_model_command( println!("used_fallback: {}", resolved.used_fallback); Ok(()) } + ModelCommand::Set { model } => { + let canonical = match model.trim().to_ascii_lowercase().as_str() { + "pro" | "deepseek-v4-pro" | "deepseek-v4pro" => "deepseek-v4-pro", + "flash" | "deepseek-v4-flash" | "deepseek-v4flash" => "deepseek-v4-flash", + other => { + if other.starts_with("deepseek") { + other + } else { + bail!( + "Invalid model '{model}'. Use 'pro', 'flash', 'deepseek-v4-pro', or 'deepseek-v4-flash'." + ); + } + } + }; + store.config.default_text_model = Some(canonical.to_string()); + store.save()?; + println!("Default model set to '{canonical}'"); + Ok(()) + } } } @@ -1648,7 +1672,7 @@ fn run_app_server_command( /// Build the `serve` argv forwarded to the TUI binary for /// `codewhale app-server --http`/`--mobile`. Maps app-server flags onto the -/// matching `serve` flags (note `--insecure-no-auth` → `--insecure`). The +/// matching `serve` flags (note `--insecure-no-auth` �`--insecure`). The /// subcommand-level `--config` is bridged through the global `--config` in the /// dispatcher, so it is intentionally not part of this passthrough. An auth /// token from the environment is deliberately *not* forwarded into child argv; @@ -1944,8 +1968,8 @@ binary.", /// Resolve the sibling `codewhale-tui` executable next to the running /// dispatcher. Honours platform executable suffix (`.exe` on Windows) so -/// the npm-distributed Windows package — which ships -/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247). +/// the npm-distributed Windows package �which ships +/// `bin/downloads/codewhale-tui.exe` �is found by `Path::exists` (#247). /// /// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for /// custom installs and CI test layouts. On Windows we additionally try @@ -1976,9 +2000,9 @@ fn locate_sibling_tui_binary() -> Result { \n\ The `codewhale` dispatcher delegates interactive sessions to a sibling \ `codewhale-tui` binary. To fix this, install one of:\n\ - • npm: npm install -g codewhale (downloads both binaries)\n\ - • cargo: cargo install codewhale-cli codewhale-tui --locked\n\ - • GitHub Releases: download BOTH `codewhale-` AND \ + �npm: npm install -g codewhale (downloads both binaries)\n\ + �cargo: cargo install codewhale-cli codewhale-tui --locked\n\ + �GitHub Releases: download BOTH `codewhale-` AND \ `codewhale-tui-` from https://github.com/Hmbown/CodeWhale/releases/latest \ and place them in the same directory.\n\ \n\ @@ -2141,7 +2165,7 @@ mod tests { // Regression for #767: `run_cli` prints the full anyhow chain so users // see the underlying TOML parser error (line/column, expected token) // instead of just the top-level "failed to parse config at " - // wrapper. anyhow's bare `Display` impl drops the chain — pin both + // wrapper. anyhow's bare `Display` impl drops the chain �pin both // pieces here so a future refactor of the printing path doesn't // silently regress. #[test] @@ -2543,7 +2567,7 @@ mod tests { }; let argv = app_server_serve_passthrough(&args); let as_str: Vec<&str> = argv.iter().map(String::as_str).collect(); - // No host/port forwarded → serve applies its own --mobile 0.0.0.0 default. + // No host/port forwarded �serve applies its own --mobile 0.0.0.0 default. // No auth token is injected from the environment into child argv. assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]); } @@ -4209,7 +4233,7 @@ mod tests { // Touch the dispatcher so its parent dir is the lookup root. std::fs::write(&dispatcher, b"").unwrap(); - // No sibling yet — resolver returns None. + // No sibling yet �resolver returns None. assert!(sibling_tui_candidate(&dispatcher).is_none()); let target = @@ -4242,7 +4266,7 @@ mod tests { let dispatcher = dir.path().join("codewhale.exe"); std::fs::write(&dispatcher, b"").unwrap(); - // Only the suffixless name exists — emulates the manual rename. + // Only the suffixless name exists �emulates the manual rename. let suffixless = dispatcher.with_file_name("codewhale-tui"); std::fs::write(&suffixless, b"").unwrap(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 179f8a8e1..cfdc1aa0d 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -326,7 +326,7 @@ impl ApiProvider { &Self::FROM_KIND_LOOKUP } - /// `ApiProvider` discriminant → `ProviderKind` lookup. + /// `ApiProvider` discriminant �`ProviderKind` lookup. /// Index 1 is `None` for the legacy `DeepseekCN` variant. const KIND_LOOKUP: [Option; 26] = [ Some(codewhale_config::ProviderKind::Deepseek), @@ -357,7 +357,7 @@ impl ApiProvider { Some(codewhale_config::ProviderKind::Deepinfra), ]; - /// `ProviderKind` discriminant → `ApiProvider` lookup. + /// `ProviderKind` discriminant �`ApiProvider` lookup. const FROM_KIND_LOOKUP: [Self; 25] = [ Self::Deepseek, Self::NvidiaNim, @@ -616,8 +616,8 @@ fn deepseek_alias_deprecation(model_lower: &str) -> Option Option<&'static str> { match model.trim().to_ascii_lowercase().as_str() { - "deepseek-v4pro" => Some("deepseek-v4-pro"), - "deepseek-v4flash" => Some("deepseek-v4-flash"), + "pro" | "deepseek-v4-pro" | "deepseek-v4pro" => Some("deepseek-v4-pro"), + "flash" | "deepseek-v4-flash" | "deepseek-v4flash" => Some("deepseek-v4-flash"), _ => None, } } @@ -667,7 +667,7 @@ pub(crate) fn normalize_custom_model_id(model: &str) -> Option { /// /// DeepSeek providers use the strict `normalize_model_name` gate (official /// API only accepts DeepSeek IDs). All other providers pass any non-empty, -/// non-control-character string through — the provider API is the authority. +/// non-control-character string through �the provider API is the authority. #[must_use] pub fn requested_model_for_provider(provider: ApiProvider, model: &str) -> Option { match provider { @@ -689,11 +689,11 @@ pub fn requested_model_for_provider(provider: ApiProvider, model: &str) -> Optio /// DeepSeek weights, etc.) keeps working: /// /// 1. A DeepSeek-native provider (`deepseek` / `deepseek-cn`) accepts only -/// DeepSeek model IDs or `auto` — same gate as [`normalize_model_name`]. +/// DeepSeek model IDs or `auto` �same gate as [`normalize_model_name`]. /// 2. A non-DeepSeek *native* provider (e.g. Z.ai, which serves GLM) must not /// be handed a DeepSeek-only model ID. This reuses the same /// "foreign to a direct provider" classification the model resolver uses, -/// so DeepSeek aggregators (NVIDIA NIM, OpenRouter, Fireworks, …) stay +/// so DeepSeek aggregators (NVIDIA NIM, OpenRouter, Fireworks, � stay /// permissive. /// /// Returns `Ok(())` for any tuple we cannot confidently reject (the provider @@ -711,7 +711,7 @@ pub fn validate_route(provider: ApiProvider, model: &str) -> Result<(), String> } // Providers whose model id is passed through verbatim (OpenAI-compatible, - // Ollama tags, custom base URLs, …) are validated by the upstream service. + // Ollama tags, custom base URLs, � are validated by the upstream service. if provider_passes_model_through(provider) { return Ok(()); } @@ -983,7 +983,7 @@ fn canonical_minimax_model_id(model: &str) -> Option<&'static str> { /// /// Preserves the caller's casing when the model is already a recognised /// DeepSeek id (e.g. `DeepSeek-V4-Flash` stays as-is). Only rewrites compact -/// aliases like `deepseek-v4pro` → `deepseek-v4-pro`. +/// aliases like `deepseek-v4pro` �`deepseek-v4-pro`. #[must_use] pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option { if matches!(provider, ApiProvider::Openrouter) @@ -1032,7 +1032,7 @@ pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> { // When the user's input already matches a known model id // case-insensitively, keep their original casing; only rewrite - // compact aliases (e.g. v4pro → v4-pro). + // compact aliases (e.g. v4pro �v4-pro). if canonical.eq_ignore_ascii_case(&normalized) || normalized.to_ascii_lowercase() == canonical { @@ -1193,11 +1193,11 @@ pub struct TuiConfig { /// `[notifications].threshold_secs` gate from the lower-level /// `[notifications]` block: /// - /// - `Always` — fire a turn-completion notification on every successful + /// - `Always` �fire a turn-completion notification on every successful /// turn regardless of duration. The configured `[notifications].method` /// and `include_summary` flag are still respected. - /// - `Never` — suppress all turn-completion notifications. - /// - Unset (default) — fall back to the `[notifications]` defaults. + /// - `Never` �suppress all turn-completion notifications. + /// - Unset (default) �fall back to the `[notifications]` defaults. pub notification_condition: Option, /// When `true`, plain Up/Down on an empty composer scroll the /// transcript instead of recalling input history. Useful for @@ -1278,7 +1278,7 @@ pub struct NotificationsConfig { pub include_summary: bool, /// Completion sound: `"off"` | `"beep"` | `"bell"` | `"file"`. Default: `"beep"`. - /// Plays a sound when every turn finishes (alongside the ✅ marker). + /// Plays a sound when every turn finishes (alongside the �marker). #[serde(default)] pub completion_sound: CompletionSound, @@ -1358,7 +1358,7 @@ impl SnapshotsConfig { } } -/// Search provider enumeration — selects which backend `web_search` uses. +/// Search provider enumeration �selects which backend `web_search` uses. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum SearchProvider { @@ -1508,8 +1508,8 @@ pub struct ToolsConfig { /// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given; /// right-cluster chips (`Agents`, `ReasoningReplay`, `PrefixStability`, /// `Cache`, `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) -/// likewise honour ordering inside their cluster. The split between left and right is deliberate — left holds steady -/// identity (mode/model/cost), right holds transient signals — so we route +/// likewise honour ordering inside their cluster. The split between left and right is deliberate �left holds steady +/// identity (mode/model/cost), right holds transient signals �so we route /// each variant to the correct side rather than letting users reorder across /// the spacer. /// @@ -1658,7 +1658,7 @@ impl StatusItem { } } - /// Every variant in display order — used by the picker to enumerate rows. + /// Every variant in display order �used by the picker to enumerate rows. #[must_use] pub fn all() -> &'static [StatusItem] { &[ @@ -1780,7 +1780,7 @@ pub struct SubagentsConfig { #[serde(default)] pub max_concurrent: Option, /// How many levels of nested sub-agents the interactive `agent` tool may - /// spawn. `0` disables sub-agents entirely — the `agent` tool refuses to + /// spawn. `0` disables sub-agents entirely �the `agent` tool refuses to /// spawn, a full opt-out; `1` allows one level, `2` two, and so on. When /// unset, defaults to [`codewhale_config::DEFAULT_SPAWN_DEPTH`]; any value /// is clamped to [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`]. Fleet @@ -1814,12 +1814,12 @@ pub struct SubagentsConfig { pub heartbeat_timeout_secs: Option, } -/// `[auto]` table — knobs for the `--model auto` / `/model auto` router. +/// `[auto]` table �knobs for the `--model auto` / `/model auto` router. /// /// `cost_saving` (#1207): when `true`, the auto-mode router prefers /// `deepseek-v4-flash` for ambiguous requests, only escalating to /// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning. -/// Default is `false` (balanced — match the existing routing voice). +/// Default is `false` (balanced �match the existing routing voice). #[derive(Debug, Clone, Deserialize, Default)] pub struct AutoConfig { #[serde(default)] @@ -1894,7 +1894,7 @@ pub struct Config { /// Additional system-prompt sources concatenated in declared order /// (#454). Paths are expanded via `expand_path` so `~` and env /// vars work. Project config overrides user config (replace, not - /// merge) — that's the typical "this repo needs X plus everything + /// merge) �that's the typical "this repo needs X plus everything /// I already have" pattern, where users put `~/global.md` in the /// project's array if they want both. Each file is loaded, capped /// at 100 KiB, and skipped (with a warning) on read errors so a @@ -1902,7 +1902,7 @@ pub struct Config { pub instructions: Option>, pub allow_shell: Option, /// Opt-in ghost-text follow-up prompt suggestion after each completed turn. - /// Default: false — the user must explicitly set this to true to enable. + /// Default: false �the user must explicitly set this to true to enable. pub prompt_suggestion: Option, #[serde(alias = "approvalPolicy")] pub approval_policy: Option, @@ -1926,7 +1926,7 @@ pub struct Config { /// When true and `/usr/bin/bwrap` is present on Linux, route exec_shell /// through bubblewrap instead of relying solely on Landlock (#2184). /// Defaults to false. Requires the `bubblewrap` package to be installed - /// separately — we do NOT vendor bwrap. + /// separately �we do NOT vendor bwrap. #[serde(alias = "preferBwrap")] pub prefer_bwrap: Option, #[serde(alias = "managedConfigPath")] @@ -2083,7 +2083,7 @@ pub struct VisionModelConfig { pub base_url: Option, } -/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon. +/// `[runtime_api]` table �knobs for the local HTTP/SSE daemon. #[derive(Debug, Clone, Deserialize, Default)] pub struct RuntimeApiConfig { /// Additional CORS origins to allow on top of the built-in defaults @@ -2097,7 +2097,7 @@ pub struct RuntimeApiConfig { pub cors_origins: Option>, } -/// `[skills]` table — knobs for the community-skill installer. +/// `[skills]` table �knobs for the community-skill installer. #[derive(Debug, Clone, Deserialize, Default)] pub struct SkillsConfig { /// Curated registry index. `/skill install ` looks up the spec here. @@ -2139,7 +2139,7 @@ impl SkillsConfig { } } -/// `[network]` table — mirrors `codewhale_config::NetworkPolicyToml` so the live +/// `[network]` table �mirrors `codewhale_config::NetworkPolicyToml` so the live /// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`] /// without reaching into the workspace config crate. See `config.example.toml` /// for documentation. @@ -2200,7 +2200,7 @@ impl NetworkPolicyToml { } } -/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in +/// `[lsp]` table �mirrors [`crate::lsp::LspConfig`]. Documented in /// `config.example.toml`. When omitted, defaults from `LspConfig::default()` /// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides). #[derive(Debug, Clone, Deserialize, Default)] @@ -2472,7 +2472,7 @@ impl Config { } // Only warn if the per-provider table doesn't have an explicit // `base_url`, because if it does, the per-provider one wins and the - // root field is just dead config — no behavior surprise. + // root field is just dead config �no behavior surprise. let has_provider_base = self .provider_config_for(provider) .and_then(|p| p.base_url.as_deref().map(str::trim)) @@ -2718,7 +2718,7 @@ impl Config { } // The Codex Responses backend only serves its own model family, and a // global `default_text_model` is constrained to DeepSeek IDs or "auto" - // by validation — so it can never name a Codex-compatible model. Fall + // by validation �so it can never name a Codex-compatible model. Fall // back to the Codex default here instead of letting a DeepSeek default // leak through and be rejected by the backend. An explicit // `[providers.openai_codex] model` is honored by the block above. @@ -2908,8 +2908,8 @@ impl Config { /// Read the API key. /// - /// Precedence: **explicit in-memory override → provider/root config - /// → environment**. + /// Precedence: **explicit in-memory override �provider/root config + /// �environment**. /// /// The in-memory `self.api_key` override is only honored when the user /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL` @@ -2998,10 +2998,10 @@ impl Config { codewhale auth set --provider deepseek\n\ \n\ Alternatives:\n\ - • export DEEPSEEK_API_KEY= (current shell only;\n\ - also note: zsh users — exports in ~/.zshrc only reach interactive\n\ + �export DEEPSEEK_API_KEY= (current shell only;\n\ + also note: zsh users �exports in ~/.zshrc only reach interactive\n\ shells, prefer ~/.zshenv for everything)\n\ - • api_key = \"\" in ~/.codewhale/config.toml" + �api_key = \"\" in ~/.codewhale/config.toml" ), ApiProvider::SiliconflowCn => anyhow::bail!( "SiliconFlow China API key not found. Run 'codewhale auth set --provider siliconflow-CN', \ @@ -3989,7 +3989,7 @@ fn apply_env_overrides(config: &mut Config) { .base_url = Some(value); } // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on - // their own provider entry — the legacy root `base_url` keeps DeepSeek-only + // their own provider entry �the legacy root `base_url` keeps DeepSeek-only // semantics. if matches!(config.api_provider(), ApiProvider::Openai) && let Ok(value) = std::env::var("OPENAI_BASE_URL") @@ -4930,7 +4930,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { vision_model: override_cfg.vision_model.or(base.vision_model), // #454: project's instructions array replaces user's array // wholesale. The typical "merge" pattern is for users who want - // both — they list `~/global.md` inside the project array. + // both �they list `~/global.md` inside the project array. instructions: override_cfg.instructions.or(base.instructions), allow_shell: override_cfg.allow_shell.or(base.allow_shell), prompt_suggestion: override_cfg.prompt_suggestion.or(base.prompt_suggestion), @@ -5115,7 +5115,7 @@ fn load_single_config_file(path: &Path) -> Result { /// Build a one-line warning when top-level-only keys are nested under a section /// CodeWhale does not define (`[general]` / `[sandbox]`). TOML silently drops /// those keys, so e.g. `[general]\nallow_shell = true` never takes effect and -/// the shell tools (`exec_shell`, `task_shell_start`, …) are absent from the +/// the shell tools (`exec_shell`, `task_shell_start`, � are absent from the /// catalog with no explanation. Returns `None` when nothing is misplaced. /// /// This is the exact confusion behind #2589: `allow_shell` and `sandbox_mode` @@ -5147,7 +5147,7 @@ fn warn_on_misplaced_top_level_keys(raw: &str) -> Option { return None; } Some(format!( - "Ignoring {} — CodeWhale has no `[general]` or `[sandbox]` section, so these \ + "Ignoring {} �CodeWhale has no `[general]` or `[sandbox]` section, so these \ keys are silently dropped. Move them to the TOP of the config file (above any \ `[section]` header), e.g. `allow_shell = true`. Until then, shell tools stay \ disabled. (#2589)", @@ -5249,8 +5249,7 @@ pub fn ensure_parent_dir(path: &Path) -> Result<()> { // Tighten group/other bits on the parent dir as a hardening pass. // The dir lives under the user's home, so the chmod is best-effort: // filesystems that don't accept Unix permission bits (Docker - // bind-mounts of NTFS, network shares, FAT, certain CI volumes — - // see #897) return EPERM/ENOTSUP. The dir already exists by the + // bind-mounts of NTFS, network shares, FAT, certain CI volumes � // see #897) return EPERM/ENOTSUP. The dir already exists by the // time we get here, so failing the whole save just because we // couldn't tighten perms strands the user mid-onboarding. Warn // loudly so a security-sensitive operator can still notice via @@ -5268,7 +5267,7 @@ pub fn ensure_parent_dir(path: &Path) -> Result<()> { "could not tighten parent dir permissions; \ filesystem may not support Unix chmod \ (Docker bind-mount, NTFS, network share). \ - Continuing — the file will still be written." + Continuing �the file will still be written." ); } } @@ -5294,7 +5293,7 @@ fn write_config_file_secure(path: &Path, content: &str) -> Result<()> { // set_permissions re-asserts that on filesystems where mode-at-open // didn't take effect (or where the file already existed with broader // bits). Filesystems that don't accept Unix chmod at all (Docker - // bind-mounts of NTFS, network shares — #897) return EPERM. Treat + // bind-mounts of NTFS, network shares �#897) return EPERM. Treat // that as a warning rather than failing the whole save: the file // contents are written, and on Windows/macOS hosts the parent file // system's native ACL model is doing the access control. @@ -5323,7 +5322,7 @@ pub enum SavedCredential { /// Stored in **both** the OS keyring and the codewhale config file. /// This is the default outcome on platforms with a working keyring /// backend: writing both layers defeats the - /// `keyring → env → config-file` resolution-order shadow that + /// `keyring �env �config-file` resolution-order shadow that /// would otherwise let a stale OS-keyring entry from a previous /// install hide the freshly-entered key (#593). The `backend` /// label is the value of [`codewhale_secrets::Secrets::backend_name`] @@ -5360,8 +5359,8 @@ impl SavedCredential { /// **Dual-write strategy (#593):** writes to `~/.codewhale/config.toml` /// (always) and to the OS keyring via [`codewhale_secrets::Secrets`] /// (when a backend is reachable). The runtime resolves credentials in -/// `keyring → env → config-file` order; writing to the config file -/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry +/// `keyring �env �config-file` order; writing to the config file +/// alone �as v0.8.8 through v0.8.10 did �let a stale keyring entry /// from a prior install silently shadow the fresh value the user just /// typed during in-TUI onboarding, producing the "no response" symptom /// reported in #593. @@ -5371,8 +5370,7 @@ impl SavedCredential { /// keyring acts as the layered override that defeats stale-shadow on /// the resolution path. When the keyring write fails (no backend, OS /// permission denied, etc.) the config-file write still stands and -/// the function reports a [`SavedCredential::ConfigFile`] outcome — -/// callers should not treat that as a failure. +/// the function reports a [`SavedCredential::ConfigFile`] outcome �/// callers should not treat that as a failure. /// /// Skipped under `cfg(test)` so the suite never touches the host /// keyring. The `secrets` crate has its own test coverage for @@ -5384,13 +5382,13 @@ pub fn save_api_key(api_key: &str) -> Result { } // Always write the inspectable copy first. The config file is the - // durable record everyone — including macOS Keychain-prompted - // first-run, headless CI, and IDE terminals — can rely on. + // durable record everyone �including macOS Keychain-prompted + // first-run, headless CI, and IDE terminals �can rely on. let path = save_api_key_to_config_file(trimmed)?; // Then mirror to the OS keyring when one is reachable. This // overwrites any stale entry from a prior install so - // `Secrets::resolve` (keyring → env → config-file) no longer + // `Secrets::resolve` (keyring �env �config-file) no longer // shadows the fresh key. Skipped under `cfg(test)` so unit tests // can't pollute the host keyring (macOS Always-Allow prompts, // cross-test contamination). @@ -5501,7 +5499,7 @@ reasoning_effort = "max" /// `~/.codewhale/config.toml`. /// /// Used by [`crate::tui::app::App::new`] to decide whether to gate -/// the user behind the in-TUI api-key onboarding screen — getting +/// the user behind the in-TUI api-key onboarding screen �getting /// this wrong made users get prompted for credentials in situations /// where normal env/config auth was already available. pub fn has_api_key(config: &Config) -> bool { @@ -5555,7 +5553,7 @@ pub fn active_provider_uses_env_only_api_key(config: &Config) -> bool { active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config) } -/// Check whether the given provider has any usable API key — via env var, +/// Check whether the given provider has any usable API key �via env var, /// provider/root config. Used by the `/provider` picker to decide whether to /// prompt for a key inline. #[must_use] @@ -5932,7 +5930,7 @@ pub fn kimi_cli_credentials_present() -> bool { /// `[providers.]` table. /// /// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally -/// **not** unset — they are managed by the user's shell and outside the +/// **not** unset �they are managed by the user's shell and outside the /// CLI's purview. `Config::deepseek_api_key`'s explicit-override path /// (Path 0) ensures a freshly-entered key still wins over a stale env /// var that lingers from a previous session. @@ -5951,7 +5949,7 @@ pub fn clear_api_key() -> Result<()> { let mut result = String::new(); for line in existing.lines() { - // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it + // Match `api_key`, `api_key =`, ` api_key=`, etc. �anywhere it // appears as the leading non-whitespace token. let trimmed = line.trim_start(); if trimmed.strip_prefix("api_key").is_some_and(|rest| { @@ -6150,7 +6148,7 @@ mod tests { Ok(()) } - // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in. + // GHSA-72w5-pf8h-xfp4 �regression: `allow_shell` must be opt-in. #[test] fn allow_shell_defaults_to_false_when_unset() { let config = Config::default(); @@ -7722,8 +7720,7 @@ action = "session.compact" /// #593: the dual-write outcome describes both targets so the /// onboarding toast (`API key saved to {describe}`) tells the user - /// the key landed in *both* the keyring and the config file — - /// which is the whole point of the fix (defeats stale-keyring + /// the key landed in *both* the keyring and the config file � /// which is the whole point of the fix (defeats stale-keyring /// shadow while keeping the config file inspectable). #[test] fn saved_credential_describe_lists_both_targets_for_keyring_and_config() { @@ -8069,7 +8066,7 @@ api_key = "old-openrouter-key" api_key: Some(API_KEYRING_SENTINEL.to_string()), ..Config::default() }; - // Sentinel must not be treated as a real key — the resolver should + // Sentinel must not be treated as a real key �the resolver should // fall through to env / config-provider and ultimately bail out // with a "key not found" error. let _err = config @@ -8449,7 +8446,7 @@ scan_codewhale_only = true normalize_model_name("deepseek-v5-pro-20270101").as_deref(), Some("deepseek-v5-pro-20270101") ); - // legacy names pass through unchanged — server decides + // legacy names pass through unchanged �server decides assert_eq!( normalize_model_name("deepseek-chat").as_deref(), Some("deepseek-chat") @@ -8544,8 +8541,8 @@ scan_codewhale_only = true #[test] fn validate_route_rejects_mismatched_provider_model_tuple() { - // #3227: the exact contamination — Z.ai provider paired with a - // DeepSeek model — is rejected locally with a diagnostic that names + // #3227: the exact contamination �Z.ai provider paired with a + // DeepSeek model �is rejected locally with a diagnostic that names // the incompatible pair, before any network call. let err = validate_route(ApiProvider::Zai, "deepseek-v4-pro") .expect_err("zai + deepseek model must be rejected"); @@ -8562,7 +8559,7 @@ scan_codewhale_only = true assert!(validate_route(ApiProvider::Deepseek, "deepseek-v4-pro").is_ok()); // `auto` is always acceptable; the per-turn router resolves it. assert!(validate_route(ApiProvider::Zai, "auto").is_ok()); - // Pass-through / aggregator providers stay permissive — the upstream + // Pass-through / aggregator providers stay permissive �the upstream // API remains the authority for them. assert!(validate_route(ApiProvider::Openai, "deepseek-v4-pro").is_ok()); assert!(validate_route(ApiProvider::Openrouter, "deepseek-v4-pro").is_ok()); @@ -9080,7 +9077,7 @@ scan_codewhale_only = true } let config = Config::load(None, None)?; - // v-series snapshots pass through unchanged — no alias folding + // v-series snapshots pass through unchanged �no alias folding assert_eq!( config.default_text_model.as_deref(), Some("deepseek-v4-flash-20260423") @@ -9857,7 +9854,7 @@ model = "glm-5" } // (b) a non-passthrough provider (novita) with an unknown custom model - // and the DEFAULT base_url must also be preserved verbatim — never + // and the DEFAULT base_url must also be preserved verbatim �never // rewritten to DEFAULT_NOVITA_MODEL. { let _guard = EnvGuard::new(&temp_root); @@ -11958,7 +11955,7 @@ model = "deepseek-ai/deepseek-v4-pro" #[test] fn provider_capability_ollama_deepseek_tag_uses_deepseek_heuristic() { // #3023: known model families resolve through models.rs lookups even - // on Ollama — a legacy DeepSeek tag gets the 128K heuristic window. + // on Ollama �a legacy DeepSeek tag gets the 128K heuristic window. let cap = provider_capability(ApiProvider::Ollama, "deepseek-v3.1:671b"); assert_eq!( cap.context_window, From 5b3e9dc663c4bbaa9bb9585e8ecbbbffc8f627d2 Mon Sep 17 00:00:00 2001 From: LLL Date: Sat, 20 Jun 2026 18:08:14 +0800 Subject: [PATCH 2/2] fix: accept any model name in CLI set, restore UTF-8 encoding - ModelCommand::Set now accepts any non-empty model name, not just deepseek* - Keeps pro/flash shortcuts for DeepSeek models - Restore UTF-8 characters corrupted by prior Set-Content Addresses review feedback on PR #3350 --- crates/cli/src/lib.rs | 42 ++++++------- crates/tui/src/config.rs | 125 ++++++++++++++++++++------------------- 2 files changed, 83 insertions(+), 84 deletions(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 06eea3247..623ec3c71 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -443,7 +443,7 @@ enum AuthCommand { Set { #[arg(long, value_enum)] provider: ProviderArg, - /// Inline value (discouraged �appears in shell history). + /// Inline value (discouraged — appears in shell history). #[arg(long)] api_key: Option, /// Read the key from stdin instead of prompting. @@ -639,7 +639,7 @@ pub fn run_cli() -> std::process::ExitCode { // Use the full anyhow chain so callers see the underlying // cause (e.g. the actual TOML parse error with line/column) // instead of just the top-level context message. The bare - // `{err}` Display impl drops the chain �see #767, where + // `{err}` Display impl drops the chain — see #767, where // users hit "failed to parse config at " with no // hint that the real error was a stray BOM or unbalanced // quote a few lines down. @@ -1071,7 +1071,7 @@ fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec { - let canonical = match model.trim().to_ascii_lowercase().as_str() { + let trimmed = model.trim(); + if trimmed.is_empty() { + bail!("Model name cannot be empty"); + } + let canonical = match trimmed.to_ascii_lowercase().as_str() { "pro" | "deepseek-v4-pro" | "deepseek-v4pro" => "deepseek-v4-pro", "flash" | "deepseek-v4-flash" | "deepseek-v4flash" => "deepseek-v4-flash", - other => { - if other.starts_with("deepseek") { - other - } else { - bail!( - "Invalid model '{model}'. Use 'pro', 'flash', 'deepseek-v4-pro', or 'deepseek-v4-flash'." - ); - } - } + _ => trimmed, }; store.config.default_text_model = Some(canonical.to_string()); store.save()?; @@ -1672,7 +1668,7 @@ fn run_app_server_command( /// Build the `serve` argv forwarded to the TUI binary for /// `codewhale app-server --http`/`--mobile`. Maps app-server flags onto the -/// matching `serve` flags (note `--insecure-no-auth` �`--insecure`). The +/// matching `serve` flags (note `--insecure-no-auth` → `--insecure`). The /// subcommand-level `--config` is bridged through the global `--config` in the /// dispatcher, so it is intentionally not part of this passthrough. An auth /// token from the environment is deliberately *not* forwarded into child argv; @@ -1968,8 +1964,8 @@ binary.", /// Resolve the sibling `codewhale-tui` executable next to the running /// dispatcher. Honours platform executable suffix (`.exe` on Windows) so -/// the npm-distributed Windows package �which ships -/// `bin/downloads/codewhale-tui.exe` �is found by `Path::exists` (#247). +/// the npm-distributed Windows package — which ships +/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247). /// /// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for /// custom installs and CI test layouts. On Windows we additionally try @@ -2000,9 +1996,9 @@ fn locate_sibling_tui_binary() -> Result { \n\ The `codewhale` dispatcher delegates interactive sessions to a sibling \ `codewhale-tui` binary. To fix this, install one of:\n\ - �npm: npm install -g codewhale (downloads both binaries)\n\ - �cargo: cargo install codewhale-cli codewhale-tui --locked\n\ - �GitHub Releases: download BOTH `codewhale-` AND \ + • npm: npm install -g codewhale (downloads both binaries)\n\ + • cargo: cargo install codewhale-cli codewhale-tui --locked\n\ + • GitHub Releases: download BOTH `codewhale-` AND \ `codewhale-tui-` from https://github.com/Hmbown/CodeWhale/releases/latest \ and place them in the same directory.\n\ \n\ @@ -2165,7 +2161,7 @@ mod tests { // Regression for #767: `run_cli` prints the full anyhow chain so users // see the underlying TOML parser error (line/column, expected token) // instead of just the top-level "failed to parse config at " - // wrapper. anyhow's bare `Display` impl drops the chain �pin both + // wrapper. anyhow's bare `Display` impl drops the chain — pin both // pieces here so a future refactor of the printing path doesn't // silently regress. #[test] @@ -2567,7 +2563,7 @@ mod tests { }; let argv = app_server_serve_passthrough(&args); let as_str: Vec<&str> = argv.iter().map(String::as_str).collect(); - // No host/port forwarded �serve applies its own --mobile 0.0.0.0 default. + // No host/port forwarded → serve applies its own --mobile 0.0.0.0 default. // No auth token is injected from the environment into child argv. assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]); } @@ -4233,7 +4229,7 @@ mod tests { // Touch the dispatcher so its parent dir is the lookup root. std::fs::write(&dispatcher, b"").unwrap(); - // No sibling yet �resolver returns None. + // No sibling yet — resolver returns None. assert!(sibling_tui_candidate(&dispatcher).is_none()); let target = @@ -4266,7 +4262,7 @@ mod tests { let dispatcher = dir.path().join("codewhale.exe"); std::fs::write(&dispatcher, b"").unwrap(); - // Only the suffixless name exists �emulates the manual rename. + // Only the suffixless name exists — emulates the manual rename. let suffixless = dispatcher.with_file_name("codewhale-tui"); std::fs::write(&suffixless, b"").unwrap(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index cfdc1aa0d..ffdd8fe63 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -326,7 +326,7 @@ impl ApiProvider { &Self::FROM_KIND_LOOKUP } - /// `ApiProvider` discriminant �`ProviderKind` lookup. + /// `ApiProvider` discriminant → `ProviderKind` lookup. /// Index 1 is `None` for the legacy `DeepseekCN` variant. const KIND_LOOKUP: [Option; 26] = [ Some(codewhale_config::ProviderKind::Deepseek), @@ -357,7 +357,7 @@ impl ApiProvider { Some(codewhale_config::ProviderKind::Deepinfra), ]; - /// `ProviderKind` discriminant �`ApiProvider` lookup. + /// `ProviderKind` discriminant → `ApiProvider` lookup. const FROM_KIND_LOOKUP: [Self; 25] = [ Self::Deepseek, Self::NvidiaNim, @@ -667,7 +667,7 @@ pub(crate) fn normalize_custom_model_id(model: &str) -> Option { /// /// DeepSeek providers use the strict `normalize_model_name` gate (official /// API only accepts DeepSeek IDs). All other providers pass any non-empty, -/// non-control-character string through �the provider API is the authority. +/// non-control-character string through — the provider API is the authority. #[must_use] pub fn requested_model_for_provider(provider: ApiProvider, model: &str) -> Option { match provider { @@ -689,11 +689,11 @@ pub fn requested_model_for_provider(provider: ApiProvider, model: &str) -> Optio /// DeepSeek weights, etc.) keeps working: /// /// 1. A DeepSeek-native provider (`deepseek` / `deepseek-cn`) accepts only -/// DeepSeek model IDs or `auto` �same gate as [`normalize_model_name`]. +/// DeepSeek model IDs or `auto` — same gate as [`normalize_model_name`]. /// 2. A non-DeepSeek *native* provider (e.g. Z.ai, which serves GLM) must not /// be handed a DeepSeek-only model ID. This reuses the same /// "foreign to a direct provider" classification the model resolver uses, -/// so DeepSeek aggregators (NVIDIA NIM, OpenRouter, Fireworks, � stay +/// so DeepSeek aggregators (NVIDIA NIM, OpenRouter, Fireworks, …) stay /// permissive. /// /// Returns `Ok(())` for any tuple we cannot confidently reject (the provider @@ -711,7 +711,7 @@ pub fn validate_route(provider: ApiProvider, model: &str) -> Result<(), String> } // Providers whose model id is passed through verbatim (OpenAI-compatible, - // Ollama tags, custom base URLs, � are validated by the upstream service. + // Ollama tags, custom base URLs, …) are validated by the upstream service. if provider_passes_model_through(provider) { return Ok(()); } @@ -983,7 +983,7 @@ fn canonical_minimax_model_id(model: &str) -> Option<&'static str> { /// /// Preserves the caller's casing when the model is already a recognised /// DeepSeek id (e.g. `DeepSeek-V4-Flash` stays as-is). Only rewrites compact -/// aliases like `deepseek-v4pro` �`deepseek-v4-pro`. +/// aliases like `deepseek-v4pro` → `deepseek-v4-pro`. #[must_use] pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option { if matches!(provider, ApiProvider::Openrouter) @@ -1032,7 +1032,7 @@ pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> { // When the user's input already matches a known model id // case-insensitively, keep their original casing; only rewrite - // compact aliases (e.g. v4pro �v4-pro). + // compact aliases (e.g. v4pro → v4-pro). if canonical.eq_ignore_ascii_case(&normalized) || normalized.to_ascii_lowercase() == canonical { @@ -1193,11 +1193,11 @@ pub struct TuiConfig { /// `[notifications].threshold_secs` gate from the lower-level /// `[notifications]` block: /// - /// - `Always` �fire a turn-completion notification on every successful + /// - `Always` — fire a turn-completion notification on every successful /// turn regardless of duration. The configured `[notifications].method` /// and `include_summary` flag are still respected. - /// - `Never` �suppress all turn-completion notifications. - /// - Unset (default) �fall back to the `[notifications]` defaults. + /// - `Never` — suppress all turn-completion notifications. + /// - Unset (default) — fall back to the `[notifications]` defaults. pub notification_condition: Option, /// When `true`, plain Up/Down on an empty composer scroll the /// transcript instead of recalling input history. Useful for @@ -1278,7 +1278,7 @@ pub struct NotificationsConfig { pub include_summary: bool, /// Completion sound: `"off"` | `"beep"` | `"bell"` | `"file"`. Default: `"beep"`. - /// Plays a sound when every turn finishes (alongside the �marker). + /// Plays a sound when every turn finishes (alongside the ✅ marker). #[serde(default)] pub completion_sound: CompletionSound, @@ -1358,7 +1358,7 @@ impl SnapshotsConfig { } } -/// Search provider enumeration �selects which backend `web_search` uses. +/// Search provider enumeration — selects which backend `web_search` uses. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum SearchProvider { @@ -1508,8 +1508,8 @@ pub struct ToolsConfig { /// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given; /// right-cluster chips (`Agents`, `ReasoningReplay`, `PrefixStability`, /// `Cache`, `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) -/// likewise honour ordering inside their cluster. The split between left and right is deliberate �left holds steady -/// identity (mode/model/cost), right holds transient signals �so we route +/// likewise honour ordering inside their cluster. The split between left and right is deliberate — left holds steady +/// identity (mode/model/cost), right holds transient signals — so we route /// each variant to the correct side rather than letting users reorder across /// the spacer. /// @@ -1658,7 +1658,7 @@ impl StatusItem { } } - /// Every variant in display order �used by the picker to enumerate rows. + /// Every variant in display order — used by the picker to enumerate rows. #[must_use] pub fn all() -> &'static [StatusItem] { &[ @@ -1780,7 +1780,7 @@ pub struct SubagentsConfig { #[serde(default)] pub max_concurrent: Option, /// How many levels of nested sub-agents the interactive `agent` tool may - /// spawn. `0` disables sub-agents entirely �the `agent` tool refuses to + /// spawn. `0` disables sub-agents entirely — the `agent` tool refuses to /// spawn, a full opt-out; `1` allows one level, `2` two, and so on. When /// unset, defaults to [`codewhale_config::DEFAULT_SPAWN_DEPTH`]; any value /// is clamped to [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`]. Fleet @@ -1814,12 +1814,12 @@ pub struct SubagentsConfig { pub heartbeat_timeout_secs: Option, } -/// `[auto]` table �knobs for the `--model auto` / `/model auto` router. +/// `[auto]` table — knobs for the `--model auto` / `/model auto` router. /// /// `cost_saving` (#1207): when `true`, the auto-mode router prefers /// `deepseek-v4-flash` for ambiguous requests, only escalating to /// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning. -/// Default is `false` (balanced �match the existing routing voice). +/// Default is `false` (balanced — match the existing routing voice). #[derive(Debug, Clone, Deserialize, Default)] pub struct AutoConfig { #[serde(default)] @@ -1894,7 +1894,7 @@ pub struct Config { /// Additional system-prompt sources concatenated in declared order /// (#454). Paths are expanded via `expand_path` so `~` and env /// vars work. Project config overrides user config (replace, not - /// merge) �that's the typical "this repo needs X plus everything + /// merge) — that's the typical "this repo needs X plus everything /// I already have" pattern, where users put `~/global.md` in the /// project's array if they want both. Each file is loaded, capped /// at 100 KiB, and skipped (with a warning) on read errors so a @@ -1902,7 +1902,7 @@ pub struct Config { pub instructions: Option>, pub allow_shell: Option, /// Opt-in ghost-text follow-up prompt suggestion after each completed turn. - /// Default: false �the user must explicitly set this to true to enable. + /// Default: false — the user must explicitly set this to true to enable. pub prompt_suggestion: Option, #[serde(alias = "approvalPolicy")] pub approval_policy: Option, @@ -1926,7 +1926,7 @@ pub struct Config { /// When true and `/usr/bin/bwrap` is present on Linux, route exec_shell /// through bubblewrap instead of relying solely on Landlock (#2184). /// Defaults to false. Requires the `bubblewrap` package to be installed - /// separately �we do NOT vendor bwrap. + /// separately — we do NOT vendor bwrap. #[serde(alias = "preferBwrap")] pub prefer_bwrap: Option, #[serde(alias = "managedConfigPath")] @@ -2083,7 +2083,7 @@ pub struct VisionModelConfig { pub base_url: Option, } -/// `[runtime_api]` table �knobs for the local HTTP/SSE daemon. +/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon. #[derive(Debug, Clone, Deserialize, Default)] pub struct RuntimeApiConfig { /// Additional CORS origins to allow on top of the built-in defaults @@ -2097,7 +2097,7 @@ pub struct RuntimeApiConfig { pub cors_origins: Option>, } -/// `[skills]` table �knobs for the community-skill installer. +/// `[skills]` table — knobs for the community-skill installer. #[derive(Debug, Clone, Deserialize, Default)] pub struct SkillsConfig { /// Curated registry index. `/skill install ` looks up the spec here. @@ -2139,7 +2139,7 @@ impl SkillsConfig { } } -/// `[network]` table �mirrors `codewhale_config::NetworkPolicyToml` so the live +/// `[network]` table — mirrors `codewhale_config::NetworkPolicyToml` so the live /// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`] /// without reaching into the workspace config crate. See `config.example.toml` /// for documentation. @@ -2200,7 +2200,7 @@ impl NetworkPolicyToml { } } -/// `[lsp]` table �mirrors [`crate::lsp::LspConfig`]. Documented in +/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in /// `config.example.toml`. When omitted, defaults from `LspConfig::default()` /// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides). #[derive(Debug, Clone, Deserialize, Default)] @@ -2472,7 +2472,7 @@ impl Config { } // Only warn if the per-provider table doesn't have an explicit // `base_url`, because if it does, the per-provider one wins and the - // root field is just dead config �no behavior surprise. + // root field is just dead config — no behavior surprise. let has_provider_base = self .provider_config_for(provider) .and_then(|p| p.base_url.as_deref().map(str::trim)) @@ -2718,7 +2718,7 @@ impl Config { } // The Codex Responses backend only serves its own model family, and a // global `default_text_model` is constrained to DeepSeek IDs or "auto" - // by validation �so it can never name a Codex-compatible model. Fall + // by validation — so it can never name a Codex-compatible model. Fall // back to the Codex default here instead of letting a DeepSeek default // leak through and be rejected by the backend. An explicit // `[providers.openai_codex] model` is honored by the block above. @@ -2908,8 +2908,8 @@ impl Config { /// Read the API key. /// - /// Precedence: **explicit in-memory override �provider/root config - /// �environment**. + /// Precedence: **explicit in-memory override → provider/root config + /// → environment**. /// /// The in-memory `self.api_key` override is only honored when the user /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL` @@ -2998,10 +2998,10 @@ impl Config { codewhale auth set --provider deepseek\n\ \n\ Alternatives:\n\ - �export DEEPSEEK_API_KEY= (current shell only;\n\ - also note: zsh users �exports in ~/.zshrc only reach interactive\n\ + • export DEEPSEEK_API_KEY= (current shell only;\n\ + also note: zsh users — exports in ~/.zshrc only reach interactive\n\ shells, prefer ~/.zshenv for everything)\n\ - �api_key = \"\" in ~/.codewhale/config.toml" + • api_key = \"\" in ~/.codewhale/config.toml" ), ApiProvider::SiliconflowCn => anyhow::bail!( "SiliconFlow China API key not found. Run 'codewhale auth set --provider siliconflow-CN', \ @@ -3989,7 +3989,7 @@ fn apply_env_overrides(config: &mut Config) { .base_url = Some(value); } // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on - // their own provider entry �the legacy root `base_url` keeps DeepSeek-only + // their own provider entry — the legacy root `base_url` keeps DeepSeek-only // semantics. if matches!(config.api_provider(), ApiProvider::Openai) && let Ok(value) = std::env::var("OPENAI_BASE_URL") @@ -4930,7 +4930,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { vision_model: override_cfg.vision_model.or(base.vision_model), // #454: project's instructions array replaces user's array // wholesale. The typical "merge" pattern is for users who want - // both �they list `~/global.md` inside the project array. + // both — they list `~/global.md` inside the project array. instructions: override_cfg.instructions.or(base.instructions), allow_shell: override_cfg.allow_shell.or(base.allow_shell), prompt_suggestion: override_cfg.prompt_suggestion.or(base.prompt_suggestion), @@ -5115,7 +5115,7 @@ fn load_single_config_file(path: &Path) -> Result { /// Build a one-line warning when top-level-only keys are nested under a section /// CodeWhale does not define (`[general]` / `[sandbox]`). TOML silently drops /// those keys, so e.g. `[general]\nallow_shell = true` never takes effect and -/// the shell tools (`exec_shell`, `task_shell_start`, � are absent from the +/// the shell tools (`exec_shell`, `task_shell_start`, …) are absent from the /// catalog with no explanation. Returns `None` when nothing is misplaced. /// /// This is the exact confusion behind #2589: `allow_shell` and `sandbox_mode` @@ -5147,7 +5147,7 @@ fn warn_on_misplaced_top_level_keys(raw: &str) -> Option { return None; } Some(format!( - "Ignoring {} �CodeWhale has no `[general]` or `[sandbox]` section, so these \ + "Ignoring {} — CodeWhale has no `[general]` or `[sandbox]` section, so these \ keys are silently dropped. Move them to the TOP of the config file (above any \ `[section]` header), e.g. `allow_shell = true`. Until then, shell tools stay \ disabled. (#2589)", @@ -5249,7 +5249,8 @@ pub fn ensure_parent_dir(path: &Path) -> Result<()> { // Tighten group/other bits on the parent dir as a hardening pass. // The dir lives under the user's home, so the chmod is best-effort: // filesystems that don't accept Unix permission bits (Docker - // bind-mounts of NTFS, network shares, FAT, certain CI volumes � // see #897) return EPERM/ENOTSUP. The dir already exists by the + // bind-mounts of NTFS, network shares, FAT, certain CI volumes — + // see #897) return EPERM/ENOTSUP. The dir already exists by the // time we get here, so failing the whole save just because we // couldn't tighten perms strands the user mid-onboarding. Warn // loudly so a security-sensitive operator can still notice via @@ -5267,7 +5268,7 @@ pub fn ensure_parent_dir(path: &Path) -> Result<()> { "could not tighten parent dir permissions; \ filesystem may not support Unix chmod \ (Docker bind-mount, NTFS, network share). \ - Continuing �the file will still be written." + Continuing — the file will still be written." ); } } @@ -5293,7 +5294,7 @@ fn write_config_file_secure(path: &Path, content: &str) -> Result<()> { // set_permissions re-asserts that on filesystems where mode-at-open // didn't take effect (or where the file already existed with broader // bits). Filesystems that don't accept Unix chmod at all (Docker - // bind-mounts of NTFS, network shares �#897) return EPERM. Treat + // bind-mounts of NTFS, network shares — #897) return EPERM. Treat // that as a warning rather than failing the whole save: the file // contents are written, and on Windows/macOS hosts the parent file // system's native ACL model is doing the access control. @@ -5322,7 +5323,7 @@ pub enum SavedCredential { /// Stored in **both** the OS keyring and the codewhale config file. /// This is the default outcome on platforms with a working keyring /// backend: writing both layers defeats the - /// `keyring �env �config-file` resolution-order shadow that + /// `keyring → env → config-file` resolution-order shadow that /// would otherwise let a stale OS-keyring entry from a previous /// install hide the freshly-entered key (#593). The `backend` /// label is the value of [`codewhale_secrets::Secrets::backend_name`] @@ -5359,8 +5360,8 @@ impl SavedCredential { /// **Dual-write strategy (#593):** writes to `~/.codewhale/config.toml` /// (always) and to the OS keyring via [`codewhale_secrets::Secrets`] /// (when a backend is reachable). The runtime resolves credentials in -/// `keyring �env �config-file` order; writing to the config file -/// alone �as v0.8.8 through v0.8.10 did �let a stale keyring entry +/// `keyring → env → config-file` order; writing to the config file +/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry /// from a prior install silently shadow the fresh value the user just /// typed during in-TUI onboarding, producing the "no response" symptom /// reported in #593. @@ -5370,7 +5371,8 @@ impl SavedCredential { /// keyring acts as the layered override that defeats stale-shadow on /// the resolution path. When the keyring write fails (no backend, OS /// permission denied, etc.) the config-file write still stands and -/// the function reports a [`SavedCredential::ConfigFile`] outcome �/// callers should not treat that as a failure. +/// the function reports a [`SavedCredential::ConfigFile`] outcome — +/// callers should not treat that as a failure. /// /// Skipped under `cfg(test)` so the suite never touches the host /// keyring. The `secrets` crate has its own test coverage for @@ -5382,13 +5384,13 @@ pub fn save_api_key(api_key: &str) -> Result { } // Always write the inspectable copy first. The config file is the - // durable record everyone �including macOS Keychain-prompted - // first-run, headless CI, and IDE terminals �can rely on. + // durable record everyone — including macOS Keychain-prompted + // first-run, headless CI, and IDE terminals — can rely on. let path = save_api_key_to_config_file(trimmed)?; // Then mirror to the OS keyring when one is reachable. This // overwrites any stale entry from a prior install so - // `Secrets::resolve` (keyring �env �config-file) no longer + // `Secrets::resolve` (keyring → env → config-file) no longer // shadows the fresh key. Skipped under `cfg(test)` so unit tests // can't pollute the host keyring (macOS Always-Allow prompts, // cross-test contamination). @@ -5499,7 +5501,7 @@ reasoning_effort = "max" /// `~/.codewhale/config.toml`. /// /// Used by [`crate::tui::app::App::new`] to decide whether to gate -/// the user behind the in-TUI api-key onboarding screen �getting +/// the user behind the in-TUI api-key onboarding screen — getting /// this wrong made users get prompted for credentials in situations /// where normal env/config auth was already available. pub fn has_api_key(config: &Config) -> bool { @@ -5553,7 +5555,7 @@ pub fn active_provider_uses_env_only_api_key(config: &Config) -> bool { active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config) } -/// Check whether the given provider has any usable API key �via env var, +/// Check whether the given provider has any usable API key — via env var, /// provider/root config. Used by the `/provider` picker to decide whether to /// prompt for a key inline. #[must_use] @@ -5930,7 +5932,7 @@ pub fn kimi_cli_credentials_present() -> bool { /// `[providers.]` table. /// /// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally -/// **not** unset �they are managed by the user's shell and outside the +/// **not** unset — they are managed by the user's shell and outside the /// CLI's purview. `Config::deepseek_api_key`'s explicit-override path /// (Path 0) ensures a freshly-entered key still wins over a stale env /// var that lingers from a previous session. @@ -5949,7 +5951,7 @@ pub fn clear_api_key() -> Result<()> { let mut result = String::new(); for line in existing.lines() { - // Match `api_key`, `api_key =`, ` api_key=`, etc. �anywhere it + // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it // appears as the leading non-whitespace token. let trimmed = line.trim_start(); if trimmed.strip_prefix("api_key").is_some_and(|rest| { @@ -6148,7 +6150,7 @@ mod tests { Ok(()) } - // GHSA-72w5-pf8h-xfp4 �regression: `allow_shell` must be opt-in. + // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in. #[test] fn allow_shell_defaults_to_false_when_unset() { let config = Config::default(); @@ -7720,7 +7722,8 @@ action = "session.compact" /// #593: the dual-write outcome describes both targets so the /// onboarding toast (`API key saved to {describe}`) tells the user - /// the key landed in *both* the keyring and the config file � /// which is the whole point of the fix (defeats stale-keyring + /// the key landed in *both* the keyring and the config file — + /// which is the whole point of the fix (defeats stale-keyring /// shadow while keeping the config file inspectable). #[test] fn saved_credential_describe_lists_both_targets_for_keyring_and_config() { @@ -8066,7 +8069,7 @@ api_key = "old-openrouter-key" api_key: Some(API_KEYRING_SENTINEL.to_string()), ..Config::default() }; - // Sentinel must not be treated as a real key �the resolver should + // Sentinel must not be treated as a real key — the resolver should // fall through to env / config-provider and ultimately bail out // with a "key not found" error. let _err = config @@ -8446,7 +8449,7 @@ scan_codewhale_only = true normalize_model_name("deepseek-v5-pro-20270101").as_deref(), Some("deepseek-v5-pro-20270101") ); - // legacy names pass through unchanged �server decides + // legacy names pass through unchanged — server decides assert_eq!( normalize_model_name("deepseek-chat").as_deref(), Some("deepseek-chat") @@ -8541,8 +8544,8 @@ scan_codewhale_only = true #[test] fn validate_route_rejects_mismatched_provider_model_tuple() { - // #3227: the exact contamination �Z.ai provider paired with a - // DeepSeek model �is rejected locally with a diagnostic that names + // #3227: the exact contamination — Z.ai provider paired with a + // DeepSeek model — is rejected locally with a diagnostic that names // the incompatible pair, before any network call. let err = validate_route(ApiProvider::Zai, "deepseek-v4-pro") .expect_err("zai + deepseek model must be rejected"); @@ -8559,7 +8562,7 @@ scan_codewhale_only = true assert!(validate_route(ApiProvider::Deepseek, "deepseek-v4-pro").is_ok()); // `auto` is always acceptable; the per-turn router resolves it. assert!(validate_route(ApiProvider::Zai, "auto").is_ok()); - // Pass-through / aggregator providers stay permissive �the upstream + // Pass-through / aggregator providers stay permissive — the upstream // API remains the authority for them. assert!(validate_route(ApiProvider::Openai, "deepseek-v4-pro").is_ok()); assert!(validate_route(ApiProvider::Openrouter, "deepseek-v4-pro").is_ok()); @@ -9077,7 +9080,7 @@ scan_codewhale_only = true } let config = Config::load(None, None)?; - // v-series snapshots pass through unchanged �no alias folding + // v-series snapshots pass through unchanged — no alias folding assert_eq!( config.default_text_model.as_deref(), Some("deepseek-v4-flash-20260423") @@ -9854,7 +9857,7 @@ model = "glm-5" } // (b) a non-passthrough provider (novita) with an unknown custom model - // and the DEFAULT base_url must also be preserved verbatim �never + // and the DEFAULT base_url must also be preserved verbatim — never // rewritten to DEFAULT_NOVITA_MODEL. { let _guard = EnvGuard::new(&temp_root); @@ -11955,7 +11958,7 @@ model = "deepseek-ai/deepseek-v4-pro" #[test] fn provider_capability_ollama_deepseek_tag_uses_deepseek_heuristic() { // #3023: known model families resolve through models.rs lookups even - // on Ollama �a legacy DeepSeek tag gets the 128K heuristic window. + // on Ollama — a legacy DeepSeek tag gets the 128K heuristic window. let cap = provider_capability(ApiProvider::Ollama, "deepseek-v3.1:671b"); assert_eq!( cap.context_window,