From 5300dc484e41560e70294a6c7c58135e82e86633 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Sun, 7 Jun 2026 02:44:08 +0200 Subject: [PATCH 1/4] chore: enforce lf for rust sources --- .gitattributes | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitattributes b/.gitattributes index 099e7f8ed..6e639fdb8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,5 +3,11 @@ # produces different compiled binaries on Windows vs Linux/macOS. crates/tui/src/prompts/*.md text eol=lf +# Rustfmt writes LF; keep Rust sources stable across Windows/Linux/macOS. +*.rs text eol=lf + +# Keep repository attributes themselves stable on every platform. +.gitattributes text eol=lf + # Everything else auto-detects (default). * text=auto From 8e8b45a20e29ec59cce6af10d010843b81e3f7d6 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Sun, 7 Jun 2026 02:44:15 +0200 Subject: [PATCH 2/4] test: make command-adjacent tests hermetic --- crates/tui/src/commands/init.rs | 10 +++ crates/tui/src/commands/skills.rs | 28 +++++++ crates/tui/src/project_context.rs | 24 ++++++ crates/tui/src/project_context_cache.rs | 2 +- crates/tui/src/runtime_api.rs | 2 + crates/tui/src/skills/mod.rs | 98 +------------------------ crates/tui/src/tools/diagnostics.rs | 1 + crates/tui/src/tools/git.rs | 1 + crates/tui/src/tools/git_history.rs | 1 + crates/tui/src/tools/subagent/tests.rs | 11 +++ crates/tui/src/tui/ui/tests.rs | 11 +++ 11 files changed, 92 insertions(+), 97 deletions(-) diff --git a/crates/tui/src/commands/init.rs b/crates/tui/src/commands/init.rs index 3ce9d092f..3b24f23ed 100644 --- a/crates/tui/src/commands/init.rs +++ b/crates/tui/src/commands/init.rs @@ -1077,6 +1077,11 @@ mod tests { .current_dir(tmpdir.path()) .output() .unwrap(); + Command::new("git") + .args(["config", "core.autocrlf", "false"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); Command::new("git") .args(["checkout", "-b", "main"]) .current_dir(tmpdir.path()) @@ -1120,6 +1125,11 @@ mod tests { .current_dir(tmpdir.path()) .output() .unwrap(); + Command::new("git") + .args(["config", "core.autocrlf", "false"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); Command::new("git") .args(["checkout", "-b", "main"]) .current_dir(tmpdir.path()) diff --git a/crates/tui/src/commands/skills.rs b/crates/tui/src/commands/skills.rs index e852d030a..298d17451 100644 --- a/crates/tui/src/commands/skills.rs +++ b/crates/tui/src/commands/skills.rs @@ -13,10 +13,32 @@ use crate::tui::history::HistoryCell; use super::CommandResult; +#[cfg(test)] +thread_local! { + static TEST_HOME_DIR: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +#[cfg(not(test))] fn discover_visible_skills(app: &App) -> SkillRegistry { crate::skills::discover_for_workspace_and_dir(&app.workspace, &app.skills_dir) } +#[cfg(test)] +fn discover_visible_skills(app: &App) -> SkillRegistry { + TEST_HOME_DIR.with(|home| { + if let Some(home) = home.borrow().as_deref() { + crate::skills::discover_for_workspace_and_dir_with_home( + &app.workspace, + &app.skills_dir, + Some(home), + ) + } else { + crate::skills::discover_for_workspace_and_dir(&app.workspace, &app.skills_dir) + } + }) +} + fn render_skill_warnings(registry: &SkillRegistry) -> String { if registry.warnings().is_empty() { return String::new(); @@ -601,6 +623,7 @@ mod tests { _lock: std::sync::MutexGuard<'static, ()>, home_prev: Option, userprofile_prev: Option, + test_home_prev: Option, } impl IsolatedHome { @@ -616,10 +639,12 @@ mod tests { std::env::set_var("HOME", &home); std::env::set_var("USERPROFILE", &home); } + let test_home_prev = TEST_HOME_DIR.with(|slot| slot.replace(Some(home))); Self { _lock: lock, home_prev, userprofile_prev, + test_home_prev, } } @@ -634,6 +659,9 @@ mod tests { impl Drop for IsolatedHome { fn drop(&mut self) { + TEST_HOME_DIR.with(|slot| { + *slot.borrow_mut() = self.test_home_prev.take(); + }); // SAFETY: the shared test env mutex is still held while Drop runs. unsafe { Self::restore_var("HOME", self.home_prev.take()); diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 016bd8cec..f8dfa6d6c 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -684,12 +684,20 @@ fn load_project_context_with_parents_and_home( home_dir: Option<&Path>, ) -> ProjectContext { let mut ctx = load_project_context(workspace); + let parent_search_stop = project_context_parent_search_stop_dir(); // If no context found in workspace, check parent directories if !ctx.has_instructions() { let mut current = workspace.parent(); while let Some(parent) = current { + if parent_search_stop + .as_deref() + .is_some_and(|stop| paths_equal_after_canonicalizing(parent, stop)) + { + break; + } + let parent_ctx = load_project_context(parent); ctx.warnings.extend(parent_ctx.warnings.iter().cloned()); if parent_ctx.has_instructions() { @@ -768,9 +776,17 @@ pub(crate) fn project_context_cache_candidate_paths( ) -> Vec { let workspace = canonicalize_workspace_or_keep(workspace); let mut paths = Vec::new(); + let parent_search_stop = project_context_parent_search_stop_dir(); let mut current = Some(workspace.as_path()); while let Some(dir) = current { + if parent_search_stop + .as_deref() + .is_some_and(|stop| paths_equal_after_canonicalizing(dir, stop)) + { + break; + } + for filename in PROJECT_CONTEXT_FILES { paths.push(dir.join(filename)); } @@ -836,6 +852,14 @@ fn canonicalize_workspace_or_keep(workspace: &Path) -> PathBuf { fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()) } +fn project_context_parent_search_stop_dir() -> Option { + dirs::home_dir().map(|home| canonicalize_workspace_or_keep(&home)) +} + +fn paths_equal_after_canonicalizing(left: &Path, right: &Path) -> bool { + canonicalize_workspace_or_keep(left) == canonicalize_workspace_or_keep(right) +} + /// Combine global user-wide preferences with a project-local /// AGENTS.md/CLAUDE.md/instructions.md. Global comes first so /// workspace-specific rules can override it — the model reads in declared diff --git a/crates/tui/src/project_context_cache.rs b/crates/tui/src/project_context_cache.rs index 722c64cb3..aefb3adf0 100644 --- a/crates/tui/src/project_context_cache.rs +++ b/crates/tui/src/project_context_cache.rs @@ -181,7 +181,7 @@ mod tests { let plain = compute_cache_key(workspace.path(), Some(home.path())); let dotted = compute_cache_key(&workspace.path().join("."), Some(home.path())); - assert_eq!(plain, dotted); + assert_eq!(plain.workspace, dotted.workspace); } #[test] diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index bf782495d..d86645443 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -2506,6 +2506,7 @@ mod tests { let repo = tmp.path().join("repo"); fs::create_dir_all(&repo)?; run_test_git(&repo, &["init", "-b", "main"])?; + run_test_git(&repo, &["config", "core.autocrlf", "false"])?; fs::write(repo.join("tracked.txt"), "clean\n")?; run_test_git(&repo, &["add", "tracked.txt"])?; run_test_git( @@ -2987,6 +2988,7 @@ mod tests { let repo = tmp.path().join("repo"); fs::create_dir_all(&repo)?; run_test_git(&repo, &["init", "-b", "feature/agent"])?; + run_test_git(&repo, &["config", "core.autocrlf", "false"])?; fs::write(repo.join("README.md"), "branch visibility\n")?; run_test_git(&repo, &["add", "README.md"])?; run_test_git( diff --git a/crates/tui/src/skills/mod.rs b/crates/tui/src/skills/mod.rs index 783d4586a..37ae3a60b 100644 --- a/crates/tui/src/skills/mod.rs +++ b/crates/tui/src/skills/mod.rs @@ -17,7 +17,6 @@ pub use system::{install_system_skills, is_bundled_skill_name}; use std::fs; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; use std::collections::{HashMap, HashSet}; use crate::logging; @@ -27,7 +26,6 @@ const MAX_AVAILABLE_SKILLS_CHARS: usize = 12_000; // === Defaults === -#[allow(dead_code)] #[must_use] pub fn default_skills_dir() -> PathBuf { dirs::home_dir().map_or_else( @@ -42,17 +40,6 @@ pub fn agents_global_skills_dir() -> Option { dirs::home_dir().map(|p| p.join(".agents").join("skills")) } -/// Global Claude-compatible skills directory (`~/.claude/skills`). The -/// SKILL.md frontmatter convention is shared across the broader Claude -/// ecosystem, so picking up the global path lets users inherit skills -/// they already installed for other Claude-compatible tools without -/// re-authoring them in DeepSeek's native layout (#902). -#[allow(dead_code)] -#[must_use] -pub fn claude_global_skills_dir() -> Option { - dirs::home_dir().map(|p| p.join(".claude").join("skills")) -} - // === Types === /// Parsed representation of a SKILL.md definition. @@ -445,40 +432,6 @@ impl SkillRegistry { } } -/// Render a compact model-visible skills block. -/// -/// The full `SKILL.md` body is intentionally not included here. This mirrors -/// Resolve the active skills directory given a workspace, mirroring the -/// hierarchy `App::new` walks: `/.agents/skills` → -/// `/skills` → [`agents_global_skills_dir`] (`~/.agents/skills`, -/// when present) → [`default_skills_dir`] (`~/.codewhale/skills`). -/// Returns the first directory that exists, or the global default -/// (which itself falls back to `/tmp/codewhale/skills` if the user -/// has no home directory). -/// -/// Kept for callers that want a single canonical directory (e.g. -/// "where do I install a new skill?"). For session-time discovery -/// that should pick up cross-tool skill folders too, use -/// [`skills_directories`] / [`discover_in_workspace`] (#432). -#[must_use] -#[allow(dead_code)] // Intentionally kept for the "single canonical install dir" surface; live callers use discover_in_workspace. -pub fn resolve_skills_dir(workspace: &Path) -> PathBuf { - let agents = workspace.join(".agents").join("skills"); - if agents.exists() { - return agents; - } - let local = workspace.join("skills"); - if local.exists() { - return local; - } - if let Some(global_agents) = agents_global_skills_dir() - && global_agents.exists() - { - return global_agents; - } - default_skills_dir() -} - /// Resolve every candidate skills directory for a workspace, in /// precedence order — most specific first. Used for session-time /// skill discovery so the model sees skills that originated in @@ -494,7 +447,7 @@ pub fn resolve_skills_dir(workspace: &Path) -> PathBuf { /// 5. `/.cursor/skills` — Cursor interop. /// 6. `/.codewhale/skills` — CodeWhale workspace skills. /// 7. [`agents_global_skills_dir`] — agentskills.io global. -/// 8. [`claude_global_skills_dir`] — Claude-ecosystem global (#902). +/// 8. `~/.claude/skills` — Claude-ecosystem global (#902). /// 9. `~/.codewhale/skills` — CodeWhale global, primary install target. /// 10. `~/.deepseek/skills` — legacy DeepSeek global fallback. /// @@ -621,7 +574,7 @@ pub(crate) fn discover_from_directories(dirs: impl IntoIterator) } #[cfg(test)] -fn discover_for_workspace_and_dir_with_home( +pub(crate) fn discover_for_workspace_and_dir_with_home( workspace: &Path, skills_dir: &Path, home_dir: Option<&Path>, @@ -746,44 +699,6 @@ fn truncate_for_prompt(value: &str, max_chars: usize) -> String { truncated } -// === CLI Helpers === - -#[allow(dead_code)] // CLI utility for future use -pub fn list(skills_dir: &Path) -> Result<()> { - if !skills_dir.exists() { - println!("No skills directory found at {}", skills_dir.display()); - return Ok(()); - } - - let mut entries = Vec::new(); - for entry in fs::read_dir(skills_dir)? { - let entry = entry?; - if entry.file_type()?.is_dir() { - entries.push(entry.file_name().to_string_lossy().to_string()); - } - } - - if entries.is_empty() { - println!("No skills found in {}", skills_dir.display()); - return Ok(()); - } - - entries.sort(); - for entry in entries { - println!("{entry}"); - } - Ok(()) -} - -#[allow(dead_code)] // CLI utility for future use -pub fn show(skills_dir: &Path, name: &str) -> Result<()> { - let path = skills_dir.join(name).join("SKILL.md"); - let contents = - fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?; - println!("{contents}"); - Ok(()) -} - #[cfg(test)] mod tests { use tempfile::TempDir; @@ -1042,15 +957,6 @@ mod tests { ); } - #[test] - fn claude_global_skills_dir_returns_home_relative_path() { - // Smoke test for the #902 helper. We don't assert the exact path - // because dirs::home_dir() is host-dependent; we just pin the - // suffix shape so a future refactor can't silently rename it. - let path = super::claude_global_skills_dir().expect("home dir resolves on test host"); - assert!(path.ends_with(".claude/skills") || path.ends_with(r".claude\skills")); - } - #[test] fn existing_skill_dirs_orders_globals_agents_then_claude_then_deepseek() { // Pins the precedence among the three global skill roots (#902). diff --git a/crates/tui/src/tools/diagnostics.rs b/crates/tui/src/tools/diagnostics.rs index 6a3d8db34..f4097d5d4 100644 --- a/crates/tui/src/tools/diagnostics.rs +++ b/crates/tui/src/tools/diagnostics.rs @@ -243,6 +243,7 @@ mod tests { assert!(status.success(), "git {args:?} failed"); }; run(&["init", "-q"]); + run(&["config", "core.autocrlf", "false"]); run(&["config", "user.email", "test@example.com"]); run(&["config", "user.name", "Test User"]); fs::write(root.join("README.md"), "init\n").expect("write"); diff --git a/crates/tui/src/tools/git.rs b/crates/tui/src/tools/git.rs index 27540df4a..ae7e7f934 100644 --- a/crates/tui/src/tools/git.rs +++ b/crates/tui/src/tools/git.rs @@ -333,6 +333,7 @@ mod tests { }; run(&["init", "-q"]); + run(&["config", "core.autocrlf", "false"]); run(&["config", "user.email", "test@example.com"]); run(&["config", "user.name", "Test User"]); } diff --git a/crates/tui/src/tools/git_history.rs b/crates/tui/src/tools/git_history.rs index 2062a9da7..d3dd99eaa 100644 --- a/crates/tui/src/tools/git_history.rs +++ b/crates/tui/src/tools/git_history.rs @@ -518,6 +518,7 @@ mod tests { fn init_git_repo(root: &Path) { run_git(root, &["init", "-q"]); + run_git(root, &["config", "core.autocrlf", "false"]); run_git(root, &["config", "user.email", "test@example.com"]); run_git(root, &["config", "user.name", "Test User"]); } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 0f2b00b1b..90da9f6c5 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -43,6 +43,17 @@ fn init_subagent_git_repo() -> tempfile::TempDir { String::from_utf8_lossy(&init.stderr) ); + let autocrlf = Command::new("git") + .args(["config", "core.autocrlf", "false"]) + .current_dir(dir.path()) + .output() + .expect("git config core.autocrlf should run"); + assert!( + autocrlf.status.success(), + "git config core.autocrlf failed: {}", + String::from_utf8_lossy(&autocrlf.stderr) + ); + let commit = Command::new("git") .args([ "-c", diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 024af9733..908c62dd5 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -3062,6 +3062,17 @@ fn init_git_repo() -> TempDir { String::from_utf8_lossy(&init.stderr) ); + let autocrlf = Command::new("git") + .args(["config", "core.autocrlf", "false"]) + .current_dir(dir.path()) + .output() + .expect("git config core.autocrlf should run"); + assert!( + autocrlf.status.success(), + "git config core.autocrlf failed: {}", + String::from_utf8_lossy(&autocrlf.stderr) + ); + let commit = Command::new("git") .args([ "-c", From 18df8db056b0345a8f26f9f70060f2f92aef85c3 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Sun, 7 Jun 2026 02:44:29 +0200 Subject: [PATCH 3/4] refactor: extract neutral command support --- crates/tui/src/command_safety.rs | 202 +--- crates/tui/src/commands/config.rs | 1117 +--------------------- crates/tui/src/commands/mod.rs | 68 -- crates/tui/src/commands/network.rs | 6 +- crates/tui/src/commands/user_commands.rs | 39 - crates/tui/src/config_persistence.rs | 461 +++++++++ crates/tui/src/config_ui.rs | 4 +- crates/tui/src/main.rs | 10 +- crates/tui/src/model_routing.rs | 569 +++++++++++ crates/tui/src/runtime_threads.rs | 2 +- crates/tui/src/tools/subagent/mod.rs | 6 +- crates/tui/src/tui/auto_router.rs | 10 +- crates/tui/src/tui/ui.rs | 14 +- 13 files changed, 1096 insertions(+), 1412 deletions(-) create mode 100644 crates/tui/src/config_persistence.rs create mode 100644 crates/tui/src/model_routing.rs diff --git a/crates/tui/src/command_safety.rs b/crates/tui/src/command_safety.rs index da7835395..6f55660d2 100644 --- a/crates/tui/src/command_safety.rs +++ b/crates/tui/src/command_safety.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - //! Command safety analysis for shell execution //! //! This module provides pre-execution analysis of shell commands to detect @@ -374,43 +372,38 @@ pub enum SafetyLevel { #[derive(Debug, Clone)] pub struct SafetyAnalysis { pub level: SafetyLevel, - pub command: String, pub reasons: Vec, pub suggestions: Vec, } impl SafetyAnalysis { - pub fn safe(command: &str) -> Self { + pub fn safe(_command: &str) -> Self { Self { level: SafetyLevel::Safe, - command: command.to_string(), reasons: vec!["Command is read-only".to_string()], suggestions: vec![], } } - pub fn workspace_safe(command: &str, reason: &str) -> Self { + pub fn workspace_safe(_command: &str, reason: &str) -> Self { Self { level: SafetyLevel::WorkspaceSafe, - command: command.to_string(), reasons: vec![reason.to_string()], suggestions: vec![], } } - pub fn requires_approval(command: &str, reasons: Vec) -> Self { + pub fn requires_approval(_command: &str, reasons: Vec) -> Self { Self { level: SafetyLevel::RequiresApproval, - command: command.to_string(), reasons, suggestions: vec![], } } - pub fn dangerous(command: &str, reasons: Vec, suggestions: Vec) -> Self { + pub fn dangerous(_command: &str, reasons: Vec, suggestions: Vec) -> Self { Self { level: SafetyLevel::Dangerous, - command: command.to_string(), reasons, suggestions, } @@ -1012,72 +1005,6 @@ fn is_workspace_safe_command(command: &str) -> bool { false } -/// Check if a path escapes the workspace -pub fn path_escapes_workspace(path: &str, workspace: &str) -> bool { - let path_lower = normalize_safety_path(path); - let workspace_lower = normalize_safety_path(workspace); - - // Check for obvious escape patterns - if path_lower.starts_with("~/") || path_lower.starts_with("$home") { - return true; - } - - if is_absolute_safety_path(&path_lower) { - let path_components = lexical_components(&path_lower); - let workspace_components = lexical_components(&workspace_lower); - return !components_start_with(&path_components, &workspace_components); - } - - // Walk the path components. Track depth relative to the workspace root: - // non-`..` components increment depth, `..` components decrement it. - // If depth ever goes negative, the path escapes the workspace boundary. - // This correctly distinguishes genuine traversal like `../outside` from - // names that happen to contain consecutive dots like `foo..bar`. - let mut depth: i32 = 0; - for component in path_lower.split('/') { - match component { - "" | "." => {} - ".." => depth -= 1, - _ => depth += 1, - } - if depth < 0 { - return true; - } - } - - false -} - -fn normalize_safety_path(path: &str) -> String { - path.trim().replace('\\', "/").to_lowercase() -} - -fn is_absolute_safety_path(path: &str) -> bool { - path.starts_with('/') - || path - .as_bytes() - .get(1..3) - .is_some_and(|bytes| bytes[0] == b':' && bytes[1] == b'/') -} - -fn lexical_components(path: &str) -> Vec<&str> { - let mut components = Vec::new(); - for component in path.split('/') { - match component { - "" | "." => {} - ".." => { - components.pop(); - } - _ => components.push(component), - } - } - components -} - -fn components_start_with(path: &[&str], prefix: &[&str]) -> bool { - path.len() >= prefix.len() && path.iter().zip(prefix.iter()).all(|(a, b)| a == b) -} - /// Parse a command and extract the primary command name pub fn extract_primary_command(command: &str) -> Option<&str> { let trimmed = command.trim(); @@ -1093,56 +1020,6 @@ pub fn extract_primary_command(command: &str) -> Option<&str> { } } -/// Categorize commands into groups -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CommandCategory { - FileSystem, - Network, - Process, - Package, - Git, - Build, - System, - Shell, - Other, -} - -/// Get the category of a command -pub fn categorize_command(command: &str) -> CommandCategory { - let primary = match extract_primary_command(command) { - Some(cmd) => cmd.to_lowercase(), - None => return CommandCategory::Other, - }; - - match primary.as_str() { - "ls" | "dir" | "cat" | "head" | "tail" | "less" | "more" | "cp" | "mv" | "rm" | "mkdir" - | "rmdir" | "touch" | "chmod" | "chown" | "ln" | "find" | "fd" | "locate" | "stat" - | "file" => CommandCategory::FileSystem, - - "curl" | "wget" | "fetch" | "nc" | "netcat" | "ssh" | "scp" | "sftp" | "rsync" | "ftp" - | "ping" | "traceroute" | "nslookup" | "dig" | "host" | "nmap" => CommandCategory::Network, - - "ps" | "top" | "htop" | "kill" | "killall" | "pkill" | "pgrep" | "nice" | "renice" - | "nohup" | "timeout" => CommandCategory::Process, - - "npm" | "yarn" | "pnpm" | "pip" | "pip3" | "brew" | "apt" | "apt-get" | "yum" | "dnf" - | "pacman" => CommandCategory::Package, - - "git" | "gh" | "hub" => CommandCategory::Git, - - "make" | "cmake" | "ninja" | "meson" | "cargo" | "go" | "gcc" | "g++" | "clang" - | "rustc" | "javac" | "tsc" => CommandCategory::Build, - - "sudo" | "su" | "systemctl" | "service" | "shutdown" | "reboot" | "mount" | "umount" - | "fdisk" | "parted" => CommandCategory::System, - - "bash" | "sh" | "zsh" | "fish" | "csh" | "tcsh" | "dash" | "source" | "." | "exec" - | "eval" => CommandCategory::Shell, - - _ => CommandCategory::Other, - } -} - // === Unit Tests === #[cfg(test)] @@ -1321,62 +1198,6 @@ mod tests { ); } - #[test] - fn test_path_escapes_workspace() { - assert!(path_escapes_workspace("/etc/passwd", "/home/user/project")); - assert!(path_escapes_workspace("~/secret", "/home/user/project")); - assert!(!path_escapes_workspace( - "./src/main.rs", - "/home/user/project" - )); - } - - #[test] - fn test_path_escapes_workspace_doesnt_flag_double_dot_in_names() { - // Names like `foo..bar` should NOT be flagged as path traversal - assert!(!path_escapes_workspace( - "some..file.txt", - "/home/user/project" - )); - assert!(!path_escapes_workspace( - "./dir..name/file.txt", - "/home/user/project" - )); - } - - #[test] - fn test_path_escapes_workspace_detects_genuine_traversal() { - assert!(path_escapes_workspace("../outside", "/home/user/project")); - assert!(path_escapes_workspace( - "..\\outside", - "C:\\Users\\me\\project" - )); - assert!(path_escapes_workspace( - "./subdir/../../etc/passwd", - "/home/user/project" - )); - assert!(path_escapes_workspace( - "/home/user/project/../secret", - "/home/user/project" - )); - assert!(path_escapes_workspace( - "C:\\Users\\me\\project\\..\\secret", - "C:\\Users\\me\\project" - )); - } - - #[test] - fn test_path_escapes_workspace_allows_absolute_workspace_children() { - assert!(!path_escapes_workspace( - "/home/user/project/src/main.rs", - "/home/user/project" - )); - assert!(!path_escapes_workspace( - "C:\\Users\\me\\project\\src\\main.rs", - "C:\\Users\\me\\project" - )); - } - #[test] fn test_extract_primary_command() { assert_eq!(extract_primary_command("ls -la"), Some("ls")); @@ -1387,21 +1208,6 @@ mod tests { assert_eq!(extract_primary_command(" git status "), Some("git")); } - #[test] - fn test_categorize_command() { - assert_eq!(categorize_command("ls -la"), CommandCategory::FileSystem); - assert_eq!( - categorize_command("curl https://example.com"), - CommandCategory::Network - ); - assert_eq!(categorize_command("git status"), CommandCategory::Git); - assert_eq!(categorize_command("npm install"), CommandCategory::Package); - assert_eq!( - categorize_command("sudo apt update"), - CommandCategory::System - ); - } - // ── classify_command tests ──────────────────────────────────────────────── /// Helper: split a string on whitespace into a `Vec<&str>` and call diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 5ef5e93b7..28c532681 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -1,26 +1,25 @@ //! Config commands: config, settings, mode switches, trust, logout -use std::path::{Path, PathBuf}; -use std::time::Duration; - use super::CommandResult; -use crate::client::DeepSeekClient; use crate::config::{ ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_STREAM_CHUNK_TIMEOUT_SECS, DEFAULT_XIAOMI_MIMO_BASE_URL, MAX_STREAM_CHUNK_TIMEOUT_SECS, MIN_STREAM_CHUNK_TIMEOUT_SECS, - XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key, effective_home_dir, - expand_path, normalize_model_name_for_provider, + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key, + normalize_model_name_for_provider, +}; +use crate::config_persistence::{ + persist_provider_base_url_key, persist_root_bool_key, persist_root_string_key, + persist_tui_integer_key, }; use crate::config_ui::{ConfigUiMode, parse_mode}; -use crate::llm_client::LlmClient; use crate::localization::resolve_locale; -use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt}; use crate::settings::Settings; use crate::tui::app::{ App, AppAction, AppMode, OnboardingState, ReasoningEffort, SidebarFocus, VimMode, }; use crate::tui::approval::ApprovalMode; use anyhow::Result; +use std::path::{Path, PathBuf}; /// Open the interactive config editor. /// @@ -373,226 +372,6 @@ fn sidebar_status_message(focus: SidebarFocus) -> &'static str { } } -/// Persist `tui.status_items` to `~/.codewhale/config.toml` without disturbing -/// the rest of the file. We round-trip through `toml::Value` so any keys we -/// don't know about (provider blocks, MCP, etc.) survive the write -/// untouched. -/// -/// Returns the path written so the caller can surface it in a status toast. -pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Result { - use anyhow::Context; - use std::fs; - - let path = config_toml_path(None)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create config directory {}", parent.display()))?; - } - - let mut doc: toml::Value = if path.exists() { - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))? - } else { - toml::Value::Table(toml::value::Table::new()) - }; - - let table = doc - .as_table_mut() - .context("config.toml root must be a table")?; - let tui_entry = table - .entry("tui".to_string()) - .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); - let tui_table = tui_entry - .as_table_mut() - .context("`tui` section in config.toml must be a table")?; - let array = items - .iter() - .map(|item| toml::Value::String(item.key().to_string())) - .collect::>(); - tui_table.insert("status_items".to_string(), toml::Value::Array(array)); - - let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; - fs::write(&path, body) - .with_context(|| format!("failed to write config at {}", path.display()))?; - Ok(path) -} - -pub fn persist_root_string_key( - config_path: Option<&Path>, - key: &str, - value: &str, -) -> anyhow::Result { - use anyhow::Context; - use std::fs; - - let path = config_toml_path(config_path)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create config directory {}", parent.display()))?; - } - - let mut doc: toml::Value = if path.exists() { - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))? - } else { - toml::Value::Table(toml::value::Table::new()) - }; - let table = doc - .as_table_mut() - .context("config.toml root must be a table")?; - table.insert(key.to_string(), toml::Value::String(value.to_string())); - let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; - fs::write(&path, body) - .with_context(|| format!("failed to write config at {}", path.display()))?; - Ok(path) -} - -fn persist_root_bool_key( - config_path: Option<&Path>, - key: &str, - value: bool, -) -> anyhow::Result { - use anyhow::Context; - use std::fs; - - let path = config_toml_path(config_path)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create config directory {}", parent.display()))?; - } - - let mut doc: toml::Value = if path.exists() { - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))? - } else { - toml::Value::Table(toml::value::Table::new()) - }; - let table = doc - .as_table_mut() - .context("config.toml root must be a table")?; - table.insert(key.to_string(), toml::Value::Boolean(value)); - let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; - fs::write(&path, body) - .with_context(|| format!("failed to write config at {}", path.display()))?; - Ok(path) -} - -fn persist_tui_integer_key( - config_path: Option<&Path>, - key: &str, - value: u64, -) -> anyhow::Result { - use anyhow::Context; - use std::fs; - - let path = config_toml_path(config_path)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create config directory {}", parent.display()))?; - } - - let mut doc: toml::Value = if path.exists() { - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))? - } else { - toml::Value::Table(toml::value::Table::new()) - }; - let table = doc - .as_table_mut() - .context("config.toml root must be a table")?; - let tui_entry = table - .entry("tui".to_string()) - .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); - let tui_table = tui_entry - .as_table_mut() - .context("`tui` section in config.toml must be a table")?; - let value = i64::try_from(value).context("integer value is too large for TOML")?; - tui_table.insert(key.to_string(), toml::Value::Integer(value)); - let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; - fs::write(&path, body) - .with_context(|| format!("failed to write config at {}", path.display()))?; - Ok(path) -} - -fn persist_provider_base_url_key( - config_path: Option<&Path>, - provider: ApiProvider, - value: &str, -) -> anyhow::Result { - use anyhow::Context; - use std::fs; - - let path = config_toml_path(config_path)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create config directory {}", parent.display()))?; - } - - let mut doc: toml::Value = if path.exists() { - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))? - } else { - toml::Value::Table(toml::value::Table::new()) - }; - let table = doc - .as_table_mut() - .context("config.toml root must be a table")?; - let providers = table - .entry("providers".to_string()) - .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) - .as_table_mut() - .context("`providers` must be a table")?; - let provider_key = provider_base_url_table_key(provider)?; - let entry = providers - .entry(provider_key.to_string()) - .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) - .as_table_mut() - .with_context(|| format!("`providers.{provider_key}` must be a table"))?; - entry.insert( - "base_url".to_string(), - toml::Value::String(value.to_string()), - ); - - let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; - fs::write(&path, body) - .with_context(|| format!("failed to write config at {}", path.display()))?; - Ok(path) -} - -fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static str> { - match provider { - ApiProvider::Deepseek | ApiProvider::DeepseekCN => { - anyhow::bail!("DeepSeek uses the root base_url setting") - } - ApiProvider::NvidiaNim => Ok("nvidia_nim"), - ApiProvider::Openai => Ok("openai"), - ApiProvider::Atlascloud => Ok("atlascloud"), - ApiProvider::WanjieArk => Ok("wanjie_ark"), - ApiProvider::Volcengine => Ok("volcengine"), - ApiProvider::Openrouter => Ok("openrouter"), - ApiProvider::XiaomiMimo => Ok("xiaomi_mimo"), - ApiProvider::Novita => Ok("novita"), - ApiProvider::Fireworks => Ok("fireworks"), - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => Ok("siliconflow"), - ApiProvider::Arcee => Ok("arcee"), - ApiProvider::Huggingface => Ok("huggingface"), - ApiProvider::Moonshot => Ok("moonshot"), - ApiProvider::Sglang => Ok("sglang"), - ApiProvider::Vllm => Ok("vllm"), - ApiProvider::Ollama => Ok("ollama"), - } -} - fn resolve_provider_url_value(provider: ApiProvider, value: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { @@ -638,39 +417,6 @@ fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String { } } -/// Resolve the path to `~/.codewhale/config.toml` (or -/// `$CODEWHALE_CONFIG_PATH` / `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we -/// never write to a different file than the one we read. -pub(super) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result { - use anyhow::Context; - if let Some(path) = config_path { - return Ok(expand_path(path.to_string_lossy().as_ref())); - } - if let Ok(env) = std::env::var("CODEWHALE_CONFIG_PATH") { - let trimmed = env.trim(); - if !trimmed.is_empty() { - return Ok(PathBuf::from(trimmed)); - } - } - if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") { - let trimmed = env.trim(); - if !trimmed.is_empty() { - return Ok(PathBuf::from(trimmed)); - } - } - let home = - effective_home_dir().context("failed to resolve home directory for config.toml path")?; - let primary = home.join(".codewhale").join("config.toml"); - if primary.exists() { - return Ok(primary); - } - let legacy = home.join(".deepseek").join("config.toml"); - if legacy.exists() { - return Ok(legacy); - } - Ok(primary) -} - /// Modify a setting at runtime pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult { let key = key.to_lowercase(); @@ -1098,40 +844,6 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> } } -/// Modify a setting at runtime -#[allow(dead_code)] -pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult { - let Some(args) = args else { - let available = Settings::available_settings() - .iter() - .map(|(k, d)| format!(" {k}: {d}")) - .collect::>() - .join("\n"); - return CommandResult::message(format!( - "Usage: /set \n\n\ - Available settings:\n{available}\n\n\ - Session-only settings:\n \ - model: Current model\n \ - approval_mode: auto | suggest | never\n\n\ - Add --save to persist to settings file." - )); - }; - - let parts: Vec<&str> = args.splitn(2, ' ').collect(); - if parts.len() < 2 { - return CommandResult::error("Usage: /set "); - } - - let key = parts[0].to_lowercase(); - let (value, should_save) = if parts[1].ends_with(" --save") { - (parts[1].trim_end_matches(" --save").trim(), true) - } else { - (parts[1].trim(), false) - }; - - set_config_value(app, &key, value, should_save) -} - /// Select the TUI operating mode. pub fn mode(app: &mut App, arg: Option<&str>) -> CommandResult { let Some(arg) = arg.filter(|value| !value.trim().is_empty()) else { @@ -1350,393 +1062,6 @@ fn expand_tilde(raw: &str) -> String { raw.to_string() } -/// Auto-select a model based on request complexity. -/// -/// Short messages (<100 chars) → Flash (fast & cheap). -/// Long messages (>500 chars) → Pro (powerful reasoning). -/// Messages with complex keywords → Pro. -/// Default → Flash (cost savings). -pub fn auto_model_heuristic(input: &str, _current_model: &str) -> String { - auto_model_heuristic_with_bias(input, _current_model, false) -} - -/// `auto_model_heuristic` parameterised by the `[auto] cost_saving` opt-in -/// (#1207). When `cost_saving` is `true` the keyword set drops the borderline -/// triggers (`implement`, `analyze`) and the long-message length threshold -/// goes from 500 to 1000 — both shifts let "looks involved but might be a -/// one-liner" requests stay on Flash unless they actually look agentic. -pub fn auto_model_heuristic_with_bias( - input: &str, - _current_model: &str, - cost_saving: bool, -) -> String { - auto_model_heuristic_selection_with_bias(input, _current_model, cost_saving).model -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AutoModelHeuristicConfidence { - Decisive, - Ambiguous, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct AutoModelHeuristicSelection { - model: String, - confidence: AutoModelHeuristicConfidence, -} - -fn auto_model_heuristic_selection_with_bias( - input: &str, - _current_model: &str, - cost_saving: bool, -) -> AutoModelHeuristicSelection { - let len = input.chars().count(); - let lower = input.to_lowercase(); - let borderline_pro_keywords: &[&str] = &[ - "implement", - "analyze", - "\u{5b9e}\u{73b0}", // 实现 - "\u{5206}\u{6790}", // 分析 - "\u{5be6}\u{73fe}", // 實現 - ]; - let strong_match = COMPLEX_KEYWORDS - .iter() - .any(|kw| !borderline_pro_keywords.contains(kw) && lower.contains(kw)); - let borderline_match = borderline_pro_keywords.iter().any(|kw| lower.contains(kw)); - let pro_match = strong_match || (!cost_saving && borderline_match); - if pro_match { - return AutoModelHeuristicSelection { - model: "deepseek-v4-pro".to_string(), - confidence: AutoModelHeuristicConfidence::Decisive, - }; - } - // Short messages → Flash - if len < 100 { - return AutoModelHeuristicSelection { - model: "deepseek-v4-flash".to_string(), - confidence: AutoModelHeuristicConfidence::Decisive, - }; - } - // Long complex requests → Pro. Cost-saving raises the threshold so that - // long-but-routine requests (pasted logs, CSV-style data) don't escalate. - let long_threshold = if cost_saving { 1_000 } else { 500 }; - if len > long_threshold { - return AutoModelHeuristicSelection { - model: "deepseek-v4-pro".to_string(), - confidence: AutoModelHeuristicConfidence::Decisive, - }; - } - // Grey-zone default branch: Flash is the deterministic fallback, but the - // Flash router can still add value here because there was no strong local - // signal. - AutoModelHeuristicSelection { - model: "deepseek-v4-flash".to_string(), - confidence: AutoModelHeuristicConfidence::Ambiguous, - } -} - -/// Keywords that escalate `auto`-mode model selection to -/// `deepseek-v4-pro`. The Latin entries are lowercase (the caller -/// lowercases the message); CJK has no case so the literal form -/// matches as-is. -/// -/// Without the CJK entries, a Chinese-speaking user typing -/// "帮我重构这个模块" or "审计安全漏洞" silently fell through to the -/// short/long-message threshold and usually landed on Flash even -/// for tasks that obviously need Pro-grade reasoning. -const COMPLEX_KEYWORDS: &[&str] = &[ - // English (unchanged from the original list). - "refactor", - "architecture", - "design", - "debug", - "security", - "review", - "audit", - "migrate", - "optimize", - "rewrite", - "implement", - "analyze", - // Simplified Chinese. - "\u{91cd}\u{6784}", // 重构 - "\u{67b6}\u{6784}", // 架构 - "\u{8bbe}\u{8ba1}", // 设计 - "\u{8c03}\u{8bd5}", // 调试 - "\u{5b89}\u{5168}", // 安全 - "\u{5ba1}\u{67e5}", // 审查 - "\u{5ba1}\u{8ba1}", // 审计 - "\u{8fc1}\u{79fb}", // 迁移 - "\u{4f18}\u{5316}", // 优化 - "\u{91cd}\u{5199}", // 重写 - "\u{5b9e}\u{73b0}", // 实现 - "\u{5206}\u{6790}", // 分析 - // Traditional Chinese variants where they differ. - "\u{91cd}\u{69cb}", // 重構 - "\u{67b6}\u{69cb}", // 架構 - "\u{8a2d}\u{8a08}", // 設計 - "\u{8abf}\u{8a66}", // 調試 - "\u{5be9}\u{67e5}", // 審查 - "\u{5be9}\u{8a08}", // 審計 - "\u{9077}\u{79fb}", // 遷移 - "\u{512a}\u{5316}", // 優化 - "\u{91cd}\u{5beb}", // 重寫 - "\u{5be6}\u{73fe}", // 實現 -]; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AutoRouteRecommendation { - pub model: String, - pub reasoning_effort: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AutoRouteSource { - FlashRouter, - Heuristic, -} - -impl AutoRouteSource { - #[must_use] - pub fn label(self) -> &'static str { - match self { - AutoRouteSource::FlashRouter => "flash-router", - AutoRouteSource::Heuristic => "heuristic", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AutoRouteSelection { - pub model: String, - pub reasoning_effort: Option, - pub source: AutoRouteSource, -} - -pub const AUTO_MODEL_ROUTER_SYSTEM_PROMPT: &str = "\ -You are the codewhale auto-routing classifier. Return only compact JSON: \ -{\"model\":\"deepseek-v4-flash|deepseek-v4-pro\",\"thinking\":\"off|high|max\"}. \ -Use deepseek-v4-flash for trivial, conversational, status, or single-step work. \ -Use deepseek-v4-pro for coding, debugging, release work, multi-step tasks, high-risk decisions, \ -tool-heavy work, ambiguous requests, or anything that benefits from deeper reasoning. \ -Use thinking off only for trivial no-tool answers, high for ordinary reasoning, and max for \ -agentic, coding, multi-file, release, architecture, debugging, security, tool-heavy, or uncertain work."; - -/// Bias appended to the auto-router's system prompt when the user opts in to -/// `[auto] cost_saving = true` (#1207). Reverses the default tie-breaker for -/// genuinely ambiguous requests so Pro is reserved for tasks that clearly -/// require it; ordinary tweaks, config edits, and short reads stay on Flash. -pub const AUTO_MODEL_ROUTER_COST_SAVING_ADDENDUM: &str = "\ -\n\nCost-saving mode is ON. Prefer deepseek-v4-flash for any request that is \ -not unmistakably agentic, multi-step, architecture/design, security review, \ -debugging, or otherwise clearly out of Flash's capability. Resolve ambiguous \ -cases in favour of deepseek-v4-flash, not deepseek-v4-pro."; - -/// Parse the Flash router's JSON-only response. -/// -/// The runtime treats classifier output as untrusted: only known V4 model IDs -/// and supported reasoning tiers are accepted. Anything else falls back to the -/// deterministic heuristic. -pub fn parse_auto_route_recommendation(raw: &str) -> Option { - let json = extract_first_json_object(raw)?; - let value: serde_json::Value = serde_json::from_str(json).ok()?; - let model = value.get("model").and_then(serde_json::Value::as_str)?; - let model = normalize_auto_route_model(model)?; - let reasoning_effort = value - .get("thinking") - .or_else(|| value.get("reasoning_effort")) - .or_else(|| value.get("effort")) - .and_then(serde_json::Value::as_str) - .and_then(parse_auto_route_reasoning_effort); - - Some(AutoRouteRecommendation { - model: model.to_string(), - reasoning_effort, - }) -} - -fn extract_first_json_object(raw: &str) -> Option<&str> { - let start = raw.find('{')?; - let end = raw.rfind('}')?; - (end >= start).then_some(&raw[start..=end]) -} - -fn normalize_auto_route_model(model: &str) -> Option<&'static str> { - match model.trim().to_ascii_lowercase().as_str() { - "deepseek-v4-pro" | "v4-pro" | "pro" => Some("deepseek-v4-pro"), - "deepseek-v4-flash" | "v4-flash" | "flash" => Some("deepseek-v4-flash"), - _ => None, - } -} - -fn parse_auto_route_reasoning_effort(effort: &str) -> Option { - match effort.trim().to_ascii_lowercase().as_str() { - "off" | "disabled" | "none" | "false" => Some(ReasoningEffort::Off), - "low" | "minimal" | "medium" | "mid" => Some(ReasoningEffort::High), - "high" => Some(ReasoningEffort::High), - "max" | "maximum" | "xhigh" => Some(ReasoningEffort::Max), - _ => None, - } -} - -#[must_use] -pub fn normalize_auto_route_effort(effort: ReasoningEffort) -> ReasoningEffort { - match effort { - ReasoningEffort::Low | ReasoningEffort::Medium => ReasoningEffort::High, - other => other, - } -} - -pub async fn resolve_auto_route_with_flash( - config: &crate::config::Config, - latest_request: &str, - recent_context: &str, - selected_model_mode: &str, - selected_thinking_mode: &str, -) -> AutoRouteSelection { - let cost_saving = config.auto_cost_saving(); - let heuristic = - auto_model_heuristic_selection_with_bias(latest_request, selected_model_mode, cost_saving); - if heuristic.confidence == AutoModelHeuristicConfidence::Decisive { - return auto_route_from_heuristic(latest_request, heuristic); - } - - match auto_route_flash_recommendation( - config, - latest_request, - recent_context, - selected_model_mode, - selected_thinking_mode, - ) - .await - { - Ok(Some(recommendation)) => AutoRouteSelection { - model: recommendation.model, - reasoning_effort: recommendation.reasoning_effort, - source: AutoRouteSource::FlashRouter, - }, - Ok(None) | Err(_) => auto_route_from_heuristic(latest_request, heuristic), - } -} - -fn auto_route_from_heuristic( - latest_request: &str, - heuristic: AutoModelHeuristicSelection, -) -> AutoRouteSelection { - AutoRouteSelection { - model: heuristic.model, - reasoning_effort: Some(normalize_auto_route_effort(crate::auto_reasoning::select( - false, - latest_request, - ))), - source: AutoRouteSource::Heuristic, - } -} - -async fn auto_route_flash_recommendation( - config: &crate::config::Config, - latest_request: &str, - recent_context: &str, - selected_model_mode: &str, - selected_thinking_mode: &str, -) -> Result> { - if cfg!(test) { - return Ok(None); - } - - let client = DeepSeekClient::new(config)?; - let mut router_system = AUTO_MODEL_ROUTER_SYSTEM_PROMPT.to_string(); - if config.auto_cost_saving() { - router_system.push_str(AUTO_MODEL_ROUTER_COST_SAVING_ADDENDUM); - } - let request = MessageRequest { - model: "deepseek-v4-flash".to_string(), - messages: vec![Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: auto_route_prompt( - latest_request, - recent_context, - selected_model_mode, - selected_thinking_mode, - ), - cache_control: None, - }], - }], - max_tokens: 96, - system: Some(SystemPrompt::Text(router_system)), - tools: None, - tool_choice: None, - metadata: None, - thinking: None, - reasoning_effort: Some("off".to_string()), - stream: Some(false), - temperature: Some(0.0), - top_p: None, - }; - - let response = - tokio::time::timeout(Duration::from_secs(4), client.create_message(request)).await??; - Ok(parse_auto_route_recommendation(&message_response_text( - &response, - ))) -} - -fn auto_route_prompt( - latest_request: &str, - recent_context: &str, - selected_model_mode: &str, - selected_thinking_mode: &str, -) -> String { - format!( - "Session mode: agent\nSelected model mode: {}\nSelected thinking mode: {}\n\nRecent context:\n{}\n\nLatest user request:\n{}\n\nReturn JSON only.", - selected_model_mode, - selected_thinking_mode, - if recent_context.trim().is_empty() { - "No prior context." - } else { - recent_context - }, - truncate_for_auto_router(latest_request, 4_000) - ) -} - -fn message_response_text(response: &MessageResponse) -> String { - let mut out = String::new(); - for block in &response.content { - match block { - ContentBlock::Text { text, .. } | ContentBlock::ToolResult { content: text, .. } => { - append_router_text(&mut out, text); - } - ContentBlock::Thinking { thinking } => { - append_router_text(&mut out, thinking); - } - ContentBlock::ToolUse { name, .. } => { - append_router_text(&mut out, &format!("[tool call: {name}]")); - } - _ => {} - } - } - out -} - -fn append_router_text(out: &mut String, text: &str) { - if !out.is_empty() { - out.push('\n'); - } - out.push_str(text); -} - -fn truncate_for_auto_router(text: &str, max_chars: usize) -> String { - let mut chars = text.chars(); - let truncated: String = chars.by_ref().take(max_chars).collect(); - if chars.next().is_some() { - format!("{truncated}...") - } else { - truncated - } -} - /// Toggle LSP diagnostics on/off or show status. /// /// - `/lsp on` — enable inline LSP diagnostics @@ -2001,20 +1326,10 @@ mod tests { } #[test] - fn test_set_without_args_shows_usage() { - let mut app = create_test_app(); - let result = set_config(&mut app, None); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Usage: /set")); - assert!(msg.contains("Available settings:")); - } - - #[test] - fn test_set_model_updates_app_state() { + fn config_model_updates_app_state() { let mut app = create_test_app(); let _old_model = app.model.clone(); - let result = set_config(&mut app, Some("model deepseek-v4-flash")); + let result = config_command(&mut app, Some("model deepseek-v4-flash")); assert!(result.message.is_some()); let msg = result.message.unwrap(); assert!(msg.contains("model = deepseek-v4-flash")); @@ -2026,11 +1341,11 @@ mod tests { } #[test] - fn test_set_model_auto_enables_auto_thinking() { + fn config_model_auto_enables_auto_thinking() { let mut app = create_test_app(); app.reasoning_effort = ReasoningEffort::Off; - let result = set_config(&mut app, Some("model auto")); + let result = config_command(&mut app, Some("model auto")); assert!(result.message.is_some()); assert!(app.auto_model); @@ -2041,9 +1356,9 @@ mod tests { } #[test] - fn test_set_model_accepts_future_deepseek_model_id() { + fn config_model_accepts_future_deepseek_model_id() { let mut app = create_test_app(); - let result = set_config(&mut app, Some("model deepseek-v4")); + let result = config_command(&mut app, Some("model deepseek-v4")); assert!(result.message.is_some()); let msg = result.message.unwrap(); assert!(msg.contains("model = deepseek-v4")); @@ -2051,229 +1366,16 @@ mod tests { } #[test] - fn test_set_model_with_save_flag() { + fn config_model_with_save_flag() { let mut app = create_test_app(); - let _result = set_config(&mut app, Some("model deepseek-v4-flash --save")); + let _result = config_command(&mut app, Some("model deepseek-v4-flash --save")); // Note: This test may fail in environments where settings can't be saved // The important thing is that the model is updated assert_eq!(app.model, "deepseek-v4-flash"); } #[test] - fn auto_model_heuristic_chinese_keywords_route_to_pro() { - // Without these keywords, a Chinese user typing - // "帮我重构这个模块" (37 chars in chars().count() terms after - // the leading helper text) fell through to the short-message - // Flash branch even though the intent is obviously Pro-tier. - for msg in [ - "\u{5e2e}\u{6211}\u{91cd}\u{6784}\u{8fd9}\u{4e2a}\u{6a21}\u{5757}", // 帮我重构这个模块 - "\u{8bbe}\u{8ba1}\u{6570}\u{636e}\u{5e93}\u{67b6}\u{6784}", // 设计数据库架构 - "\u{8c03}\u{8bd5}\u{5d29}\u{6e83}\u{95ee}\u{9898}", // 调试崩溃问题 - "\u{5ba1}\u{8ba1}\u{5b89}\u{5168}\u{6f0f}\u{6d1e}", // 审计安全漏洞 - "\u{8fc1}\u{79fb}\u{5230}\u{65b0}\u{6846}\u{67b6}", // 迁移到新框架 - "\u{4f18}\u{5316}\u{6027}\u{80fd}\u{74f6}\u{9888}", // 优化性能瓶颈 - "\u{5206}\u{6790}\u{8fd9}\u{6bb5}\u{4ee3}\u{7801}", // 分析这段代码 - ] { - assert_eq!( - auto_model_heuristic(msg, "auto"), - "deepseek-v4-pro", - "expected Pro for `{msg}`", - ); - } - } - - #[test] - fn auto_model_heuristic_traditional_chinese_keywords_route_to_pro() { - for msg in [ - "\u{8acb}\u{91cd}\u{69cb}\u{6b64}\u{6a21}\u{7d44}", // 請重構此模組 - "\u{67b6}\u{69cb}\u{8a2d}\u{8a08}", // 架構設計 - "\u{4ee3}\u{78bc}\u{8abf}\u{8a66}", // 代碼調試 - "\u{5be9}\u{8a08}\u{6f0f}\u{6d1e}", // 審計漏洞 - "\u{9077}\u{79fb}\u{5230}\u{65b0}\u{67b6}\u{69cb}", // 遷移到新架構 - "\u{512a}\u{5316}\u{6027}\u{80fd}", // 優化性能 - "\u{91cd}\u{5beb}\u{4ee3}\u{78bc}", // 重寫代碼 - "\u{5be6}\u{73fe}\u{65b0}\u{529f}\u{80fd}", // 實現新功能 - ] { - assert_eq!( - auto_model_heuristic(msg, "auto"), - "deepseek-v4-pro", - "expected Pro for `{msg}`", - ); - } - } - - #[test] - fn auto_model_heuristic_short_chinese_chat_stays_on_flash() { - // Sanity: a short non-keyword Chinese message still falls - // through to the cost-saving Flash branch. - // "你好" (2 chars) — well under the 100-char Flash floor. - assert_eq!( - auto_model_heuristic("\u{4f60}\u{597d}", "auto"), - "deepseek-v4-flash", - ); - } - - #[test] - fn auto_heuristic_selection_marks_short_and_complex_routes_decisive() { - let short = auto_model_heuristic_selection_with_bias("yes", "auto", false); - assert_eq!(short.model, "deepseek-v4-flash"); - assert_eq!( - short.confidence, - AutoModelHeuristicConfidence::Decisive, - "trivial replies should skip the Flash router" - ); - - let complex = auto_model_heuristic_selection_with_bias( - "Please review the auth migration", - "auto", - false, - ); - assert_eq!(complex.model, "deepseek-v4-pro"); - assert_eq!( - complex.confidence, - AutoModelHeuristicConfidence::Decisive, - "strong complexity keywords should skip the Flash router" - ); - } - - #[test] - fn auto_heuristic_selection_leaves_default_branch_ambiguous_for_router() { - let request = - "Please update the configuration notes so each option has a clearer label. ".repeat(3); - assert!( - (100..500).contains(&request.chars().count()), - "test request must stay in the default grey zone" - ); - - let selection = auto_model_heuristic_selection_with_bias(&request, "auto", false); - assert_eq!(selection.model, "deepseek-v4-flash"); - assert_eq!( - selection.confidence, - AutoModelHeuristicConfidence::Ambiguous, - "only the grey-zone default branch should invoke the Flash router" - ); - } - - #[test] - fn auto_route_recommendation_parses_strict_json() { - let rec = - parse_auto_route_recommendation(r#"{"model":"deepseek-v4-pro","thinking":"max"}"#) - .expect("valid router response should parse"); - - assert_eq!(rec.model, "deepseek-v4-pro"); - assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::Max)); - } - - #[test] - fn auto_route_recommendation_accepts_wrapped_json_aliases() { - let rec = - parse_auto_route_recommendation(r#"route: {"model":"flash","reasoning_effort":"off"}"#) - .expect("wrapped router response should parse"); - - assert_eq!(rec.model, "deepseek-v4-flash"); - assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::Off)); - } - - #[test] - fn auto_route_recommendation_normalizes_legacy_low_medium_to_high() { - let rec = parse_auto_route_recommendation( - r#"{"model":"deepseek-v4-pro","reasoning_effort":"medium"}"#, - ) - .expect("medium should parse for back-compat"); - - assert_eq!(rec.model, "deepseek-v4-pro"); - assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::High)); - } - - #[test] - fn auto_route_recommendation_rejects_unknown_model() { - assert!( - parse_auto_route_recommendation(r#"{"model":"some-other-model","thinking":"max"}"#,) - .is_none() - ); - } - - #[test] - fn auto_heuristic_default_routes_implement_to_pro() { - // Default (no cost-saving): "implement" is one of the borderline - // keywords that escalates to Pro. - assert_eq!( - auto_model_heuristic_with_bias("Please implement a binary search", "auto", false), - "deepseek-v4-pro" - ); - } - - #[test] - fn auto_heuristic_cost_saving_keeps_borderline_keywords_on_flash() { - // Cost-saving: "implement" / "analyze" are no longer enough to escalate. - assert_eq!( - auto_model_heuristic_with_bias("Please implement a binary search", "auto", true), - "deepseek-v4-flash" - ); - assert_eq!( - auto_model_heuristic_with_bias("analyze this snippet", "auto", true), - "deepseek-v4-flash" - ); - } - - #[test] - fn auto_heuristic_strong_keywords_still_route_to_pro_under_cost_saving() { - // Cost-saving must NOT swallow obviously Pro-grade work. - for kw in [ - "refactor", - "architecture", - "design", - "debug", - "security", - "review", - "audit", - "migrate", - "optimize", - "rewrite", - ] { - let req = format!("Please {kw} this module"); - assert_eq!( - auto_model_heuristic_with_bias(&req, "auto", true), - "deepseek-v4-pro", - "expected Pro for strong keyword `{kw}` even in cost-saving mode" - ); - } - } - - #[test] - fn auto_heuristic_cost_saving_raises_long_message_threshold() { - // 600-char request is "long" by default (>500) → Pro, - // but stays Flash under cost-saving (threshold 1000). - let body = "filler sentence. ".repeat(40); // ~680 chars - assert_eq!( - auto_model_heuristic_with_bias(&body, "auto", false), - "deepseek-v4-pro" - ); - assert_eq!( - auto_model_heuristic_with_bias(&body, "auto", true), - "deepseek-v4-flash" - ); - } - - #[test] - fn config_auto_cost_saving_defaults_to_false() { - let cfg = crate::config::Config::default(); - assert!(!cfg.auto_cost_saving()); - } - - #[test] - fn config_auto_cost_saving_reads_table() { - let cfg = crate::config::Config { - auto: Some(crate::config::AutoConfig { - cost_saving: Some(true), - }), - ..Default::default() - }; - assert!(cfg.auto_cost_saving()); - } - - #[test] - fn test_set_default_mode_normal_save_reports_normalized_value() { + fn config_default_mode_normal_save_reports_normalized_value() { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() @@ -2287,7 +1389,7 @@ mod tests { let _guard = EnvGuard::new(&temp_root); let mut app = create_test_app(); - let result = set_config(&mut app, Some("default_mode normal --save")); + let result = config_command(&mut app, Some("default_mode normal --save")); let msg = result.message.unwrap(); assert_eq!(msg, "default_mode = agent (saved)"); assert_eq!(app.mode, AppMode::Agent); @@ -2343,7 +1445,7 @@ mod tests { Some("base_url https://example.internal.local/v1 --save"), ); let msg = result.message.unwrap(); - let saved_path = config_toml_path(None).unwrap(); + let saved_path = crate::config_persistence::config_toml_path(None).unwrap(); let saved = fs::read_to_string(&saved_path).unwrap(); assert_eq!( @@ -2721,7 +1823,7 @@ mod tests { let _guard = EnvGuard::new(&temp_root); let mut app = create_test_app(); - let result = set_config(&mut app, Some("theme grayscale --save")); + let result = config_command(&mut app, Some("theme grayscale --save")); let msg = result.message.unwrap(); assert_eq!(msg, "theme = grayscale (saved)"); @@ -2733,50 +1835,50 @@ mod tests { } #[test] - fn test_set_approval_mode_valid_values() { + fn config_approval_mode_valid_values() { let mut app = create_test_app(); // Test auto - let result = set_config(&mut app, Some("approval_mode auto")); + let result = config_command(&mut app, Some("approval_mode auto")); assert!(result.message.is_some()); assert_eq!(app.approval_mode, ApprovalMode::Auto); // Test suggest - let result = set_config(&mut app, Some("approval_mode suggest")); + let result = config_command(&mut app, Some("approval_mode suggest")); assert!(result.message.is_some()); assert_eq!(app.approval_mode, ApprovalMode::Suggest); // Test never - let result = set_config(&mut app, Some("approval_mode never")); + let result = config_command(&mut app, Some("approval_mode never")); assert!(result.message.is_some()); assert_eq!(app.approval_mode, ApprovalMode::Never); } #[test] - fn test_set_approval_mode_invalid_value() { + fn config_approval_mode_invalid_value() { let mut app = create_test_app(); - let result = set_config(&mut app, Some("approval_mode invalid")); + let result = config_command(&mut app, Some("approval_mode invalid")); assert!(result.message.is_some()); let msg = result.message.unwrap(); assert!(msg.contains("Invalid approval_mode")); } #[test] - fn test_set_without_save_flag() { + fn config_without_save_flag() { let _lock = lock_test_env(); let mut app = create_test_app(); - let result = set_config(&mut app, Some("auto_compact true")); + let result = config_command(&mut app, Some("auto_compact true")); assert!(result.message.is_some()); let msg = result.message.unwrap(); assert!(msg.contains("(session only")); } #[test] - fn test_set_composer_border_updates_live_app() { + fn config_composer_border_updates_live_app() { let _lock = lock_test_env(); let mut app = create_test_app(); app.composer_border = true; - let result = set_config(&mut app, Some("composer_border false")); + let result = config_command(&mut app, Some("composer_border false")); assert!(result.message.is_some()); assert!(!app.composer_border); @@ -2839,165 +1941,4 @@ mod tests { let updated = fs::read_to_string(config_path).unwrap(); assert!(!updated.contains("api_key")); } - - #[test] - fn test_set_invalid_setting() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - let _result = set_config(&mut app, Some("nonexistent value")); - // Should either error or handle as session setting - // The current implementation tries to set it in Settings - // which may succeed or fail depending on Settings implementation - } - - #[test] - fn test_set_key_without_value() { - let mut app = create_test_app(); - let result = set_config(&mut app, Some("model")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Usage: /set")); - } - - #[test] - fn persist_status_items_writes_tui_section_to_config_toml() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-statusline-persist-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let items = vec![ - crate::config::StatusItem::Mode, - crate::config::StatusItem::Model, - crate::config::StatusItem::Cost, - ]; - - let path = persist_status_items(&items).expect("persist should succeed"); - let body = fs::read_to_string(&path).expect("written file should be readable"); - assert!(body.contains("[tui]"), "expected [tui] section in {body}"); - assert!( - body.contains("status_items"), - "expected status_items key in {body}" - ); - assert!(body.contains("\"mode\""), "expected mode key in {body}"); - assert!(body.contains("\"cost\""), "expected cost key in {body}"); - } - - #[test] - fn config_toml_path_uses_codewhale_home_for_fresh_installs() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-config-path-fresh-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - unsafe { - env::remove_var("DEEPSEEK_CONFIG_PATH"); - } - - assert_eq!( - config_toml_path(None).unwrap(), - temp_root.join(".codewhale").join("config.toml") - ); - } - - #[test] - fn config_toml_path_preserves_legacy_config_when_it_exists() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-config-path-legacy-{}-{}", - std::process::id(), - nanos - )); - let legacy_config = temp_root.join(".deepseek").join("config.toml"); - fs::create_dir_all(legacy_config.parent().unwrap()).unwrap(); - fs::write(&legacy_config, "").unwrap(); - let _guard = EnvGuard::new(&temp_root); - - unsafe { - env::remove_var("DEEPSEEK_CONFIG_PATH"); - } - - assert_eq!(config_toml_path(None).unwrap(), legacy_config); - } - - #[test] - fn config_toml_path_prefers_codewhale_env_over_legacy_env() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-config-path-env-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let preferred = temp_root.join("preferred.toml"); - let legacy = temp_root.join("legacy.toml"); - - unsafe { - env::set_var("CODEWHALE_CONFIG_PATH", &preferred); - env::set_var("DEEPSEEK_CONFIG_PATH", &legacy); - } - - assert_eq!(config_toml_path(None).unwrap(), preferred); - } - - #[test] - fn persist_status_items_preserves_existing_unrelated_keys() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-statusline-preserve-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let path = temp_root.join(".deepseek").join("config.toml"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - // Seed the config with a sentinel key the picker MUST NOT clobber. - fs::write( - &path, - "api_key = \"sentinel-key\"\nmodel = \"deepseek-v4-pro\"\n", - ) - .unwrap(); - - let written = persist_status_items(&[crate::config::StatusItem::Mode]) - .expect("persist should succeed"); - let body = fs::read_to_string(&written).expect("written file should be readable"); - assert!( - body.contains("api_key = \"sentinel-key\""), - "round-trip lost api_key: {body}" - ); - assert!( - body.contains("model = \"deepseek-v4-pro\""), - "round-trip lost model: {body}" - ); - assert!( - body.contains("status_items"), - "expected status_items in {body}" - ); - } } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 5cfbbdee8..59ea42874 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -78,7 +78,6 @@ impl CommandResult { } /// Create a result with both message and action - #[allow(dead_code)] pub fn with_message_and_action(msg: impl Into, action: AppAction) -> Self { Self { message: Some(msg.into()), @@ -710,37 +709,9 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> config::set_config_value(app, key, value, persist) } -/// Persist the user's chosen footer items to `~/.deepseek/config.toml` under -/// `tui.status_items`. See [`config::persist_status_items`] for details. -pub fn persist_status_items( - items: &[crate::config::StatusItem], -) -> anyhow::Result { - config::persist_status_items(items) -} - -/// Persist a root-level string key in `config.toml`. -pub fn persist_root_string_key( - config_path: Option<&std::path::Path>, - key: &str, - value: &str, -) -> anyhow::Result { - config::persist_root_string_key(config_path, key, value) -} - pub fn switch_mode(app: &mut App, mode: crate::tui::app::AppMode) -> String { config::switch_mode(app, mode) } - -/// Auto-select a model based on request complexity. -pub fn auto_model_heuristic(input: &str, current_model: &str) -> String { - config::auto_model_heuristic(input, current_model) -} - -pub use config::{ - AutoRouteRecommendation, AutoRouteSelection, normalize_auto_route_effort, - parse_auto_route_recommendation, resolve_auto_route_with_flash, -}; - /// Execute a Recursive Language Model (RLM) turn — Algorithm 1 from /// Zhang et al. (arXiv:2512.24601). /// @@ -1006,45 +977,6 @@ pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> { .find(|cmd| cmd.name == name || cmd.aliases.contains(&name)) } -/// Get all command names matching a prefix, including both built-in -/// static commands and user-defined commands, formatted as `/name`. -/// -/// `workspace` is used to also scan workspace-local command directories; -/// pass `None` when no workspace context is available. -#[allow(dead_code)] -pub fn all_command_names_matching( - prefix: &str, - workspace: Option<&std::path::Path>, -) -> Vec { - let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase(); - let mut result: Vec = COMMANDS - .iter() - .filter(|cmd| { - cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix)) - }) - .map(|cmd| format!("/{}", cmd.name)) - .collect(); - - // Add user-defined commands - result.extend(user_commands::user_commands_matching(&prefix, workspace)); - - result.sort(); - result.dedup(); - result -} - -/// Get all commands matching a prefix (for autocomplete) -#[allow(dead_code)] -pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> { - let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase(); - COMMANDS - .iter() - .filter(|cmd| { - cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix)) - }) - .collect() -} - fn edit_distance(a: &str, b: &str) -> usize { if a == b { return 0; diff --git a/crates/tui/src/commands/network.rs b/crates/tui/src/commands/network.rs index dbe0e7afe..c1535e670 100644 --- a/crates/tui/src/commands/network.rs +++ b/crates/tui/src/commands/network.rs @@ -70,7 +70,7 @@ enum NetworkEdit { } fn list_policy() -> anyhow::Result { - let path = super::config::config_toml_path(None)?; + let path = crate::config_persistence::config_toml_path(None)?; let doc = load_config_doc(&path)?; let network = doc.get("network").and_then(Value::as_table); let default = network @@ -97,7 +97,7 @@ fn list_policy() -> anyhow::Result { } fn update_host(edit: NetworkEdit, host: &str) -> anyhow::Result { - let path = super::config::config_toml_path(None)?; + let path = crate::config_persistence::config_toml_path(None)?; let mut doc = load_config_doc(&path)?; let network = network_table_mut(&mut doc)?; @@ -136,7 +136,7 @@ fn update_default(value: &str) -> anyhow::Result { _ => bail!("Usage: /network default "), }; - let path = super::config::config_toml_path(None)?; + let path = crate::config_persistence::config_toml_path(None)?; let mut doc = load_config_doc(&path)?; let network = network_table_mut(&mut doc)?; network.insert("default".to_string(), Value::String(normalized.to_string())); diff --git a/crates/tui/src/commands/user_commands.rs b/crates/tui/src/commands/user_commands.rs index 8d8478391..eb702e42f 100644 --- a/crates/tui/src/commands/user_commands.rs +++ b/crates/tui/src/commands/user_commands.rs @@ -232,22 +232,6 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option) -> Vec { - let prefix = prefix.to_lowercase(); - load_user_commands(workspace) - .into_iter() - .filter(|(name, _)| name.starts_with(&prefix)) - .map(|(name, _)| format!("/{name}")) - .collect() -} - #[cfg(test)] mod tests { use super::*; @@ -307,12 +291,6 @@ mod tests { assert!(result.is_none()); } - #[test] - fn test_user_commands_matching_with_prefix_no_workspace() { - let matches = user_commands_matching("zzzznotfound", None); - assert!(matches.is_empty()); - } - // ── Workspace-local commands tests ───────────────────────────────── fn write_command(dir: &Path, name: &str, body: &str) { @@ -474,23 +452,6 @@ mod tests { } } - #[test] - fn user_commands_matching_with_workspace() { - let tmp = TempDir::new().unwrap(); - let ws = tmp.path(); - write_command( - &ws.join(".deepseek").join("commands"), - "project-cmd", - "body", - ); - - let matches = user_commands_matching("project", Some(ws)); - assert!( - matches.contains(&"/project-cmd".to_string()), - "got: {matches:?}" - ); - } - #[test] fn frontmatter_is_stripped_before_dispatch() { use crate::config::Config; diff --git a/crates/tui/src/config_persistence.rs b/crates/tui/src/config_persistence.rs new file mode 100644 index 000000000..aa79793c0 --- /dev/null +++ b/crates/tui/src/config_persistence.rs @@ -0,0 +1,461 @@ +//! Config file path resolution and TOML persistence helpers. +//! +//! These helpers are used by command handlers and non-command UI code, so +//! persistence lives outside the command tree. + +use std::path::{Path, PathBuf}; + +use crate::config::{ApiProvider, StatusItem, effective_home_dir, expand_path}; + +pub(crate) fn persist_status_items(items: &[StatusItem]) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(None)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let tui_entry = table + .entry("tui".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); + let tui_table = tui_entry + .as_table_mut() + .context("`tui` section in config.toml must be a table")?; + let array = items + .iter() + .map(|item| toml::Value::String(item.key().to_string())) + .collect::>(); + tui_table.insert("status_items".to_string(), toml::Value::Array(array)); + + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +pub(crate) fn persist_root_string_key( + config_path: Option<&Path>, + key: &str, + value: &str, +) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(config_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + table.insert(key.to_string(), toml::Value::String(value.to_string())); + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +pub(crate) fn persist_root_bool_key( + config_path: Option<&Path>, + key: &str, + value: bool, +) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(config_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + table.insert(key.to_string(), toml::Value::Boolean(value)); + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +pub(crate) fn persist_tui_integer_key( + config_path: Option<&Path>, + key: &str, + value: u64, +) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(config_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let tui_entry = table + .entry("tui".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); + let tui_table = tui_entry + .as_table_mut() + .context("`tui` section in config.toml must be a table")?; + let value = i64::try_from(value).context("integer value is too large for TOML")?; + tui_table.insert(key.to_string(), toml::Value::Integer(value)); + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +pub(crate) fn persist_provider_base_url_key( + config_path: Option<&Path>, + provider: ApiProvider, + value: &str, +) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(config_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let providers = table + .entry("providers".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .context("`providers` must be a table")?; + let provider_key = provider_base_url_table_key(provider)?; + let entry = providers + .entry(provider_key.to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .with_context(|| format!("`providers.{provider_key}` must be a table"))?; + entry.insert( + "base_url".to_string(), + toml::Value::String(value.to_string()), + ); + + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static str> { + match provider { + ApiProvider::Deepseek | ApiProvider::DeepseekCN => { + anyhow::bail!("DeepSeek uses the root base_url setting") + } + ApiProvider::NvidiaNim => Ok("nvidia_nim"), + ApiProvider::Openai => Ok("openai"), + ApiProvider::Atlascloud => Ok("atlascloud"), + ApiProvider::WanjieArk => Ok("wanjie_ark"), + ApiProvider::Volcengine => Ok("volcengine"), + ApiProvider::Openrouter => Ok("openrouter"), + ApiProvider::XiaomiMimo => Ok("xiaomi_mimo"), + ApiProvider::Novita => Ok("novita"), + ApiProvider::Fireworks => Ok("fireworks"), + ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => Ok("siliconflow"), + ApiProvider::Arcee => Ok("arcee"), + ApiProvider::Huggingface => Ok("huggingface"), + ApiProvider::Moonshot => Ok("moonshot"), + ApiProvider::Sglang => Ok("sglang"), + ApiProvider::Vllm => Ok("vllm"), + ApiProvider::Ollama => Ok("ollama"), + } +} + +pub(crate) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result { + use anyhow::Context; + + if let Some(path) = config_path { + return Ok(expand_path(path.to_string_lossy().as_ref())); + } + if let Ok(env) = std::env::var("CODEWHALE_CONFIG_PATH") { + let trimmed = env.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") { + let trimmed = env.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + let home = + effective_home_dir().context("failed to resolve home directory for config.toml path")?; + let primary = home.join(".codewhale").join("config.toml"); + if primary.exists() { + return Ok(primary); + } + let legacy = home.join(".deepseek").join("config.toml"); + if legacy.exists() { + return Ok(legacy); + } + Ok(primary) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::ffi::OsString; + use std::fs; + use std::path::Path; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct EnvGuard { + home: Option, + userprofile: Option, + codewhale_config_path: Option, + deepseek_config_path: Option, + _lock: std::sync::MutexGuard<'static, ()>, + } + + impl EnvGuard { + fn new(home: &Path) -> Self { + let lock = crate::test_support::lock_test_env(); + let home_str = OsString::from(home.as_os_str()); + let config_path = home.join(".deepseek").join("config.toml"); + let config_str = OsString::from(config_path.as_os_str()); + let home_prev = env::var_os("HOME"); + let userprofile_prev = env::var_os("USERPROFILE"); + let codewhale_config_prev = env::var_os("CODEWHALE_CONFIG_PATH"); + let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH"); + + // Safety: test-only environment mutation guarded by process-wide mutex. + unsafe { + env::set_var("HOME", &home_str); + env::set_var("USERPROFILE", &home_str); + env::remove_var("CODEWHALE_CONFIG_PATH"); + env::set_var("DEEPSEEK_CONFIG_PATH", &config_str); + } + + Self { + home: home_prev, + userprofile: userprofile_prev, + codewhale_config_path: codewhale_config_prev, + deepseek_config_path: deepseek_config_prev, + _lock: lock, + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.home.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("HOME", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("HOME"); + } + } + + if let Some(value) = self.userprofile.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("USERPROFILE", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("USERPROFILE"); + } + } + + if let Some(value) = self.codewhale_config_path.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("CODEWHALE_CONFIG_PATH", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("CODEWHALE_CONFIG_PATH"); + } + } + + if let Some(value) = self.deepseek_config_path.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_CONFIG_PATH", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("DEEPSEEK_CONFIG_PATH"); + } + } + } + } + + fn temp_root(prefix: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())) + } + + #[test] + fn persist_status_items_writes_tui_section_to_config_toml() { + let temp_root = temp_root("codewhale-statusline-persist"); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let items = vec![ + crate::config::StatusItem::Mode, + crate::config::StatusItem::Model, + crate::config::StatusItem::Cost, + ]; + + let path = persist_status_items(&items).expect("persist should succeed"); + let body = fs::read_to_string(&path).expect("written file should be readable"); + assert!(body.contains("[tui]"), "expected [tui] section in {body}"); + assert!( + body.contains("status_items"), + "expected status_items key in {body}" + ); + assert!(body.contains("\"mode\""), "expected mode key in {body}"); + assert!(body.contains("\"cost\""), "expected cost key in {body}"); + } + + #[test] + fn config_toml_path_uses_codewhale_home_for_fresh_installs() { + let temp_root = temp_root("codewhale-config-path-fresh"); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + unsafe { + env::remove_var("DEEPSEEK_CONFIG_PATH"); + } + + assert_eq!( + config_toml_path(None).unwrap(), + temp_root.join(".codewhale").join("config.toml") + ); + } + + #[test] + fn config_toml_path_preserves_legacy_config_when_it_exists() { + let temp_root = temp_root("codewhale-config-path-legacy"); + let legacy_config = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(legacy_config.parent().unwrap()).unwrap(); + fs::write(&legacy_config, "").unwrap(); + let _guard = EnvGuard::new(&temp_root); + + unsafe { + env::remove_var("DEEPSEEK_CONFIG_PATH"); + } + + assert_eq!(config_toml_path(None).unwrap(), legacy_config); + } + + #[test] + fn config_toml_path_prefers_codewhale_env_over_legacy_env() { + let temp_root = temp_root("codewhale-config-path-env"); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let preferred = temp_root.join("preferred.toml"); + let legacy = temp_root.join("legacy.toml"); + + unsafe { + env::set_var("CODEWHALE_CONFIG_PATH", &preferred); + env::set_var("DEEPSEEK_CONFIG_PATH", &legacy); + } + + assert_eq!(config_toml_path(None).unwrap(), preferred); + } + + #[test] + fn persist_status_items_preserves_existing_unrelated_keys() { + let temp_root = temp_root("codewhale-statusline-preserve"); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let path = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "api_key = \"sentinel-key\"\nmodel = \"deepseek-v4-pro\"\n", + ) + .unwrap(); + + let written = persist_status_items(&[crate::config::StatusItem::Mode]) + .expect("persist should succeed"); + let body = fs::read_to_string(&written).expect("written file should be readable"); + assert!( + body.contains("api_key = \"sentinel-key\""), + "round-trip lost api_key: {body}" + ); + assert!( + body.contains("model = \"deepseek-v4-pro\""), + "round-trip lost model: {body}" + ); + assert!( + body.contains("status_items"), + "expected status_items in {body}" + ); + } +} diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 0e7a1a6e3..adc99b354 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -596,7 +596,7 @@ pub fn apply_document( app.status_items = new_status_items.clone(); app.needs_redraw = true; if persist { - let path = commands::persist_status_items(&new_status_items)?; + let path = crate::config_persistence::persist_status_items(&new_status_items)?; notes.push(format!("status_items saved to {}", path.display())); } else { notes.push("status_items updated for this session".to_string()); @@ -685,7 +685,7 @@ fn apply_reasoning_effort( app.last_effective_reasoning_effort = None; app.update_model_compaction_budget(); if persist { - commands::persist_root_string_key( + crate::config_persistence::persist_root_string_key( app.config_path.as_deref(), "reasoning_effort", effort.as_setting(), diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index a3cc4386b..4973c106a 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -27,6 +27,7 @@ mod compaction; mod composer_history; mod composer_stash; mod config; +mod config_persistence; mod config_ui; mod core; mod cost_status; @@ -46,6 +47,7 @@ mod lsp; mod mcp; mod mcp_server; mod memory; +mod model_routing; mod models; mod network_policy; mod palette; @@ -5505,7 +5507,7 @@ struct CliAutoRoute { async fn resolve_cli_auto_route(config: &Config, model: &str, prompt: &str) -> CliAutoRoute { if model.trim().eq_ignore_ascii_case("auto") { let selection = - commands::resolve_auto_route_with_flash(config, prompt, "", "auto", "auto").await; + model_routing::resolve_auto_route_with_flash(config, prompt, "", "auto", "auto").await; CliAutoRoute { model: selection.model, reasoning_effort: selection.reasoning_effort, @@ -6709,6 +6711,12 @@ mod terminal_mode_tests { .args(["config", "user.email", "codewhale@example.invalid"]) .status() .expect("git config user.email"); + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .args(["config", "core.autocrlf", "false"]) + .status() + .expect("git config core.autocrlf"); std::fs::write( repo.join("math_utils.py"), "def add(a, b):\n return a - b\n", diff --git a/crates/tui/src/model_routing.rs b/crates/tui/src/model_routing.rs new file mode 100644 index 000000000..9e9b483ef --- /dev/null +++ b/crates/tui/src/model_routing.rs @@ -0,0 +1,569 @@ +//! Model selection and auto-routing. +//! +//! The CLI, TUI, runtime threads, subagents, and command handlers all need +//! this behavior, so it intentionally lives outside the command tree. + +use std::time::Duration; + +use anyhow::Result; + +use crate::client::DeepSeekClient; +use crate::config::Config; +use crate::llm_client::LlmClient; +use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt}; +use crate::tui::app::ReasoningEffort; + +/// Auto-select a model based on request complexity. +/// +/// Short messages (<100 chars) go to Flash. Long messages and requests with +/// complex keywords go to Pro. The fallback is Flash. +pub(crate) fn auto_model_heuristic(input: &str, current_model: &str) -> String { + auto_model_heuristic_with_bias(input, current_model, false) +} + +fn auto_model_heuristic_with_bias(input: &str, current_model: &str, cost_saving: bool) -> String { + auto_model_heuristic_selection_with_bias(input, current_model, cost_saving).model +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AutoModelHeuristicConfidence { + Decisive, + Ambiguous, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AutoModelHeuristicSelection { + model: String, + confidence: AutoModelHeuristicConfidence, +} + +fn auto_model_heuristic_selection_with_bias( + input: &str, + _current_model: &str, + cost_saving: bool, +) -> AutoModelHeuristicSelection { + let len = input.chars().count(); + let lower = input.to_lowercase(); + let borderline_pro_keywords: &[&str] = &[ + "implement", + "analyze", + "\u{5b9e}\u{73b0}", + "\u{5206}\u{6790}", + "\u{5be6}\u{73fe}", + ]; + let strong_match = COMPLEX_KEYWORDS + .iter() + .any(|kw| !borderline_pro_keywords.contains(kw) && lower.contains(kw)); + let borderline_match = borderline_pro_keywords.iter().any(|kw| lower.contains(kw)); + let pro_match = strong_match || (!cost_saving && borderline_match); + if pro_match { + return AutoModelHeuristicSelection { + model: "deepseek-v4-pro".to_string(), + confidence: AutoModelHeuristicConfidence::Decisive, + }; + } + if len < 100 { + return AutoModelHeuristicSelection { + model: "deepseek-v4-flash".to_string(), + confidence: AutoModelHeuristicConfidence::Decisive, + }; + } + let long_threshold = if cost_saving { 1_000 } else { 500 }; + if len > long_threshold { + return AutoModelHeuristicSelection { + model: "deepseek-v4-pro".to_string(), + confidence: AutoModelHeuristicConfidence::Decisive, + }; + } + + AutoModelHeuristicSelection { + model: "deepseek-v4-flash".to_string(), + confidence: AutoModelHeuristicConfidence::Ambiguous, + } +} + +const COMPLEX_KEYWORDS: &[&str] = &[ + "refactor", + "architecture", + "design", + "debug", + "security", + "review", + "audit", + "migrate", + "optimize", + "rewrite", + "implement", + "analyze", + "\u{91cd}\u{6784}", + "\u{67b6}\u{6784}", + "\u{8bbe}\u{8ba1}", + "\u{8c03}\u{8bd5}", + "\u{5b89}\u{5168}", + "\u{5ba1}\u{67e5}", + "\u{5ba1}\u{8ba1}", + "\u{8fc1}\u{79fb}", + "\u{4f18}\u{5316}", + "\u{91cd}\u{5199}", + "\u{5b9e}\u{73b0}", + "\u{5206}\u{6790}", + "\u{91cd}\u{69cb}", + "\u{67b6}\u{69cb}", + "\u{8a2d}\u{8a08}", + "\u{8abf}\u{8a66}", + "\u{5be9}\u{67e5}", + "\u{5be9}\u{8a08}", + "\u{9077}\u{79fb}", + "\u{512a}\u{5316}", + "\u{91cd}\u{5beb}", + "\u{5be6}\u{73fe}", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AutoRouteRecommendation { + pub(crate) model: String, + pub(crate) reasoning_effort: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AutoRouteSource { + FlashRouter, + Heuristic, +} + +impl AutoRouteSource { + #[must_use] + pub(crate) fn label(self) -> &'static str { + match self { + AutoRouteSource::FlashRouter => "flash-router", + AutoRouteSource::Heuristic => "heuristic", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AutoRouteSelection { + pub(crate) model: String, + pub(crate) reasoning_effort: Option, + pub(crate) source: AutoRouteSource, +} + +const AUTO_MODEL_ROUTER_SYSTEM_PROMPT: &str = "\ +You are the codewhale auto-routing classifier. Return only compact JSON: \ +{\"model\":\"deepseek-v4-flash|deepseek-v4-pro\",\"thinking\":\"off|high|max\"}. \ +Use deepseek-v4-flash for trivial, conversational, status, or single-step work. \ +Use deepseek-v4-pro for coding, debugging, release work, multi-step tasks, high-risk decisions, \ +tool-heavy work, ambiguous requests, or anything that benefits from deeper reasoning. \ +Use thinking off only for trivial no-tool answers, high for ordinary reasoning, and max for \ +agentic, coding, multi-file, release, architecture, debugging, security, tool-heavy, or uncertain work."; + +const AUTO_MODEL_ROUTER_COST_SAVING_ADDENDUM: &str = "\ +\n\nCost-saving mode is ON. Prefer deepseek-v4-flash for any request that is \ +not unmistakably agentic, multi-step, architecture/design, security review, \ +debugging, or otherwise clearly out of Flash's capability. Resolve ambiguous \ +cases in favour of deepseek-v4-flash, not deepseek-v4-pro."; + +pub(crate) fn parse_auto_route_recommendation(raw: &str) -> Option { + let json = extract_first_json_object(raw)?; + let value: serde_json::Value = serde_json::from_str(json).ok()?; + let model = value.get("model").and_then(serde_json::Value::as_str)?; + let model = normalize_auto_route_model(model)?; + let reasoning_effort = value + .get("thinking") + .or_else(|| value.get("reasoning_effort")) + .or_else(|| value.get("effort")) + .and_then(serde_json::Value::as_str) + .and_then(parse_auto_route_reasoning_effort); + + Some(AutoRouteRecommendation { + model: model.to_string(), + reasoning_effort, + }) +} + +fn extract_first_json_object(raw: &str) -> Option<&str> { + let start = raw.find('{')?; + let end = raw.rfind('}')?; + (end >= start).then_some(&raw[start..=end]) +} + +fn normalize_auto_route_model(model: &str) -> Option<&'static str> { + match model.trim().to_ascii_lowercase().as_str() { + "deepseek-v4-pro" | "v4-pro" | "pro" => Some("deepseek-v4-pro"), + "deepseek-v4-flash" | "v4-flash" | "flash" => Some("deepseek-v4-flash"), + _ => None, + } +} + +fn parse_auto_route_reasoning_effort(effort: &str) -> Option { + match effort.trim().to_ascii_lowercase().as_str() { + "off" | "disabled" | "none" | "false" => Some(ReasoningEffort::Off), + "low" | "minimal" | "medium" | "mid" => Some(ReasoningEffort::High), + "high" => Some(ReasoningEffort::High), + "max" | "maximum" | "xhigh" => Some(ReasoningEffort::Max), + _ => None, + } +} + +#[must_use] +pub(crate) fn normalize_auto_route_effort(effort: ReasoningEffort) -> ReasoningEffort { + match effort { + ReasoningEffort::Low | ReasoningEffort::Medium => ReasoningEffort::High, + other => other, + } +} + +pub(crate) async fn resolve_auto_route_with_flash( + config: &Config, + latest_request: &str, + recent_context: &str, + selected_model_mode: &str, + selected_thinking_mode: &str, +) -> AutoRouteSelection { + let cost_saving = config.auto_cost_saving(); + let heuristic = + auto_model_heuristic_selection_with_bias(latest_request, selected_model_mode, cost_saving); + if heuristic.confidence == AutoModelHeuristicConfidence::Decisive { + return auto_route_from_heuristic(latest_request, heuristic); + } + + match auto_route_flash_recommendation( + config, + latest_request, + recent_context, + selected_model_mode, + selected_thinking_mode, + ) + .await + { + Ok(Some(recommendation)) => AutoRouteSelection { + model: recommendation.model, + reasoning_effort: recommendation.reasoning_effort, + source: AutoRouteSource::FlashRouter, + }, + Ok(None) | Err(_) => auto_route_from_heuristic(latest_request, heuristic), + } +} + +fn auto_route_from_heuristic( + latest_request: &str, + heuristic: AutoModelHeuristicSelection, +) -> AutoRouteSelection { + AutoRouteSelection { + model: heuristic.model, + reasoning_effort: Some(normalize_auto_route_effort(crate::auto_reasoning::select( + false, + latest_request, + ))), + source: AutoRouteSource::Heuristic, + } +} + +async fn auto_route_flash_recommendation( + config: &Config, + latest_request: &str, + recent_context: &str, + selected_model_mode: &str, + selected_thinking_mode: &str, +) -> Result> { + if cfg!(test) { + return Ok(None); + } + + let client = DeepSeekClient::new(config)?; + let mut router_system = AUTO_MODEL_ROUTER_SYSTEM_PROMPT.to_string(); + if config.auto_cost_saving() { + router_system.push_str(AUTO_MODEL_ROUTER_COST_SAVING_ADDENDUM); + } + let request = MessageRequest { + model: "deepseek-v4-flash".to_string(), + messages: vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: auto_route_prompt( + latest_request, + recent_context, + selected_model_mode, + selected_thinking_mode, + ), + cache_control: None, + }], + }], + max_tokens: 96, + system: Some(SystemPrompt::Text(router_system)), + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("off".to_string()), + stream: Some(false), + temperature: Some(0.0), + top_p: None, + }; + + let response = + tokio::time::timeout(Duration::from_secs(4), client.create_message(request)).await??; + Ok(parse_auto_route_recommendation(&message_response_text( + &response, + ))) +} + +fn auto_route_prompt( + latest_request: &str, + recent_context: &str, + selected_model_mode: &str, + selected_thinking_mode: &str, +) -> String { + format!( + "Session mode: agent\nSelected model mode: {}\nSelected thinking mode: {}\n\nRecent context:\n{}\n\nLatest user request:\n{}\n\nReturn JSON only.", + selected_model_mode, + selected_thinking_mode, + if recent_context.trim().is_empty() { + "No prior context." + } else { + recent_context + }, + truncate_for_auto_router(latest_request, 4_000) + ) +} + +fn message_response_text(response: &MessageResponse) -> String { + let mut out = String::new(); + for block in &response.content { + match block { + ContentBlock::Text { text, .. } | ContentBlock::ToolResult { content: text, .. } => { + append_router_text(&mut out, text); + } + ContentBlock::Thinking { thinking } => { + append_router_text(&mut out, thinking); + } + ContentBlock::ToolUse { name, .. } => { + append_router_text(&mut out, &format!("[tool call: {name}]")); + } + _ => {} + } + } + out +} + +fn append_router_text(out: &mut String, text: &str) { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(text); +} + +fn truncate_for_auto_router(text: &str, max_chars: usize) -> String { + let mut chars = text.chars(); + let truncated: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{truncated}...") + } else { + truncated + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_model_heuristic_chinese_keywords_route_to_pro() { + for msg in [ + "\u{5e2e}\u{6211}\u{91cd}\u{6784}\u{8fd9}\u{4e2a}\u{6a21}\u{5757}", + "\u{8bbe}\u{8ba1}\u{6570}\u{636e}\u{5e93}\u{67b6}\u{6784}", + "\u{8c03}\u{8bd5}\u{5d29}\u{6e83}\u{95ee}\u{9898}", + "\u{5ba1}\u{8ba1}\u{5b89}\u{5168}\u{6f0f}\u{6d1e}", + "\u{8fc1}\u{79fb}\u{5230}\u{65b0}\u{6846}\u{67b6}", + "\u{4f18}\u{5316}\u{6027}\u{80fd}\u{74f6}\u{9888}", + "\u{5206}\u{6790}\u{8fd9}\u{6bb5}\u{4ee3}\u{7801}", + ] { + assert_eq!( + auto_model_heuristic(msg, "auto"), + "deepseek-v4-pro", + "expected Pro for `{msg}`", + ); + } + } + + #[test] + fn auto_model_heuristic_traditional_chinese_keywords_route_to_pro() { + for msg in [ + "\u{8acb}\u{91cd}\u{69cb}\u{6b64}\u{6a21}\u{7d44}", + "\u{67b6}\u{69cb}\u{8a2d}\u{8a08}", + "\u{4ee3}\u{78bc}\u{8abf}\u{8a66}", + "\u{5be9}\u{8a08}\u{6f0f}\u{6d1e}", + "\u{9077}\u{79fb}\u{5230}\u{65b0}\u{67b6}\u{69cb}", + "\u{512a}\u{5316}\u{6027}\u{80fd}", + "\u{91cd}\u{5beb}\u{4ee3}\u{78bc}", + "\u{5be6}\u{73fe}\u{65b0}\u{529f}\u{80fd}", + ] { + assert_eq!( + auto_model_heuristic(msg, "auto"), + "deepseek-v4-pro", + "expected Pro for `{msg}`", + ); + } + } + + #[test] + fn auto_model_heuristic_short_chinese_chat_stays_on_flash() { + assert_eq!( + auto_model_heuristic("\u{4f60}\u{597d}", "auto"), + "deepseek-v4-flash", + ); + } + + #[test] + fn auto_heuristic_selection_marks_short_and_complex_routes_decisive() { + let short = auto_model_heuristic_selection_with_bias("yes", "auto", false); + assert_eq!(short.model, "deepseek-v4-flash"); + assert_eq!( + short.confidence, + AutoModelHeuristicConfidence::Decisive, + "trivial replies should skip the Flash router" + ); + + let complex = auto_model_heuristic_selection_with_bias( + "Please review the auth migration", + "auto", + false, + ); + assert_eq!(complex.model, "deepseek-v4-pro"); + assert_eq!( + complex.confidence, + AutoModelHeuristicConfidence::Decisive, + "strong complexity keywords should skip the Flash router" + ); + } + + #[test] + fn auto_heuristic_selection_leaves_default_branch_ambiguous_for_router() { + let request = + "Please update the configuration notes so each option has a clearer label. ".repeat(3); + assert!( + (100..500).contains(&request.chars().count()), + "test request must stay in the default grey zone" + ); + + let selection = auto_model_heuristic_selection_with_bias(&request, "auto", false); + assert_eq!(selection.model, "deepseek-v4-flash"); + assert_eq!( + selection.confidence, + AutoModelHeuristicConfidence::Ambiguous, + "only the grey-zone default branch should invoke the Flash router" + ); + } + + #[test] + fn auto_route_recommendation_parses_strict_json() { + let rec = + parse_auto_route_recommendation(r#"{"model":"deepseek-v4-pro","thinking":"max"}"#) + .expect("valid router response should parse"); + + assert_eq!(rec.model, "deepseek-v4-pro"); + assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::Max)); + } + + #[test] + fn auto_route_recommendation_accepts_wrapped_json_aliases() { + let rec = + parse_auto_route_recommendation(r#"route: {"model":"flash","reasoning_effort":"off"}"#) + .expect("wrapped router response should parse"); + + assert_eq!(rec.model, "deepseek-v4-flash"); + assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::Off)); + } + + #[test] + fn auto_route_recommendation_normalizes_legacy_low_medium_to_high() { + let rec = parse_auto_route_recommendation( + r#"{"model":"deepseek-v4-pro","reasoning_effort":"medium"}"#, + ) + .expect("medium should parse for back-compat"); + + assert_eq!(rec.model, "deepseek-v4-pro"); + assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::High)); + } + + #[test] + fn auto_route_recommendation_rejects_unknown_model() { + assert!( + parse_auto_route_recommendation(r#"{"model":"some-other-model","thinking":"max"}"#,) + .is_none() + ); + } + + #[test] + fn auto_heuristic_default_routes_implement_to_pro() { + assert_eq!( + auto_model_heuristic_with_bias("Please implement a binary search", "auto", false), + "deepseek-v4-pro" + ); + } + + #[test] + fn auto_heuristic_cost_saving_keeps_borderline_keywords_on_flash() { + assert_eq!( + auto_model_heuristic_with_bias("Please implement a binary search", "auto", true), + "deepseek-v4-flash" + ); + assert_eq!( + auto_model_heuristic_with_bias("analyze this snippet", "auto", true), + "deepseek-v4-flash" + ); + } + + #[test] + fn auto_heuristic_strong_keywords_still_route_to_pro_under_cost_saving() { + for kw in [ + "refactor", + "architecture", + "design", + "debug", + "security", + "review", + "audit", + "migrate", + "optimize", + "rewrite", + ] { + let req = format!("Please {kw} this module"); + assert_eq!( + auto_model_heuristic_with_bias(&req, "auto", true), + "deepseek-v4-pro", + "expected Pro for strong keyword `{kw}` even in cost-saving mode" + ); + } + } + + #[test] + fn auto_heuristic_cost_saving_raises_long_message_threshold() { + let body = "filler sentence. ".repeat(40); + assert_eq!( + auto_model_heuristic_with_bias(&body, "auto", false), + "deepseek-v4-pro" + ); + assert_eq!( + auto_model_heuristic_with_bias(&body, "auto", true), + "deepseek-v4-flash" + ); + } + + #[test] + fn config_auto_cost_saving_defaults_to_false() { + let cfg = Config::default(); + assert!(!cfg.auto_cost_saving()); + } + + #[test] + fn config_auto_cost_saving_reads_table() { + let cfg = Config { + auto: Some(crate::config::AutoConfig { + cost_saving: Some(true), + }), + ..Default::default() + }; + assert!(cfg.auto_cost_saving()); + } +} diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 64369523d..2f1e5c0a9 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1660,7 +1660,7 @@ impl RuntimeThreadManager { let requested_model = req.model.unwrap_or_else(|| thread.model.clone()); let auto_model = requested_model.trim().eq_ignore_ascii_case("auto"); let (model, reasoning_effort) = if auto_model { - let selection = crate::commands::resolve_auto_route_with_flash( + let selection = crate::model_routing::resolve_auto_route_with_flash( &self.config, &prompt, "", diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 5421031fc..32a94ede2 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -5140,7 +5140,7 @@ fn fallback_subagent_assignment_route( let model = if let Some(model) = configured_model { model } else if runtime.auto_model { - crate::commands::auto_model_heuristic(prompt, &runtime.model) + crate::model_routing::auto_model_heuristic(prompt, &runtime.model) } else { runtime.model.clone() }; @@ -5166,7 +5166,7 @@ fn fallback_subagent_assignment_route( async fn subagent_flash_router( runtime: &SubAgentRuntime, prompt: &str, -) -> Result> { +) -> Result> { if cfg!(test) { return Ok(None); } @@ -5199,7 +5199,7 @@ async fn subagent_flash_router( runtime.client.create_message(request), ) .await??; - Ok(crate::commands::parse_auto_route_recommendation( + Ok(crate::model_routing::parse_auto_route_recommendation( &message_response_text(&response.content), )) } diff --git a/crates/tui/src/tui/auto_router.rs b/crates/tui/src/tui/auto_router.rs index 17fcc53ed..4d5414b90 100644 --- a/crates/tui/src/tui/auto_router.rs +++ b/crates/tui/src/tui/auto_router.rs @@ -4,12 +4,12 @@ //! The TUI calls `resolve_auto_model_selection` once per user turn when //! `app.auto_model` is set. The async function builds a recent-context //! summary from `api_messages` (capped to six rows of up to 900 chars -//! each), passes it through `commands::resolve_auto_route_with_flash`, +//! each), passes it through `model_routing::resolve_auto_route_with_flash`, //! and returns the selection (model + reasoning effort). The remaining //! helpers are pure transforms used to build that summary. -use crate::commands; use crate::config::Config; +use crate::model_routing; use crate::models::{ContentBlock, Message}; use crate::tui::app::{App, QueuedMessage, ReasoningEffort}; @@ -25,13 +25,13 @@ pub(super) async fn resolve_auto_model_selection( config: &Config, message: &QueuedMessage, latest_content: &str, -) -> commands::AutoRouteSelection { +) -> model_routing::AutoRouteSelection { let latest_request = if latest_content.trim().is_empty() { message.display.as_str() } else { latest_content }; - commands::resolve_auto_route_with_flash( + model_routing::resolve_auto_route_with_flash( config, latest_request, &recent_auto_router_context(&app.api_messages), @@ -43,7 +43,7 @@ pub(super) async fn resolve_auto_model_selection( /// Normalize the heuristic effort to the canonical auto-route effort. pub(super) fn normalize_auto_routed_effort(effort: ReasoningEffort) -> ReasoningEffort { - commands::normalize_auto_route_effort(effort) + model_routing::normalize_auto_route_effort(effort) } /// Build a compact recent-context summary for the auto-route prompt. diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d6b309d72..da43754df 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4769,7 +4769,7 @@ fn rollback_provider_after_auth_failure(app: &mut App, config: &mut Config) -> O app.api_key_env_only = previous_api_key_env_only; let persistence_error = (|| -> anyhow::Result<()> { - commands::persist_root_string_key( + crate::config_persistence::persist_root_string_key( app.config_path.as_deref(), "provider", previous_provider.as_str(), @@ -5348,7 +5348,9 @@ async fn dispatch_user_message( auto_selection .as_ref() .map(|selection| selection.model.clone()) - .unwrap_or_else(|| commands::auto_model_heuristic(&message.display, &app.model)) + .unwrap_or_else(|| { + crate::model_routing::auto_model_heuristic(&message.display, &app.model) + }) } else { app.model.clone() }; @@ -5813,7 +5815,11 @@ async fn switch_provider( .await; let persist_warning = (|| -> anyhow::Result<()> { - commands::persist_root_string_key(app.config_path.as_deref(), "provider", target.as_str())?; + crate::config_persistence::persist_root_string_key( + app.config_path.as_deref(), + "provider", + target.as_str(), + )?; let mut settings = crate::settings::Settings::load()?; settings.default_provider = Some(target.as_str().to_string()); @@ -7732,7 +7738,7 @@ async fn handle_view_events( app.status_items = items.clone(); app.needs_redraw = true; if final_save { - match commands::persist_status_items(&items) { + match crate::config_persistence::persist_status_items(&items) { Ok(path) => { app.status_message = Some(format!("Status line saved to {}", path.display())); From fefd63f30b8fac3578e76dfd6e12fd66f1474677 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Sun, 7 Jun 2026 03:19:45 +0200 Subject: [PATCH 4/4] fix: address command layer review feedback --- crates/tui/src/mcp.rs | 9 +++++++++ crates/tui/src/project_context.rs | 11 ++++------- crates/tui/src/prompts.rs | 10 +++++----- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index 39d5875e1..dea45276f 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -1129,6 +1129,7 @@ fn is_connection_closed_error_text(err: &str) -> bool { || err.contains("connection reset") || err.contains("broken pipe") || err.contains("unexpected eof") + || err.contains("forcibly closed") } fn parse_sse_message_data(body: &str) -> Vec> { @@ -4401,6 +4402,14 @@ mod tests { is_mcp_stale_session_error(&err), "reset legacy SSE POST should force reconnect before retry" ); + + let err = anyhow::anyhow!( + "MCP SSE POST send failed (transport=sse endpoint=http://127.0.0.1:123/messages): An existing connection was forcibly closed by the remote host." + ); + assert!( + is_mcp_stale_session_error(&err), + "Windows reset wording should force reconnect before retry" + ); } #[tokio::test] diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index f8dfa6d6c..54c8300fb 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -683,17 +683,18 @@ fn load_project_context_with_parents_and_home( workspace: &Path, home_dir: Option<&Path>, ) -> ProjectContext { + let workspace_canonical = canonicalize_workspace_or_keep(workspace); let mut ctx = load_project_context(workspace); let parent_search_stop = project_context_parent_search_stop_dir(); // If no context found in workspace, check parent directories if !ctx.has_instructions() { - let mut current = workspace.parent(); + let mut current = workspace_canonical.parent(); while let Some(parent) = current { if parent_search_stop .as_deref() - .is_some_and(|stop| paths_equal_after_canonicalizing(parent, stop)) + .is_some_and(|stop| parent == stop) { break; } @@ -782,7 +783,7 @@ pub(crate) fn project_context_cache_candidate_paths( while let Some(dir) = current { if parent_search_stop .as_deref() - .is_some_and(|stop| paths_equal_after_canonicalizing(dir, stop)) + .is_some_and(|stop| dir == stop) { break; } @@ -856,10 +857,6 @@ fn project_context_parent_search_stop_dir() -> Option { dirs::home_dir().map(|home| canonicalize_workspace_or_keep(&home)) } -fn paths_equal_after_canonicalizing(left: &Path, right: &Path) -> bool { - canonicalize_workspace_or_keep(left) == canonicalize_workspace_or_keep(right) -} - /// Combine global user-wide preferences with a project-local /// AGENTS.md/CLAUDE.md/instructions.md. Global comes first so /// workspace-specific rules can override it — the model reads in declared diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index d4cb68d58..1e8136b90 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -2710,12 +2710,12 @@ mod tests { // surface (engine.rs builds the system prompt via this fn or // its sibling _and_skills variant on every turn). let _env_guard = crate::test_support::lock_test_env(); - let tmp = tempdir().expect("tempdir"); - let home = tmp.path().join("home"); - let _home = EnvVarGuard::set("HOME", home.as_os_str()); - let _userprofile = EnvVarGuard::set("USERPROFILE", home.as_os_str()); + let workspace_tmp = tempdir().expect("workspace tempdir"); + let home_tmp = tempdir().expect("home tempdir"); + let _home = EnvVarGuard::set("HOME", home_tmp.path().as_os_str()); + let _userprofile = EnvVarGuard::set("USERPROFILE", home_tmp.path().as_os_str()); let _skills_dir = EnvVarGuard::remove("DEEPSEEK_SKILLS_DIR"); - let workspace = tmp.path(); + let workspace = workspace_tmp.path(); for mode in [AppMode::Agent, AppMode::Yolo, AppMode::Plan] { let a = match system_prompt_for_mode_with_context(mode, workspace, None) {