fix: cover purge command in Vietnamese locale

This commit is contained in:
Hunter B
2026-05-30 23:23:39 -07:00
20 changed files with 2461 additions and 93 deletions
+16 -12
View File
@@ -286,25 +286,29 @@ max_subagents = 10 # optional (1-20)
# ─────────────────────────────────────────────────────────────────────────────────
# Web Search Provider
# ─────────────────────────────────────────────────────────────────────────────────
# Choose which backend `web_search` uses. Default is Bing HTML scraping — no
# API key needed. DuckDuckGo remains selectable and still falls back to Bing
# when its HTML endpoint returns a bot challenge or no parseable results.
# Switch to Tavily or Bocha for reliable search in mainland China.
# Choose which backend `web_search` uses. Default is DuckDuckGo HTML scraping
# with Bing fallback — no API key needed. Bing remains selectable for users who
# explicitly prefer it. Switch to Tavily, Bocha, Metaso, or Baidu for
# API-backed search.
#
# [search]
# provider = "bing" # bing | duckduckgo | tavily | bocha | metaso
# provider = "duckduckgo" # duckduckgo | bing | tavily | bocha | metaso | baidu
# # duckduckgo: HTML scrape with Bing fallback
# # tavily: https://tavily.com — AI search, needs api_key
# # bocha: https://bochaai.com — 博查AI搜索,国内友好,需api_key
# # metaso: https://metaso.cn秘塔AI搜索,每天 100 次免费
# # 设置 METASO_API_KEY 或 [search] api_key 可提升额度
# api_key = "tvly-YOUR_KEY" # required for tavily, bocha, and metaso (optional for metaso)
# # WARNING: treat config.toml like a secret file when
# # storing API keys. Use env vars or `auth set` instead.
# # bing: HTML scrape, no API key
# # tavily: https://tavily.com — AI search, needs api_key
# # bocha: https://bochaai.com博查AI搜索,国内友好,需api_key
# # metaso: https://metaso.cn — 秘塔AI搜索,每天 100 次免费
# # 设置 METASO_API_KEY 或 [search] api_key 可提升额度
# # baidu: 百度 AI Search via qianfan.baidubce.com,需 api_key
# api_key = "YOUR_SEARCH_KEY" # required for tavily, bocha, and baidu; optional for metaso
# # WARNING: treat config.toml like a secret file when
# # storing API keys. Prefer env vars for local smoke tests.
#
# Env-var overrides:
# DEEPSEEK_SEARCH_PROVIDER → search.provider
# DEEPSEEK_SEARCH_API_KEY → search.api_key
# METASO_API_KEY → metaso key fallback
# BAIDU_SEARCH_API_KEY → baidu key fallback
# ─────────────────────────────────────────────────────────────────────────────────
# Network Policy (#135)
+41
View File
@@ -744,6 +744,47 @@ pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult {
}
}
/// `/slop [query|export]` — inspect or export the slop ledger (#2127).
/// With no arguments, prints a summary. `query` shows filtered results;
/// `export` outputs the full ledger as Markdown.
pub fn slop(_app: &mut App, arg: Option<&str>) -> CommandResult {
let arg = arg.map(str::trim).unwrap_or("");
let ledger = match crate::slop_ledger::SlopLedger::load() {
Ok(l) => l,
Err(e) => return CommandResult::error(format!("Failed to load slop ledger: {e}")),
};
match arg {
"" => CommandResult::message(ledger.summary()),
"query" | "q" => {
if ledger.is_empty() {
return CommandResult::message("Slop ledger is empty.");
}
let mut out = String::new();
for entry in &ledger.query(&Default::default()) {
use std::fmt::Write;
let _ = writeln!(
out,
"[{}] {} ({:?} | {:?}) — {}",
crate::slop_ledger::short_id(&entry.id),
entry.bucket.as_str(),
entry.severity,
entry.status,
entry.title
);
}
CommandResult::message(out)
}
"export" | "e" => {
let md = ledger.export_markdown(None, None);
CommandResult::message(md)
}
_ => CommandResult::error(format!(
"Unknown /slop action '{arg}'. Use /slop, /slop query, or /slop export."
)),
}
}
/// Manage workspace-level trust and the per-path allowlist.
///
/// Subcommands:
+10
View File
@@ -552,6 +552,13 @@ pub const COMMANDS: &[CommandInfo] = &[
usage: "/cache [count|inspect|stats|warmup]",
description_id: MessageId::CmdCacheDescription,
},
// Slop Ledger (#2127)
CommandInfo {
name: "slop",
aliases: &["canzha"],
usage: "/slop [query|export]",
description_id: MessageId::CmdSlopDescription,
},
];
/// Execute a slash command
@@ -628,6 +635,9 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"balance" => balance::balance(app),
"cache" => debug::cache(app, arg),
// Slop ledger (#2127)
"slop" | "canzha" => config::slop(app, arg),
// ChangeLog command
"change" => change::change(app, arg),
"system" | "xitong" => debug::system_prompt(app),
+89 -2
View File
@@ -684,6 +684,14 @@ pub enum SearchProvider {
/// or `METASO_API_KEY` env var; configurable via `[search] api_key`.
#[serde(alias = "metaso")]
Metaso,
/// Baidu AI Search API (<https://qianfan.baidubce.com>). Requires api_key.
#[serde(
alias = "baidu-search",
alias = "baidu_ai_search",
alias = "baidu_search",
alias = "baidu-ai-search"
)]
Baidu,
}
impl SearchProvider {
@@ -694,6 +702,10 @@ impl SearchProvider {
"duckduckgo" | "duck-duck-go" | "duck_duck_go" | "ddg" => Some(Self::DuckDuckGo),
"tavily" => Some(Self::Tavily),
"bocha" => Some(Self::Bocha),
"metaso" => Some(Self::Metaso),
"baidu" | "baidu-search" | "baidu_search" | "baidu-ai-search" | "baidu_ai_search" => {
Some(Self::Baidu)
}
_ => None,
}
}
@@ -706,6 +718,7 @@ impl SearchProvider {
Self::Tavily => "tavily",
Self::Bocha => "bocha",
Self::Metaso => "metaso",
Self::Baidu => "baidu",
}
}
}
@@ -737,11 +750,12 @@ pub struct SearchProviderResolution {
/// Web search provider configuration (`[search]` table in config.toml).
#[derive(Debug, Clone, Deserialize, Default)]
pub struct SearchConfig {
/// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha` | `metaso`. Default: `duckduckgo`.
/// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha` | `metaso` | `baidu`. Default: `duckduckgo`.
#[serde(default)]
pub provider: Option<SearchProvider>,
/// API key for Tavily, Bocha, or Metaso. Not required for Bing or DuckDuckGo.
/// API key for Tavily, Bocha, Metaso, or Baidu. Not required for Bing or DuckDuckGo.
/// Metaso also falls back to `METASO_API_KEY` env var, then a built-in default.
/// Baidu also falls back to `BAIDU_SEARCH_API_KEY` env var.
#[serde(default)]
pub api_key: Option<String>,
}
@@ -2912,6 +2926,14 @@ fn apply_env_overrides(config: &mut Config) {
if let Ok(value) = std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH") {
config.managed_config_path = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_SEARCH_API_KEY")
&& !value.trim().is_empty()
{
config
.search
.get_or_insert_with(SearchConfig::default)
.api_key = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_REQUIREMENTS_PATH") {
config.requirements_path = Some(value);
}
@@ -4363,6 +4385,35 @@ mod tests {
);
}
#[test]
fn explicit_baidu_search_provider_is_preserved() {
let config: Config = toml::from_str(
r#"
[search]
provider = "baidu"
"#,
)
.expect("search config");
assert_eq!(
config.search.and_then(|search| search.provider),
Some(SearchProvider::Baidu)
);
}
#[test]
fn baidu_search_provider_aliases_parse() {
assert_eq!(SearchProvider::parse("baidu"), Some(SearchProvider::Baidu));
assert_eq!(
SearchProvider::parse("baidu-search"),
Some(SearchProvider::Baidu)
);
assert_eq!(
SearchProvider::parse("baidu_ai_search"),
Some(SearchProvider::Baidu)
);
}
#[test]
fn search_provider_resolution_reports_default_source() {
let _guard = lock_test_env();
@@ -4416,6 +4467,42 @@ mod tests {
assert_eq!(resolution.source, SearchProviderSource::EnvOverride);
}
#[test]
fn search_provider_env_override_accepts_baidu() {
let _guard = lock_test_env();
let prev = env::var_os("DEEPSEEK_SEARCH_PROVIDER");
unsafe { env::set_var("DEEPSEEK_SEARCH_PROVIDER", "baidu") };
let config: Config = toml::from_str(
r#"
[search]
provider = "duckduckgo"
"#,
)
.expect("search config");
let resolution = config.search_provider_resolution();
unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) };
assert_eq!(resolution.provider, SearchProvider::Baidu);
assert_eq!(resolution.source, SearchProviderSource::EnvOverride);
}
#[test]
fn apply_env_overrides_sets_search_api_key() {
let _guard = lock_test_env();
let prev = env::var_os("DEEPSEEK_SEARCH_API_KEY");
unsafe { env::set_var("DEEPSEEK_SEARCH_API_KEY", "search-env-key") };
let mut config = Config::default();
apply_env_overrides(&mut config);
unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_API_KEY", prev) };
assert_eq!(
config.search.and_then(|search| search.api_key),
Some("search-env-key".to_string())
);
}
#[test]
fn search_provider_resolution_ignores_invalid_env_override() {
let _guard = lock_test_env();
+46 -3
View File
@@ -12,7 +12,7 @@ use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime};
use anyhow::Result;
use futures_util::StreamExt;
@@ -178,8 +178,9 @@ pub struct EngineConfig {
pub workshop: Option<crate::tools::large_output_router::WorkshopConfig>,
/// Which search backend `web_search` should use. Default: DuckDuckGo.
pub search_provider: crate::config::SearchProvider,
/// API key for Tavily, Bocha, or Metaso. `None` for Bing or DuckDuckGo.
/// API key for Tavily, Bocha, Metaso, or Baidu. `None` for Bing or DuckDuckGo.
/// Metaso also falls back to `METASO_API_KEY` env var, then a built-in key.
/// Baidu also falls back to `BAIDU_SEARCH_API_KEY`.
pub search_api_key: Option<String>,
/// Per-step DeepSeek API timeout for sub-agent `create_message` requests.
/// Resolved from `[subagents] api_timeout_secs` (clamped to 1..=1800)
@@ -358,6 +359,10 @@ pub struct Engine {
/// Diagnostics collected during the current step's tool calls. Drained
/// and forwarded as a synthetic user message before the next API call.
pending_lsp_blocks: Vec<crate::lsp::DiagnosticBlock>,
/// Cached SlopLedger gate block keyed by the ledger file's modified time.
/// This keeps prompt refreshes cheap while still noticing append/update
/// writes from slop ledger tools during the same session.
slop_ledger_gate_cache: Option<(Option<SystemTime>, Option<String>)>,
}
// === Internal tool helpers ===
@@ -599,6 +604,7 @@ impl Engine {
turn_counter: 0,
lsp_manager,
pending_lsp_blocks: Vec::new(),
slop_ledger_gate_cache: None,
workshop_vars,
sandbox_backend,
};
@@ -1987,8 +1993,20 @@ impl Engine {
},
self.session.approval_mode,
);
let stable_prompt =
let mut stable_prompt =
merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone());
// SlopLedger completion-gate: inject unresolved slop entries into the
// system prompt so the agent can autonomously review them before
// claiming the task is done (#2127).
let gate_block = self.slop_ledger_gate_block();
if let Some(ref block) = gate_block {
if let Some(SystemPrompt::Text(prompt_text)) = &mut stable_prompt {
prompt_text.push_str("\n\n");
prompt_text.push_str(block);
}
}
let stable_hash = system_prompt_hash(stable_prompt.as_ref());
if self.session.system_prompt_override {
self.session.last_system_prompt_hash = Some(stable_hash);
@@ -2000,6 +2018,31 @@ impl Engine {
}
}
fn slop_ledger_gate_block(&mut self) -> Option<String> {
let modified = crate::slop_ledger::SlopLedger::default_path()
.ok()
.and_then(|path| std::fs::metadata(path).ok())
.and_then(|metadata| metadata.modified().ok());
if let Some((cached_modified, cached_block)) = &self.slop_ledger_gate_cache
&& *cached_modified == modified
{
return cached_block.clone();
}
let loaded = crate::slop_ledger::SlopLedger::load()
.ok()
.and_then(|ledger| {
if ledger.has_open_entries() {
ledger.completion_gate_summary()
} else {
None
}
});
self.slop_ledger_gate_cache = Some((modified, loaded.clone()));
loaded
}
fn merge_compaction_summary(&mut self, summary_prompt: Option<SystemPrompt>) {
if summary_prompt.is_none() {
return;
+8
View File
@@ -67,6 +67,14 @@ impl Engine {
.with_parallel_tool()
.with_recall_archive_tool();
// SlopLedger: plan mode only gets read-only query + export,
// agent/yolo get the full set including append + update.
builder = if mode == AppMode::Plan {
builder.with_slop_ledger_read_only_tools()
} else {
builder.with_slop_ledger_tools()
};
if mode != AppMode::Plan {
builder = builder
.with_rlm_tool(self.deepseek_client.clone(), self.session.model.clone())
+11
View File
@@ -304,6 +304,7 @@ pub enum MessageId {
CmdSettingsDescription,
CmdSkillDescription,
CmdSkillsDescription,
CmdSlopDescription,
CmdStashDescription,
CmdStatusDescription,
CmdStatuslineDescription,
@@ -565,6 +566,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CmdSettingsDescription,
MessageId::CmdSkillDescription,
MessageId::CmdSkillsDescription,
MessageId::CmdSlopDescription,
MessageId::CmdStashDescription,
MessageId::CmdStatusDescription,
MessageId::CmdStatuslineDescription,
@@ -1051,6 +1053,7 @@ fn english(id: MessageId) -> &'static str {
MessageId::CmdSkillsDescription => {
"List local skills (filter by `/skills <prefix>`; --remote browses the curated registry)"
}
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
MessageId::CmdStashDescription => {
"Park or restore a composer draft (Ctrl+S to push, /stash list/pop)"
}
@@ -1388,6 +1391,9 @@ fn vietnamese(id: MessageId) -> Option<&'static str> {
MessageId::CmdCompactDescription => {
"Kích hoạt nén ngữ cảnh để giải phóng không gian (cũ; v0.6.6 ưu tiên khởi động lại chu kỳ)"
}
MessageId::CmdPurgeDescription => {
"Cho agent cắt gọn lịch sử trò chuyện để giải phóng ngữ cảnh"
}
MessageId::CmdConfigDescription => "Mở trình chỉnh sửa cấu hình tương tác",
MessageId::CmdContextDescription => "Mở trình kiểm tra ngữ cảnh phiên thu gọn",
MessageId::CmdCostDescription => "Hiển thị chi tiết chi phí của phiên làm việc",
@@ -1462,6 +1468,7 @@ fn vietnamese(id: MessageId) -> Option<&'static str> {
MessageId::CmdSkillsDescription => {
"Liệt kê các kỹ năng cục bộ (lọc bằng `/skills <tiền_tố>`; --remote để duyệt kho lưu trữ được kiểm duyệt)"
}
MessageId::CmdSlopDescription => "Kiểm tra hoặc xuất SlopLedger",
MessageId::CmdStashDescription => {
"Tạm cất hoặc khôi phục bản nháp (Ctrl+S để cất, /stash list/pop để xem/lấy ra)"
}
@@ -1888,6 +1895,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
MessageId::CmdSkillsDescription => {
"ローカルスキルを一覧表示(`/skills <prefix>` で絞り込み、--remote で精選レジストリを参照)"
}
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
MessageId::CmdStashDescription => {
"コンポーザーの下書きを退避/復元(Ctrl+S で退避、/stash list|pop"
}
@@ -2257,6 +2265,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
MessageId::CmdSkillsDescription => {
"列出本地技能(用 `/skills <prefix>` 按名称前缀过滤,--remote 浏览精选注册表)"
}
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
MessageId::CmdStashDescription => "暂存或恢复输入草稿(Ctrl+S 暂存,/stash list|pop",
MessageId::CmdStatusDescription => "显示当前运行状态",
MessageId::CmdStatuslineDescription => "配置底栏要显示哪些条目",
@@ -2624,6 +2633,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
MessageId::CmdSkillsDescription => {
"Listar skills locais (filtre com `/skills <prefixo>`; --remote navega pelo registro curado)"
}
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
MessageId::CmdStashDescription => {
"Estacionar ou restaurar rascunho do compositor (Ctrl+S estaciona, /stash list|pop)"
}
@@ -3045,6 +3055,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
MessageId::CmdSkillsDescription => {
"Listar skills locales (filtra con `/skills <prefijo>`; --remote navega el registro curado)"
}
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
MessageId::CmdStashDescription => {
"Estacionar o restaurar borrador del compositor (Ctrl+S estaciona, /stash list|pop)"
}
+1
View File
@@ -69,6 +69,7 @@ mod settings;
mod shell_dispatcher;
mod skill_state;
mod skills;
mod slop_ledger;
mod snapshot;
mod task_manager;
#[cfg(test)]
+119 -7
View File
@@ -4,7 +4,7 @@ use std::collections::HashSet;
use std::convert::Infallible;
use std::fs;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::path::{Path as FsPath, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
@@ -40,7 +40,6 @@ use crate::runtime_threads::{
};
use crate::session_manager::{SavedSession, SessionManager, SessionMetadata, default_sessions_dir};
use crate::skill_state::SkillStateStore;
use crate::skills::SkillRegistry;
use crate::task_manager::{
NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskRecord, TaskSummary,
};
@@ -261,11 +260,13 @@ struct SkillEntry {
description: String,
path: PathBuf,
enabled: bool,
is_bundled: bool,
}
#[derive(Debug, Serialize)]
struct SkillsResponse {
directory: PathBuf,
directories: Vec<PathBuf>,
warnings: Vec<String>,
skills: Vec<SkillEntry>,
}
@@ -960,7 +961,7 @@ async fn list_skills(
State(state): State<RuntimeApiState>,
) -> Result<Json<SkillsResponse>, ApiError> {
let skills_dir = resolve_skills_dir(&state.config, &state.workspace);
let registry = SkillRegistry::discover(&skills_dir);
let (registry, directories) = discover_skills_for_runtime_api(&state.workspace, &skills_dir);
let skill_state = state.skill_state.lock().await;
let skills = registry
.list()
@@ -968,12 +969,14 @@ async fn list_skills(
.map(|skill| SkillEntry {
name: skill.name.clone(),
description: skill.description.clone(),
path: skills_dir.join(&skill.name).join("SKILL.md"),
path: skill.path.clone(),
enabled: skill_state.is_enabled(&skill.name),
is_bundled: skill_entry_is_bundled(skill, &skills_dir),
})
.collect();
Ok(Json(SkillsResponse {
directory: skills_dir,
directories,
warnings: registry.warnings().to_vec(),
skills,
}))
@@ -985,12 +988,12 @@ async fn set_skill_enabled(
Json(req): Json<SetSkillEnabledRequest>,
) -> Result<Json<SetSkillEnabledResponse>, ApiError> {
let skills_dir = resolve_skills_dir(&state.config, &state.workspace);
let registry = SkillRegistry::discover(&skills_dir);
let (registry, directories) = discover_skills_for_runtime_api(&state.workspace, &skills_dir);
let exists = registry.list().iter().any(|skill| skill.name == name);
if !exists {
return Err(ApiError::not_found(format!(
"skill '{name}' not found under {}",
skills_dir.display()
"skill '{name}' not found in searched directories: {}",
format_skill_search_paths(&directories)
)));
}
@@ -1766,6 +1769,50 @@ fn resolve_skills_dir(config: &Config, workspace: &std::path::Path) -> PathBuf {
config.skills_dir()
}
fn skills_search_directories(workspace: &FsPath, skills_dir: &FsPath) -> Vec<PathBuf> {
let mut directories = crate::skills::skills_directories(workspace);
if skills_dir.is_dir() && !directories.iter().any(|path| path == skills_dir) {
directories.push(skills_dir.to_path_buf());
}
directories
}
fn discover_skills_for_runtime_api(
workspace: &FsPath,
skills_dir: &FsPath,
) -> (crate::skills::SkillRegistry, Vec<PathBuf>) {
let directories = skills_search_directories(workspace, skills_dir);
let registry = crate::skills::discover_from_directories(directories.clone());
(registry, directories)
}
fn skill_entry_is_bundled(skill: &crate::skills::Skill, skills_dir: &FsPath) -> bool {
if !crate::skills::is_bundled_skill_name(&skill.name) {
return false;
}
let expected_path = skills_dir.join(&skill.name).join("SKILL.md");
paths_refer_to_same_file(&skill.path, &expected_path)
}
fn paths_refer_to_same_file(left: &FsPath, right: &FsPath) -> bool {
match (fs::canonicalize(left), fs::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}
fn format_skill_search_paths(directories: &[PathBuf]) -> String {
if directories.is_empty() {
return "<none>".to_string();
}
directories
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
fn load_mcp_config_or_default(path: &std::path::Path) -> Result<McpConfig, ApiError> {
crate::mcp::load_config(path)
.map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e:#}")))
@@ -3885,6 +3932,71 @@ mod tests {
assert_eq!(resolved, expected);
}
#[test]
fn skills_search_directories_includes_custom_skills_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
let custom_skills = tmp.path().join("custom-skills");
fs::create_dir_all(&workspace).expect("create workspace");
fs::create_dir_all(&custom_skills).expect("create custom skills");
let directories = skills_search_directories(&workspace, &custom_skills);
assert!(
directories.iter().any(|dir| dir == &custom_skills),
"custom skills_dir must be reported when discovery searches it"
);
let message = format_skill_search_paths(&directories);
assert!(message.contains("custom-skills"));
}
#[test]
fn skill_entry_is_bundled_requires_configured_bundle_path() {
let tmp = tempfile::tempdir().expect("tempdir");
let bundled_skills_dir = tmp.path().join("bundled-skills");
let bundled_skill_path = bundled_skills_dir.join("delegate").join("SKILL.md");
let override_skill_path = tmp
.path()
.join("workspace")
.join(".agents")
.join("skills")
.join("delegate")
.join("SKILL.md");
fs::create_dir_all(bundled_skill_path.parent().expect("bundled parent"))
.expect("create bundled skill dir");
fs::create_dir_all(override_skill_path.parent().expect("override parent"))
.expect("create override skill dir");
fs::write(
&bundled_skill_path,
"---\nname: delegate\ndescription: bundled\n---\n",
)
.expect("write bundled skill");
fs::write(
&override_skill_path,
"---\nname: delegate\ndescription: override\n---\n",
)
.expect("write override skill");
let bundled_skill = crate::skills::Skill {
name: "delegate".to_string(),
description: String::new(),
body: String::new(),
path: bundled_skill_path,
};
let override_skill = crate::skills::Skill {
name: "delegate".to_string(),
description: String::new(),
body: String::new(),
path: override_skill_path,
};
assert!(skill_entry_is_bundled(&bundled_skill, &bundled_skills_dir));
assert!(!skill_entry_is_bundled(
&override_skill,
&bundled_skills_dir
));
}
/// A `skills` symlink that points outside the workspace must NOT be
/// returned as the resolved skills directory. Containment check ensures
/// the canonicalized candidate stays under the canonicalized workspace
+4
View File
@@ -580,6 +580,10 @@ fn discover_for_workspace_dirs_and_dir(mut dirs: Vec<PathBuf>, skills_dir: &Path
dirs.push(skills_dir.to_path_buf());
}
discover_from_directories(dirs)
}
pub(crate) fn discover_from_directories(dirs: impl IntoIterator<Item = PathBuf>) -> SkillRegistry {
let mut merged = SkillRegistry::default();
for dir in dirs {
let registry = SkillRegistry::discover(&dir);
File diff suppressed because it is too large Load Diff
+24
View File
@@ -733,6 +733,30 @@ impl ToolRegistryBuilder {
self.with_tool(Arc::new(RememberTool))
}
/// Include the slop ledger tools (#2127) — durable tracking of
/// unresolved architectural residue: append, query, update, export.
/// Registered unconditionally; the ledger JSON file is auto-created
/// on first append.
#[must_use]
pub fn with_slop_ledger_tools(self) -> Self {
use crate::slop_ledger::{
SlopLedgerAppendTool, SlopLedgerExportTool, SlopLedgerQueryTool, SlopLedgerUpdateTool,
};
self.with_tool(Arc::new(SlopLedgerAppendTool))
.with_tool(Arc::new(SlopLedgerQueryTool))
.with_tool(Arc::new(SlopLedgerUpdateTool))
.with_tool(Arc::new(SlopLedgerExportTool))
}
/// Read-only subset of slop ledger tools (#2127) for plan mode:
/// only query and export — no append or update.
#[must_use]
pub fn with_slop_ledger_read_only_tools(self) -> Self {
use crate::slop_ledger::{SlopLedgerExportTool, SlopLedgerQueryTool};
self.with_tool(Arc::new(SlopLedgerQueryTool))
.with_tool(Arc::new(SlopLedgerExportTool))
}
/// Include the `notify` tool — model-callable desktop notification
/// (#1322). Routes through the existing `tui::notifications` OSC 9 /
/// BEL pipeline so the user's `[notifications].method` config is
+2 -1
View File
@@ -165,8 +165,9 @@ pub struct ToolContext {
/// Which search backend `web_search` should use. Default: DuckDuckGo. Set via
/// `[search] provider` in config.toml.
pub search_provider: crate::config::SearchProvider,
/// API key for Tavily, Bocha, or Metaso. `None` for Bing or DuckDuckGo.
/// API key for Tavily, Bocha, Metaso, or Baidu. `None` for Bing or DuckDuckGo.
/// Metaso also falls back to `METASO_API_KEY` env var, then a built-in key.
/// Baidu also falls back to `BAIDU_SEARCH_API_KEY`.
pub search_api_key: Option<String>,
/// Per-session workshop variable store (#548). Holds the raw content of
+286 -8
View File
@@ -1,12 +1,12 @@
//! Web search tool backed by multiple providers: Bing HTML scrape, DuckDuckGo
//! (HTML scrape with Bing fallback), Tavily API, Bocha (博查) API, and
//! Metaso API (<https://metaso.cn>).
//! (HTML scrape with Bing fallback), Tavily API, Bocha (博查) API,
//! Metaso API (<https://metaso.cn>), and Baidu AI Search.
//!
//! This is the primary web search surface for agents. For browsing workflows
//! (page open, click, screenshot) use a direct URL approach instead.
//!
//! Set `[search]` in config.toml to switch providers:
//! provider = "duckduckgo" # or tavily/bocha/metaso
//! provider = "duckduckgo" # or tavily/bocha/metaso/baidu
//! api_key = "tvly-..."
use super::spec::{
@@ -27,6 +27,7 @@ const BING_HOST: &str = "www.bing.com";
const TAVILY_ENDPOINT: &str = "https://api.tavily.com/search";
const BOCHA_ENDPOINT: &str = "https://api.bochaai.com/v1/ai/search";
const METASO_ENDPOINT: &str = "https://metaso.cn/api/v1";
const BAIDU_ENDPOINT: &str = "https://qianfan.baidubce.com/v2/ai_search/web_search";
/// Intentionally public default key provided by Metaso for open-source/community use.
/// Last-resort fallback after config and env var. Rate-limited to ~100 searches/day.
const METASO_DEFAULT_API_KEY: &str = "mk-E384C1DD5E8501BB7EFE27C949AFDE5B";
@@ -57,6 +58,7 @@ static TAG_RE: OnceLock<Regex> = OnceLock::new();
static BING_RESULT_RE: OnceLock<Regex> = OnceLock::new();
static BING_TITLE_RE: OnceLock<Regex> = OnceLock::new();
static BING_SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
static BEARER_TOKEN_RE: OnceLock<Regex> = OnceLock::new();
fn get_title_re() -> &'static Regex {
TITLE_RE.get_or_init(|| {
@@ -99,6 +101,13 @@ fn get_bing_snippet_re() -> &'static Regex {
})
}
fn get_bearer_token_re() -> &'static Regex {
BEARER_TOKEN_RE.get_or_init(|| {
Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+")
.expect("bearer token regex pattern is valid")
})
}
const DEFAULT_MAX_RESULTS: usize = 5;
const MAX_RESULTS: usize = 10;
const DEFAULT_TIMEOUT_MS: u64 = 15_000;
@@ -129,7 +138,7 @@ impl ToolSpec for WebSearchTool {
}
fn description(&self) -> &'static str {
"Search the web and return ranked results with URLs and snippets. Default backend is DuckDuckGo with Bing fallback; set `[search] provider = \"bing\" | \"tavily\" | \"bocha\"` in config.toml to switch backends. Use this instead of scraping search engines with `curl` in `exec_shell`. For a known canonical URL, prefer `fetch_url` directly."
"Search the web and return ranked results with URLs and snippets. Default backend is DuckDuckGo with Bing fallback; set `[search] provider = \"bing\" | \"tavily\" | \"bocha\" | \"metaso\" | \"baidu\"` in config.toml to switch backends. Use this instead of scraping search engines with `curl` in `exec_shell`. For a known canonical URL, prefer `fetch_url` directly."
}
fn input_schema(&self) -> Value {
@@ -210,6 +219,13 @@ impl ToolSpec for WebSearchTool {
.run_metaso_search(&query, max_results, timeout_ms, context)
.await;
}
SearchProvider::Baidu => {
let decider = context.network_policy.as_ref();
check_policy(decider, "qianfan.baidubce.com")?;
return self
.run_baidu_search(&query, max_results, timeout_ms, context)
.await;
}
SearchProvider::Bing | SearchProvider::DuckDuckGo => {}
}
@@ -645,6 +661,73 @@ impl WebSearchTool {
search_tool_result(query.to_string(), "metaso", results, None)
}
/// Search via Baidu AI Search API (<https://qianfan.baidubce.com>).
async fn run_baidu_search(
&self,
query: &str,
max_results: usize,
timeout_ms: u64,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let env_key = std::env::var("BAIDU_SEARCH_API_KEY").ok();
let api_key = context
.search_api_key
.as_deref()
.or(env_key.as_deref())
.ok_or_else(|| {
ToolError::execution_failed(
"Baidu search requires an API key. Set `BAIDU_SEARCH_API_KEY` or `[search] api_key` in config.toml.",
)
})?;
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(timeout_ms))
.build()
.map_err(|e| {
ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
})?;
let payload = baidu_search_payload(query, max_results);
let resp = client
.post(BAIDU_ENDPOINT)
.header("Authorization", format!("Bearer {api_key}"))
.json(&payload)
.send()
.await
.map_err(|e| {
ToolError::execution_failed(format!("Baidu search request failed: {e}"))
})?;
let status = resp.status();
let body = resp.text().await.map_err(|e| {
ToolError::execution_failed(format!("Failed to read Baidu response: {e}"))
})?;
if !status.is_success() {
let msg = match status.as_u16() {
401 | 403 => "Baidu search API key rejected — check BAIDU_SEARCH_API_KEY or `[search] api_key` in config.toml".to_string(),
429 => "Baidu search rate-limited — wait and retry, or check your Baidu AI Search quota".to_string(),
_ => {
let truncated = truncate_error_body(&body);
format!("Baidu search failed: HTTP {}{truncated}", status.as_u16())
}
};
return Err(ToolError::execution_failed(msg));
}
let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
ToolError::execution_failed(format!("Failed to parse Baidu response: {e}"))
})?;
if let Some(error) = baidu_error_message(&parsed) {
return Err(ToolError::execution_failed(error));
}
let results = parse_baidu_results(&parsed, max_results);
search_tool_result(query.to_string(), "baidu", results, None)
}
}
fn truncate_error_body(body: &str) -> String {
@@ -662,12 +745,87 @@ fn truncate_error_body(body: &str) -> String {
fn sanitize_error_body(body: &str) -> String {
let stripped = strip_html_tags(body);
stripped
let visible: String = stripped
.chars()
.filter(|c| !c.is_control() || c.is_ascii_whitespace())
.collect();
get_bearer_token_re()
.replace_all(&visible, "Bearer [REDACTED]")
.to_string()
}
fn parse_baidu_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
parsed
.get("references")
.and_then(|v| v.as_array())
.into_iter()
.flat_map(|arr| arr.iter())
.filter_map(|item| {
let title = item
.get("title")
.or_else(|| item.get("name"))
.and_then(|s| s.as_str())?
.trim();
let url = item
.get("url")
.or_else(|| item.get("link"))
.and_then(|s| s.as_str())?
.trim();
if title.is_empty() || url.is_empty() {
return None;
}
let snippet = item
.get("content")
.or_else(|| item.get("snippet"))
.or_else(|| item.get("summary"))
.and_then(|s| s.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string);
Some(WebSearchEntry {
title: title.to_string(),
url: url.to_string(),
snippet,
})
})
.take(max_results)
.collect()
}
fn baidu_error_message(parsed: &Value) -> Option<String> {
let code = parsed
.get("error_code")
.or_else(|| parsed.get("code"))
.and_then(|v| v.as_i64())?;
if code == 0 {
return None;
}
let message = parsed
.get("error_msg")
.or_else(|| parsed.get("message"))
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
Some(format!("Baidu search API error (code {code}: {message})"))
}
fn baidu_search_payload(query: &str, max_results: usize) -> Value {
json!({
"messages": [
{
"role": "user",
"content": query,
}
],
"search_source": "baidu_search_v2",
"resource_type_filter": [
{
"type": "web",
"top_k": max_results,
}
],
})
}
fn extract_search_query(input: &Value) -> Result<String, ToolError> {
for key in ["query", "q"] {
if let Some(value) = input.get(key) {
@@ -1034,9 +1192,10 @@ fn extract_query_param(url: &str, key: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::{
ERROR_BODY_PREVIEW_BYTES, WebSearchEntry, WebSearchTool, decode_html_entities,
extract_search_query, is_likely_spam_results, normalize_bing_url,
optional_search_max_results, root_domain, sanitize_error_body, truncate_error_body,
ERROR_BODY_PREVIEW_BYTES, WebSearchEntry, WebSearchTool, baidu_search_payload,
decode_html_entities, extract_search_query, is_likely_spam_results, normalize_bing_url,
optional_search_max_results, parse_baidu_results, root_domain, sanitize_error_body,
truncate_error_body,
};
use serde_json::json;
@@ -1314,6 +1473,96 @@ mod tests {
assert_eq!(sanitized, "error");
}
#[test]
fn sanitize_error_body_redacts_bearer_tokens() {
let body = r#"{"error":"bad token","authorization":"Bearer test-token/with+chars="}"#;
let sanitized = sanitize_error_body(body);
assert!(!sanitized.contains("test-token/with+chars="));
assert!(sanitized.contains("Bearer [REDACTED]"));
}
#[test]
fn parse_baidu_references_extracts_ranked_results() {
let body = json!({
"references": [
{
"title": "Rust 官方文档",
"url": "https://www.rust-lang.org/",
"content": "Rust 是一门注重性能和可靠性的语言。"
},
{
"title": "Cargo Book",
"url": "https://doc.rust-lang.org/cargo/",
"snippet": "Cargo is Rust's package manager."
}
]
});
let results = parse_baidu_results(&body, 10);
assert_eq!(results.len(), 2);
assert_eq!(results[0].title, "Rust 官方文档");
assert_eq!(results[0].url, "https://www.rust-lang.org/");
assert_eq!(
results[0].snippet.as_deref(),
Some("Rust 是一门注重性能和可靠性的语言。")
);
assert_eq!(results[1].title, "Cargo Book");
assert_eq!(results[1].url, "https://doc.rust-lang.org/cargo/");
assert_eq!(
results[1].snippet.as_deref(),
Some("Cargo is Rust's package manager.")
);
}
#[test]
fn parse_baidu_references_skips_incomplete_entries() {
let body = json!({
"references": [
{"title": "No URL", "content": "missing url"},
{"url": "https://example.com/no-title", "content": "missing title"},
{"title": "Valid", "url": "https://example.com/valid"}
]
});
let results = parse_baidu_results(&body, 10);
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Valid");
assert_eq!(results[0].url, "https://example.com/valid");
assert_eq!(results[0].snippet, None);
}
#[test]
fn baidu_search_payload_uses_official_search_source() {
let payload = baidu_search_payload("Rust cargo workspace", 3);
assert_eq!(
payload.get("search_source").and_then(|v| v.as_str()),
Some("baidu_search_v2")
);
assert_eq!(
payload
.get("messages")
.and_then(|v| v.as_array())
.and_then(|messages| messages.first())
.and_then(|message| message.get("content"))
.and_then(|v| v.as_str()),
Some("Rust cargo workspace")
);
assert_eq!(
payload
.get("resource_type_filter")
.and_then(|v| v.as_array())
.and_then(|filters| filters.first())
.and_then(|filter| filter.get("top_k"))
.and_then(|v| v.as_u64()),
Some(3)
);
}
#[tokio::test]
async fn tavily_provider_without_api_key_surfaces_clear_error_not_silent_fallback() {
// Trust-boundary pin: if a user has opted into Tavily but
@@ -1360,6 +1609,35 @@ mod tests {
);
}
#[tokio::test]
async fn baidu_provider_without_api_key_surfaces_clear_error_not_silent_fallback() {
use crate::config::SearchProvider;
use crate::tools::spec::{ToolContext, ToolSpec};
let prev = std::env::var_os("BAIDU_SEARCH_API_KEY");
unsafe { std::env::remove_var("BAIDU_SEARCH_API_KEY") };
let tmp = tempfile::tempdir().expect("tempdir");
let mut ctx = ToolContext::new(tmp.path().to_path_buf());
ctx.search_provider = SearchProvider::Baidu;
ctx.search_api_key = None;
let err = WebSearchTool
.execute(json!({"query": "anything"}), &ctx)
.await
.expect_err("missing api_key must surface as ToolError");
match prev {
Some(value) => unsafe { std::env::set_var("BAIDU_SEARCH_API_KEY", value) },
None => unsafe { std::env::remove_var("BAIDU_SEARCH_API_KEY") },
}
let msg = err.to_string();
assert!(
msg.contains("Baidu") && msg.contains("API key"),
"error must name the provider and missing key; got `{msg}`"
);
}
#[tokio::test]
async fn metaso_provider_uses_built_in_key_when_no_config_key_set() {
// Unlike Tavily/Bocha, Metaso falls back to a built-in default, so
+1
View File
@@ -71,6 +71,7 @@ mod ui_text;
pub mod user_input;
pub mod views;
pub mod vim_mode;
pub mod whale_routes;
pub mod widgets;
pub mod workspace_context;
+298 -54
View File
@@ -1,22 +1,16 @@
//! `/model` picker modal: pick a DeepSeek model and a thinking-effort tier
//! and apply both at once (#39).
//! `/model` picker modal: pick a model and thinking-effort tier (#39, #2026).
//!
//! Two side-by-side panes — Models on the left, Thinking effort on the
//! right. Tab swaps focus, ↑/↓ moves within the focused pane, Enter applies
//! both and closes the modal. Esc applies the last-highlighted choice and
//! closes.
//! For DeepSeek providers the picker shows whale-sized routes — model + effort
//! combinations sorted largest → fastest with friendly whale-species labels
//! (Blue Whale, Fin Whale, …, Beluga). A single ↑/↓ selection sets both
//! model and effort at once. The "auto" option is always available; custom
//! (unrecognised) model ids appear as a separate row.
//!
//! The effort pane intentionally only exposes `Off / High / Max`. Per
//! DeepSeek's [Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model),
//! `low`/`medium` are silently mapped to `high` server-side and `xhigh` is
//! mapped to `max`, so surfacing them as separate choices would be misleading.
//! The legacy variants remain valid in `~/.deepseek/settings.toml` for
//! back-compat — the picker just doesn't offer them.
//! For pass-through providers the picker falls back to the classic two-column
//! layout (Models | Thinking), with no whale labelling.
//!
//! On apply we emit a [`ViewEvent::ModelPickerApplied`] with the resolved
//! model id and effort tier; the UI handler updates `App` state, persists
//! the choice via `Settings`, and forwards `Op::SetModel` so the running
//! engine picks up the change without a restart.
//! model id and effort tier.
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
@@ -30,6 +24,7 @@ use ratatui::{
use crate::palette;
use crate::tui::app::{App, ReasoningEffort};
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
use crate::tui::whale_routes::WHALE_ROUTES;
/// Models the picker exposes by default. Kept short on purpose — power
/// users can still type `/model <id>` for anything else.
@@ -69,12 +64,21 @@ pub struct ModelPickerView {
/// When true, hide DeepSeek-specific model rows (pass-through providers
/// like openai don't support them).
hide_deepseek_models: bool,
/// When true, show whale-sized routes instead of two-column model/effort.
show_whale_routes: bool,
/// Selected whale-route index (when show_whale_routes is true).
selected_route_idx: usize,
}
impl ModelPickerView {
#[must_use]
pub fn new(app: &App) -> Self {
let hide_deepseek_models = crate::config::provider_passes_model_through(app.api_provider);
// Whale routes are DeepSeek-specific — only official providers get them.
let show_whale_routes = matches!(
app.api_provider,
crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
);
let initial_model = if app.auto_model {
"auto".to_string()
} else {
@@ -104,6 +108,34 @@ impl ModelPickerView {
.position(|e| *e == normalized)
.unwrap_or(2); // default to High if somehow unknown
// When showing whale routes, find the matching route by position in the array
// (not by sort_order, which happens to match today but is semantically wrong).
let (selected_route_idx, show_custom_model_row) = if show_whale_routes {
let idx = WHALE_ROUTES
.iter()
.position(|r| {
r.model.eq_ignore_ascii_case(&initial_model) && r.effort == normalized
})
.unwrap_or_else(|| {
// No matching whale route — key the fallback on whether the
// current model is actually "auto", not on show_custom_model_row.
// Otherwise a known DeepSeek model (e.g. v4-pro) paired with
// ReasoningEffort::Auto silently falls through to the "auto" row
// and replaces the explicit model on apply.
if initial_model.eq_ignore_ascii_case("auto") {
WHALE_ROUTES.len() // "auto" row
} else {
WHALE_ROUTES.len() + 1 // custom model row
}
});
// When the whale-route fallback selected the custom row, ensure it is
// visible so the user can see their current model in the picker.
let show_custom = show_custom_model_row || idx == WHALE_ROUTES.len() + 1;
(idx, show_custom)
} else {
(0, show_custom_model_row)
};
Self {
initial_model,
initial_effort,
@@ -113,6 +145,8 @@ impl ModelPickerView {
selection_touched: false,
show_custom_model_row,
hide_deepseek_models,
show_whale_routes,
selected_route_idx,
}
}
@@ -128,10 +162,11 @@ impl ModelPickerView {
self.visible_model_ids().len() + if self.show_custom_model_row { 1 } else { 0 }
}
/// Resolve the currently highlighted model row to a model id. If the
/// custom row is selected we return the original model from the App so
/// "Apply" doesn't blow away an unrecognised id.
/// Resolve the currently highlighted row to a model id.
fn resolved_model(&self) -> String {
if self.show_whale_routes {
return self.resolved_whale_model();
}
let visible = self.visible_model_ids();
if self.show_custom_model_row && self.selected_model_idx == visible.len() {
self.initial_model.clone()
@@ -143,13 +178,59 @@ impl ModelPickerView {
}
fn resolved_effort(&self) -> ReasoningEffort {
if self.show_whale_routes {
return self.resolved_whale_effort();
}
if self.resolved_model().trim().eq_ignore_ascii_case("auto") {
return ReasoningEffort::Auto;
}
PICKER_EFFORTS[self.selected_effort_idx]
}
/// Resolve model from the whale-route list.
fn resolved_whale_model(&self) -> String {
if self.selected_route_idx < WHALE_ROUTES.len() {
WHALE_ROUTES[self.selected_route_idx].model.to_string()
} else if self.selected_route_idx == WHALE_ROUTES.len() {
// First fallback row: always "auto".
"auto".to_string()
} else {
// Second fallback row: custom model.
self.initial_model.clone()
}
}
/// Resolve effort from the whale-route list.
fn resolved_whale_effort(&self) -> ReasoningEffort {
if self.selected_route_idx < WHALE_ROUTES.len() {
WHALE_ROUTES[self.selected_route_idx].effort
} else if self.selected_route_idx == WHALE_ROUTES.len() {
// First fallback row: "auto".
ReasoningEffort::Auto
} else {
// Second fallback row: custom model — keep the initial effort.
self.initial_effort
}
}
/// Number of rows in the whale-route list.
fn whale_route_row_count(&self) -> usize {
let base = WHALE_ROUTES.len() + 1; // routes + auto
if self.show_custom_model_row {
base + 1
} else {
base
}
}
fn move_up(&mut self) -> bool {
if self.show_whale_routes {
if self.selected_route_idx > 0 {
self.selected_route_idx -= 1;
return true;
}
return false;
}
match self.focus {
Pane::Model => {
if self.selected_model_idx > 0 {
@@ -168,6 +249,14 @@ impl ModelPickerView {
}
fn move_down(&mut self) -> bool {
if self.show_whale_routes {
let max = self.whale_route_row_count().saturating_sub(1);
if self.selected_route_idx < max {
self.selected_route_idx += 1;
return true;
}
return false;
}
match self.focus {
Pane::Model => {
let max = self.model_row_count().saturating_sub(1);
@@ -285,7 +374,9 @@ impl ModalView for ModelPickerView {
ViewAction::None
}
KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => {
self.toggle_focus();
if !self.show_whale_routes {
self.toggle_focus();
}
ViewAction::None
}
_ => ViewAction::None,
@@ -293,6 +384,88 @@ impl ModalView for ModelPickerView {
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if self.show_whale_routes {
self.render_whale_routes(area, buf);
} else {
self.render_classic(area, buf);
}
}
}
impl ModelPickerView {
/// Single-column whale-route list for DeepSeek providers.
fn render_whale_routes(&self, area: Rect, buf: &mut Buffer) {
let popup_width = 62.min(area.width.saturating_sub(4)).max(44);
let row_count = self.whale_route_row_count();
let popup_height = (row_count as u16 + 4)
.min(area.height.saturating_sub(4))
.max(8);
let popup_area = Rect {
x: area.x + (area.width.saturating_sub(popup_width)) / 2,
y: area.y + (area.height.saturating_sub(popup_height)) / 2,
width: popup_width,
height: popup_height,
};
Clear.render(popup_area, buf);
let outer = Block::default()
.title(Line::from(Span::styled(
" Whale Routes ",
Style::default()
.fg(palette::DEEPSEEK_SKY)
.add_modifier(Modifier::BOLD),
)))
.title_bottom(Line::from(vec![
Span::styled(" ↑↓ ", Style::default().fg(palette::TEXT_MUTED)),
Span::raw("choose "),
Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)),
Span::raw("apply "),
Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)),
Span::raw("apply "),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default());
let inner = outer.inner(popup_area);
outer.render(popup_area, buf);
let mut rows: Vec<(String, String)> = WHALE_ROUTES
.iter()
.map(|r| {
(
format!("{}{}", r.label, r.hint),
r.description.to_string(),
)
})
.collect();
// Fallback row 1: always "auto".
rows.push((
"auto — select per turn".to_string(),
"Let CodeWhale pick the best model each turn".to_string(),
));
// Fallback row 2: custom model when the current model isn't recognized.
if self.show_custom_model_row {
rows.push((
format!("{} — custom", self.initial_model),
"Current model (not a standard route)".to_string(),
));
}
self.render_pane(
inner,
buf,
"Model & thinking",
rows,
self.selected_route_idx,
true,
);
}
/// Classic two-column layout for pass-through providers.
fn render_classic(&self, area: Rect, buf: &mut Buffer) {
let popup_width = 64.min(area.width.saturating_sub(4)).max(40);
let popup_height = 14.min(area.height.saturating_sub(4)).max(10);
let popup_area = Rect {
@@ -455,12 +628,7 @@ mod tests {
app.auto_model = true;
app.reasoning_effort = ReasoningEffort::Off;
let mut view = ModelPickerView::new(&app);
view.selected_model_idx = 0;
view.selected_effort_idx = PICKER_EFFORTS
.iter()
.position(|effort| *effort == ReasoningEffort::Max)
.expect("max effort row");
let view = ModelPickerView::new(&app);
assert_eq!(view.resolved_model(), "auto");
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
@@ -505,53 +673,46 @@ mod tests {
}
#[test]
fn arrow_keys_move_within_focused_pane() {
fn arrow_keys_move_within_whale_routes() {
let (app, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app);
// Default focus is Model; move down then up.
let initial = view.selected_model_idx;
assert!(view.show_whale_routes);
let initial = view.selected_route_idx;
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.selected_model_idx, initial + 1);
assert_eq!(view.selected_route_idx, initial + 1);
view.handle_key(KeyEvent::new(
KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.selected_model_idx, initial);
assert_eq!(view.selected_route_idx, initial);
}
#[test]
fn tab_switches_focus_and_arrow_now_moves_effort() {
let (mut app, _lock) = create_test_app();
// Default is Max; pin to Off so the Down arrow has
// somewhere to go.
app.reasoning_effort = ReasoningEffort::Off;
fn tab_is_noop_in_whale_route_mode() {
let (app, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app);
let initial_effort_idx = view.selected_effort_idx;
assert!(view.show_whale_routes);
let before = view.selected_route_idx;
view.handle_key(KeyEvent::new(
KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.focus, Pane::Effort);
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
assert!(view.selected_effort_idx > initial_effort_idx);
assert_eq!(view.selected_route_idx, before);
}
#[test]
fn enter_emits_apply_event_with_selection() {
fn enter_with_whale_routes_emits_apply_event() {
let (mut app, _lock) = create_test_app();
app.reasoning_effort = ReasoningEffort::High;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
let mut view = ModelPickerView::new(&app);
view.handle_key(KeyEvent::new(
KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
));
// Initial route: Fin Whale (Pro + High, sort_order=1)
assert_eq!(view.selected_route_idx, 1);
// Move down to Sperm Whale (Pro + Off, sort_order=2)
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
@@ -568,13 +729,91 @@ mod tests {
..
}) => {
assert_eq!(model, "deepseek-v4-pro");
assert_eq!(effort, ReasoningEffort::Max);
assert_eq!(effort, ReasoningEffort::Off);
assert_eq!(previous_effort, ReasoningEffort::High);
}
other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
}
}
#[test]
fn whale_routes_initial_selection_matches_app_state() {
let (mut app, _lock) = create_test_app();
app.model = "deepseek-v4-flash".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Max;
let view = ModelPickerView::new(&app);
// Humpback = Flash + Max, sort_order = 3
assert_eq!(view.selected_route_idx, 3);
assert_eq!(view.resolved_model(), "deepseek-v4-flash");
assert_eq!(view.resolved_effort(), ReasoningEffort::Max);
}
#[test]
fn whale_routes_known_model_auto_effort_does_not_fall_to_auto() {
// Regression: a known DeepSeek model paired with ReasoningEffort::Auto
// must NOT fall through to the "auto" row — that would silently replace
// the explicit model with "auto" on apply.
let (mut app, _lock) = create_test_app();
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Auto;
let view = ModelPickerView::new(&app);
// Should fall to custom row (WHALE_ROUTES.len() + 1), not auto row.
assert_eq!(view.selected_route_idx, WHALE_ROUTES.len() + 1);
assert_eq!(view.resolved_model(), "deepseek-v4-pro");
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
// The custom row must be visible so the user sees their current model.
assert!(view.show_custom_model_row);
}
#[test]
fn whale_routes_auto_effort_maps_to_fallback_row() {
let (mut app, _lock) = create_test_app();
app.model = "auto".to_string();
app.auto_model = true;
app.reasoning_effort = ReasoningEffort::Auto;
let view = ModelPickerView::new(&app);
// "auto" doesn't match any whale route, falls to fallback row
assert_eq!(view.selected_route_idx, WHALE_ROUTES.len());
assert_eq!(view.resolved_model(), "auto");
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
}
#[test]
fn whale_routes_custom_model_falls_back() {
let (mut app, _lock) = create_test_app();
app.model = "deepseek-v4-pro-2026-04-XX".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::High;
let view = ModelPickerView::new(&app);
// Custom model → second fallback row (after "auto")
assert_eq!(view.selected_route_idx, WHALE_ROUTES.len() + 1);
assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX");
assert_eq!(view.resolved_effort(), ReasoningEffort::High);
// Row count includes routes + auto + custom
assert_eq!(view.whale_route_row_count(), WHALE_ROUTES.len() + 2);
}
#[test]
fn whale_routes_down_from_last_is_noop() {
let (app, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app);
// Navigate to the last row
view.selected_route_idx = view.whale_route_row_count() - 1;
let result = view.move_down();
assert!(!result);
}
#[test]
fn whale_routes_up_from_first_is_noop() {
let (app, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app);
view.selected_route_idx = 0;
let result = view.move_up();
assert!(!result);
}
#[test]
fn immediate_esc_applies_current_selection() {
let (app, _lock) = create_test_app();
@@ -592,9 +831,12 @@ mod tests {
}
#[test]
fn esc_after_selection_move_applies_highlighted_model() {
let (app, _lock) = create_test_app();
fn esc_after_selection_move_applies_highlighted_route() {
let (mut app, _lock) = create_test_app();
app.reasoning_effort = ReasoningEffort::High;
let mut view = ModelPickerView::new(&app);
// Initial: Fin Whale (Pro+High), previous_effort=High
// Down → Sperm Whale (Pro+Off)
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
@@ -608,13 +850,15 @@ mod tests {
match action {
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
model,
previous_model,
effort,
previous_effort,
..
}) => {
assert_eq!(previous_model, "deepseek-v4-pro");
assert_eq!(model, "deepseek-v4-flash");
assert_eq!(model, "deepseek-v4-pro");
assert_eq!(effort, ReasoningEffort::Off);
assert_eq!(previous_effort, ReasoningEffort::High);
}
other => panic!("expected Esc to apply highlighted model, got {other:?}"),
other => panic!("expected Esc to apply highlighted route, got {other:?}"),
}
}
+19
View File
@@ -1605,6 +1605,25 @@ async fn run_event_loop(
// composer receipt), regardless of notification method
// or platform.
if status == crate::core::events::TurnOutcomeStatus::Completed {
// SlopLedger completion-gate: after every completed
// turn, check whether there are unresolved slop entries
// the agent should address before claiming the task is
// done (#2127). This runs autonomously — no tool call
// required — so the agent can't forget to check.
if let Ok(ledger) = crate::slop_ledger::SlopLedger::load()
&& ledger.has_open_entries()
{
if let Some(gate_msg) = ledger.completion_gate_summary() {
let short =
gate_msg.lines().nth(4).unwrap_or("review before done");
app.push_status_toast(
format!("⚠️ SlopLedger: {short}"),
crate::tui::app::StatusToastLevel::Warning,
Some(12_000),
);
}
}
let tool_count = app.tool_evidence.len();
let mut receipt = "✓ turn completed".to_string();
if tool_count > 0 {
+187
View File
@@ -0,0 +1,187 @@
//! Whale-size route taxonomy for model + thinking-effort combinations (#2026).
//!
//! Maps each `(model, reasoning_effort)` pair to a friendly whale-species label,
//! sorted from largest/deepest to smallest/fastest. The labels share the same
//! species pool as sub-agent nicknames (#2016) but serve a different purpose:
//! route/tier names help users understand depth/cost/speed at a glance.
//!
//! ## Route ordering (size → speed)
//!
//! 1. Blue Whale — Pro + max thinking (largest, deepest)
//! 2. Fin Whale — Pro + high thinking
//! 3. Sperm Whale — Pro + no thinking
//! 4. Humpback — Flash + max thinking
//! 5. Minke Whale — Flash + high thinking
//! 6. Beluga — Flash + no thinking (smallest, fastest)
//!
//! Unknown or non-DeepSeek models fall back to the raw model id without
//! fake whale labeling.
use crate::tui::app::ReasoningEffort;
/// One whale-sized route: a model + thinking-effort combination with
/// a friendly label, sort order, and descriptive hint.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WhaleRoute {
/// Whale-species label, e.g. "Blue Whale".
pub label: &'static str,
/// Model id, e.g. "deepseek-v4-pro".
pub model: &'static str,
/// Reasoning effort tier.
pub effort: ReasoningEffort,
/// Sort index (0 = largest / deepest).
pub sort_order: usize,
/// Short inline hint, e.g. "Pro + max thinking".
pub hint: &'static str,
/// Longer description for tooltips / route receipts.
pub description: &'static str,
}
/// Six canonical routes, sorted largest → smallest.
pub const WHALE_ROUTES: &[WhaleRoute] = &[
WhaleRoute {
label: "Blue Whale",
model: "deepseek-v4-pro",
effort: ReasoningEffort::Max,
sort_order: 0,
hint: "Pro + max thinking",
description: "Flagship reasoning at maximum depth — architecture, debugging, security reviews",
},
WhaleRoute {
label: "Fin Whale",
model: "deepseek-v4-pro",
effort: ReasoningEffort::High,
sort_order: 1,
hint: "Pro + high thinking",
description: "Deep reasoning for complex tasks — multi-file refactors, careful planning",
},
WhaleRoute {
label: "Sperm Whale",
model: "deepseek-v4-pro",
effort: ReasoningEffort::Off,
sort_order: 2,
hint: "Pro + no thinking",
description: "Full model power without reasoning overhead — straightforward code generation",
},
WhaleRoute {
label: "Humpback",
model: "deepseek-v4-flash",
effort: ReasoningEffort::Max,
sort_order: 3,
hint: "Flash + max thinking",
description: "Fast model with reasoning depth — lightweight analysis, first-pass reviews",
},
WhaleRoute {
label: "Minke Whale",
model: "deepseek-v4-flash",
effort: ReasoningEffort::High,
sort_order: 4,
hint: "Flash + high thinking",
description: "Fast model, moderate reasoning — tool execution, read-only scouting",
},
WhaleRoute {
label: "Beluga",
model: "deepseek-v4-flash",
effort: ReasoningEffort::Off,
sort_order: 5,
hint: "Flash + no thinking",
description: "Fastest and cheapest — lookups, searches, simple edits",
},
];
impl WhaleRoute {
/// Look up the whale route for a given model id and reasoning effort.
/// Returns `None` for non-DeepSeek models or unrecognized combinations.
#[must_use]
#[allow(dead_code)]
pub fn for_model_effort(model: &str, effort: ReasoningEffort) -> Option<&'static WhaleRoute> {
WHALE_ROUTES
.iter()
.find(|r| r.model.eq_ignore_ascii_case(model) && r.effort == effort)
}
/// Look up a whale route by its sort-order index.
#[must_use]
#[allow(dead_code)]
pub fn by_sort_order(index: usize) -> Option<&'static WhaleRoute> {
WHALE_ROUTES.iter().find(|r| r.sort_order == index)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn routes_are_sorted_by_size() {
for window in WHALE_ROUTES.windows(2) {
assert!(
window[0].sort_order < window[1].sort_order,
"{} should sort before {}",
window[0].label,
window[1].label
);
}
}
#[test]
fn lookup_blue_whale_for_pro_max() {
let route = WhaleRoute::for_model_effort("deepseek-v4-pro", ReasoningEffort::Max)
.expect("blue whale route exists");
assert_eq!(route.label, "Blue Whale");
assert_eq!(route.model, "deepseek-v4-pro");
assert_eq!(route.effort, ReasoningEffort::Max);
assert_eq!(route.sort_order, 0);
}
#[test]
fn lookup_beluga_for_flash_off() {
let route = WhaleRoute::for_model_effort("deepseek-v4-flash", ReasoningEffort::Off)
.expect("beluga route exists");
assert_eq!(route.label, "Beluga");
assert_eq!(route.sort_order, 5);
}
#[test]
fn lookup_case_insensitive_model() {
let route = WhaleRoute::for_model_effort("DeepSeek-V4-Pro", ReasoningEffort::High)
.expect("case-insensitive match");
assert_eq!(route.label, "Fin Whale");
}
#[test]
fn unknown_model_returns_none() {
assert!(WhaleRoute::for_model_effort("gpt-4o", ReasoningEffort::High).is_none());
}
#[test]
fn unknown_effort_with_valid_model_returns_none() {
// ReasoningEffort::Auto is not in any whale route
assert!(WhaleRoute::for_model_effort("deepseek-v4-pro", ReasoningEffort::Auto).is_none());
}
#[test]
fn by_sort_order_finds_correct_routes() {
assert_eq!(WhaleRoute::by_sort_order(0).unwrap().label, "Blue Whale");
assert_eq!(WhaleRoute::by_sort_order(5).unwrap().label, "Beluga");
assert!(WhaleRoute::by_sort_order(99).is_none());
}
#[test]
fn every_route_has_unique_sort_order() {
let orders: Vec<usize> = WHALE_ROUTES.iter().map(|r| r.sort_order).collect();
let mut sorted = orders.clone();
sorted.sort();
sorted.dedup();
assert_eq!(orders.len(), sorted.len(), "duplicate sort orders detected");
}
#[test]
fn every_route_has_unique_label() {
let labels: Vec<&str> = WHALE_ROUTES.iter().map(|r| r.label).collect();
let mut sorted = labels.clone();
sorted.sort();
sorted.dedup();
assert_eq!(labels.len(), sorted.len(), "duplicate labels detected");
}
}
+12 -5
View File
@@ -686,14 +686,21 @@ Use `codewhale-tui features list` to inspect known flags and their effective sta
`web_search` uses DuckDuckGo by default and does not require an API key. The
DuckDuckGo path keeps a Bing fallback when DDG returns a bot challenge or no
parseable results. Bing remains selectable for users who explicitly want it,
and Tavily or Bocha can be selected when an API-backed provider is preferred.
**Metaso** ([metaso.cn](https://metaso.cn))
100 searches/day free quota — set `METASO_API_KEY` or `[search] api_key` for a higher quota.
and Tavily, Bocha, Metaso, or Baidu can be selected when an API-backed provider
is preferred.
**Metaso** ([metaso.cn](https://metaso.cn)) has a 100 searches/day free quota;
set `METASO_API_KEY` or `[search] api_key` for a higher quota.
**Baidu** uses Baidu AI Search at
`https://qianfan.baidubce.com/v2/ai_search/web_search`. Set
`BAIDU_SEARCH_API_KEY` or `[search] api_key`. This is a search-tool backend
only; it does not add a Baidu model provider.
```toml
[search]
provider = "duckduckgo" # duckduckgo | bing | tavily | bocha | metaso
# api_key = "YOUR_KEY" # required for tavily and bocha; optional for metaso (100 searches/day free quota)
provider = "baidu" # duckduckgo | bing | tavily | bocha | metaso | baidu
# api_key = "YOUR_KEY" # required for tavily, bocha, and baidu; optional for metaso
```
## Local Media Attachments
+1 -1
View File
@@ -35,7 +35,7 @@ chosen over the available shell equivalent. Companion to `crates/tui/src/prompts
|---|---|
| `grep_files` | Regex search file contents within the workspace; structured matches + context lines. Pure-Rust (`regex` crate), no `rg`/`grep` shell-out. |
| `file_search` | Fuzzy-match filenames (not contents). Use when you know roughly the name. |
| `web_search` | DuckDuckGo by default with Bing fallback; Bing, Tavily, and Bocha are selectable in config. Ranked snippets + `ref_id` for citation. |
| `web_search` | DuckDuckGo by default with Bing fallback; Bing, Tavily, Bocha, Metaso, and Baidu are selectable in config. Ranked snippets + `ref_id` for citation. |
| `fetch_url` | Direct HTTP GET on a known URL. Faster than `web_search` when the link is already known. HTML stripped to text by default. |
### Shell