From b76a11b99fd3e2878304fa49c160a904c33bec0d Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Sun, 31 May 2026 13:59:22 +0800 Subject: [PATCH 01/98] fix(state): stabilize fork migration parent links (cherry picked from commit cb22c7b70b1eaefd93fd6404dbfb08d6edd03a43) --- crates/state/src/lib.rs | 11 ++++- crates/state/tests/parity_state.rs | 73 +++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 26f12bbef..8b75306be 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -347,8 +347,15 @@ impl StateStore { SET parent_entry_id = ( SELECT m2.id FROM messages m2 - WHERE m2.created_at < messages.created_at AND m2.thread_id = messages.thread_id - ORDER BY m2.id DESC + WHERE m2.thread_id = messages.thread_id + AND ( + m2.created_at < messages.created_at + OR ( + m2.created_at = messages.created_at + AND m2.id < messages.id + ) + ) + ORDER BY m2.created_at DESC, m2.id DESC LIMIT 1 ); CREATE INDEX idx_messages_parent_entry_id ON messages(parent_entry_id); diff --git a/crates/state/tests/parity_state.rs b/crates/state/tests/parity_state.rs index 70bbe6611..733c7e90b 100644 --- a/crates/state/tests/parity_state.rs +++ b/crates/state/tests/parity_state.rs @@ -117,7 +117,7 @@ fn init_schema_migration() { VALUES ( 'thread-test-1', 'hello', false, 'deepseek', 0, 0, 'running', '/tmp/project', '0.0.0-test', 'interactive', false ); - INSERT INTO messages (thread_id, role, content, created_at) VALUES + INSERT INTO messages (thread_id, role, content, created_at) VALUES ('thread-test-1', 'foo0', 'bar0', 0), ('thread-test-1', 'foo1', 'bar1', 1), ('thread-test-1', 'foo2', 'bar2', 2); @@ -157,6 +157,76 @@ fn init_schema_migration() { StateStore::open(Some(path.clone())).expect("open state store"); } +#[test] +fn init_schema_migration_same_second_messages() { + let path = temp_state_path("init_schema_migration_same_second_messages"); + let conn = Connection::open(&path).expect("open state db"); + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT, + preview TEXT NOT NULL, + ephemeral INTEGER NOT NULL, + model_provider TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + status TEXT NOT NULL, + path TEXT, + cwd TEXT NOT NULL, + cli_version TEXT NOT NULL, + source TEXT NOT NULL, + title TEXT, + sandbox_policy TEXT, + approval_mode TEXT, + archived INTEGER NOT NULL DEFAULT 0, + archived_at INTEGER, + git_sha TEXT, + git_branch TEXT, + git_origin_url TEXT, + memory_mode TEXT + ); + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + item_json TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE + ); + INSERT INTO threads ( + id, preview, ephemeral, model_provider, created_at, updated_at, status, cwd, cli_version, source, archived + ) + VALUES ( + 'thread-test-2', 'hello', false, 'deepseek', 0, 0, 'running', '/tmp/project', '0.0.0-test', 'interactive', false + ); + INSERT INTO messages (thread_id, role, content, created_at) VALUES + ('thread-test-2', 'foo0', 'bar0', 123), + ('thread-test-2', 'foo1', 'bar1', 123), + ('thread-test-2', 'foo2', 'bar2', 123), + ('thread-test-2', 'foo3', 'bar3', 123); + "#, + ) + .expect("init schema migration"); + + let store = StateStore::open(Some(path.clone())).expect("open state store"); + let messages = store + .list_messages("thread-test-2", None) + .expect("list messages"); + assert_eq!(messages.len(), 4); + for (i, message) in messages.iter().enumerate() { + assert_eq!(message.thread_id, "thread-test-2"); + assert_eq!(message.role, format!("foo{}", i)); + assert_eq!(message.content, format!("bar{}", i)); + assert_eq!(message.created_at, 123); + } + assert_eq!(messages[0].parent_entry_id, None); + assert_eq!(messages[1].parent_entry_id, Some(messages[0].id)); + assert_eq!(messages[2].parent_entry_id, Some(messages[1].id)); + assert_eq!(messages[3].parent_entry_id, Some(messages[2].id)); +} + #[test] fn test_fork() { let path = temp_state_path("test_fork"); @@ -278,6 +348,5 @@ fn test_fork() { .get_thread("thread-test-1") .expect("get thread") .unwrap(); - dbg!(&thread); assert!(thread.current_leaf_id.is_none()); } From 18550339a5c02b65789297f275b109dcbcf7648c Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 05:43:09 -0700 Subject: [PATCH 02/98] test(state): cover same-second migration idempotency --- crates/state/tests/parity_state.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/state/tests/parity_state.rs b/crates/state/tests/parity_state.rs index 733c7e90b..2590b2a59 100644 --- a/crates/state/tests/parity_state.rs +++ b/crates/state/tests/parity_state.rs @@ -225,6 +225,9 @@ fn init_schema_migration_same_second_messages() { assert_eq!(messages[1].parent_entry_id, Some(messages[0].id)); assert_eq!(messages[2].parent_entry_id, Some(messages[1].id)); assert_eq!(messages[3].parent_entry_id, Some(messages[2].id)); + + // Test idempotent reopen after same-second parent links are migrated. + StateStore::open(Some(path.clone())).expect("open state store - idempotent"); } #[test] From 3df018994fc62e21a452a2eff18d22d29e297651 Mon Sep 17 00:00:00 2001 From: greyfreedom Date: Sun, 31 May 2026 20:13:40 +0800 Subject: [PATCH 03/98] feat(config): load typed ask permissions file (cherry picked from commit fb77cf1e0946a061376e5e9a8fc9422dddd98419) --- Cargo.lock | 1 + config.example.toml | 15 +++ crates/config/Cargo.toml | 1 + crates/config/src/lib.rs | 208 +++++++++++++++++++++++++++++++++-- crates/execpolicy/src/lib.rs | 1 + docs/CONFIGURATION.md | 6 + 6 files changed, 220 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 328d19300..50d4b04e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -866,6 +866,7 @@ name = "codewhale-config" version = "0.8.49" dependencies = [ "anyhow", + "codewhale-execpolicy", "codewhale-secrets", "dirs", "serde", diff --git a/config.example.toml b/config.example.toml index b4d21c158..562fa14e8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -133,6 +133,21 @@ allow_shell = true approval_policy = "on-request" # on-request | untrusted | never sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox +# Typed permission rules live in a sibling `permissions.toml` file, not in +# config.toml. This schema slice is ask-only and is parsed for follow-up +# approval-flow wiring; allow/deny records and UI persistence are intentionally +# out of scope here. +# +# Example ~/.codewhale/permissions.toml: +# +# [[rules]] +# tool = "exec_shell" +# command = "cargo test" +# +# [[rules]] +# tool = "read_file" +# path = "secrets/**" + # ───────────────────────────────────────────────────────────────────────────────── # External Sandbox Backend (pluggable remote execution) # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 442d3666c..726c06304 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,6 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.49" } codewhale-secrets = { path = "../secrets", version = "0.8.49" } dirs.workspace = true serde.workspace = true diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index a09569a39..866ab5a59 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -6,6 +6,7 @@ use std::path::{Component, Path, PathBuf}; use std::sync::OnceLock; use anyhow::{Context, Result, bail}; +pub use codewhale_execpolicy::ToolAskRule; use codewhale_secrets::SecretSource; pub use codewhale_secrets::Secrets; use serde::{Deserialize, Serialize}; @@ -14,6 +15,7 @@ use serde::{Deserialize, Serialize}; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; pub const CONFIG_FILE_NAME: &str = "config.toml"; +pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml"; const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro"; const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro"; const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; @@ -198,6 +200,25 @@ pub struct ProvidersToml { pub ollama: ProviderConfigToml, } +/// Sibling `permissions.toml` schema. +/// +/// This slice is intentionally ask-only: each rule is a typed condition that +/// means "ask before this tool invocation." Typed allow/deny records and UI +/// persistence are expected to land in follow-up PRs. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PermissionsToml { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, +} + +impl PermissionsToml { + #[must_use] + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } +} + impl ProvidersToml { #[must_use] pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml { @@ -1751,26 +1772,26 @@ pub struct ResolvedRuntimeOptions { pub struct ConfigStore { path: PathBuf, pub config: ConfigToml, + permissions: PermissionsToml, } impl ConfigStore { pub fn load(path: Option) -> Result { let path = resolve_config_path(path)?; - if !path.exists() { - return Ok(Self { - path, - config: ConfigToml::default(), - }); - } - - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - let parsed: ConfigToml = toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))?; + let config = 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 { + ConfigToml::default() + }; + let permissions = load_sibling_permissions(&path)?; Ok(Self { path, - config: parsed, + config, + permissions, }) } @@ -1812,6 +1833,16 @@ impl ConfigStore { pub fn path(&self) -> &Path { &self.path } + + #[must_use] + pub fn permissions(&self) -> &PermissionsToml { + &self.permissions + } + + #[must_use] + pub fn permissions_path(&self) -> PathBuf { + permissions_path_for_config_path(&self.path) + } } /// Process-wide default [`Secrets`] façade. The first caller wins; the @@ -1949,6 +1980,37 @@ pub fn resolve_config_path(explicit: Option) -> Result { normalize_config_file_path(path) } +#[must_use] +pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf { + config_path.with_file_name(PERMISSIONS_FILE_NAME) +} + +pub fn resolve_permissions_path(config_path: Option) -> Result { + Ok(permissions_path_for_config_path(&resolve_config_path( + config_path, + )?)) +} + +fn load_sibling_permissions(config_path: &Path) -> Result { + let permissions_path = permissions_path_for_config_path(config_path); + if !permissions_path.exists() { + return Ok(PermissionsToml::default()); + } + + let raw = fs::read_to_string(&permissions_path).with_context(|| { + format!( + "failed to read permissions at {}", + permissions_path.display() + ) + })?; + toml::from_str(&raw).with_context(|| { + format!( + "failed to parse permissions at {}", + permissions_path.display() + ) + }) +} + pub fn default_config_path() -> Result { // Prefer ~/.codewhale/config.toml when it exists (fresh install or // migrated), otherwise fall back to ~/.deepseek/config.toml. @@ -2285,6 +2347,127 @@ mod tests { assert!(policy.audit); } + #[test] + fn permissions_toml_deserializes_typed_ask_rules() { + let permissions: PermissionsToml = toml::from_str( + r#" + [[rules]] + tool = "exec_shell" + command = "cargo test" + + [[rules]] + tool = "read_file" + path = "secrets/**" + "#, + ) + .expect("permissions toml"); + + assert_eq!( + permissions.rules, + vec![ + ToolAskRule::exec_shell("cargo test"), + ToolAskRule::file_path("read_file", "secrets/**"), + ] + ); + } + + #[test] + fn permissions_toml_rejects_typed_allow_deny_shape() { + let err = toml::from_str::( + r#" + [[rules]] + tool = "exec_shell" + decision = "allow" + command = "cargo test" + "#, + ) + .expect_err("permissions.toml should be ask-only in this slice"); + + assert!(err.message().contains("unknown field")); + } + + #[test] + fn config_store_loads_sibling_permissions_toml() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "codewhale-permissions-schema-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("mkdir"); + let config_path = dir.join(CONFIG_FILE_NAME); + fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config"); + fs::write( + dir.join(PERMISSIONS_FILE_NAME), + r#" + [[rules]] + tool = "exec_shell" + command = "cargo test" + + [[rules]] + tool = "read_file" + path = "secrets/**" + "#, + ) + .expect("write permissions"); + + let store = ConfigStore::load(Some(config_path.clone())).expect("load config store"); + + assert_eq!(store.config.model.as_deref(), Some("deepseek-v4-flash")); + assert_eq!( + store.permissions().rules.as_slice(), + &[ + ToolAskRule::exec_shell("cargo test"), + ToolAskRule::file_path("read_file", "secrets/**"), + ] + ); + assert_eq!( + store.permissions_path(), + config_path.with_file_name(PERMISSIONS_FILE_NAME) + ); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn config_store_loads_permissions_even_when_config_is_absent() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "codewhale-permissions-only-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("mkdir"); + let config_path = dir.join(CONFIG_FILE_NAME); + fs::write( + dir.join(PERMISSIONS_FILE_NAME), + r#" + [[rules]] + tool = "exec_shell" + command = "cargo check" + "#, + ) + .expect("write permissions"); + + let store = ConfigStore::load(Some(config_path)).expect("load config store"); + + assert!(store.config.model.is_none()); + assert_eq!( + store.permissions().rules.as_slice(), + &[ToolAskRule::exec_shell("cargo check")] + ); + + let _ = fs::remove_dir_all(dir); + } + struct EnvGuard { deepseek_api_key: Option, deepseek_base_url: Option, @@ -3143,6 +3326,7 @@ unix_socket_path = "/tmp/cw-hooks.sock" api_key: Some("new-secret".to_string()), ..ConfigToml::default() }, + permissions: PermissionsToml::default(), }; store.save().expect("save"); diff --git a/crates/execpolicy/src/lib.rs b/crates/execpolicy/src/lib.rs index 4489b0eb2..8a6003047 100644 --- a/crates/execpolicy/src/lib.rs +++ b/crates/execpolicy/src/lib.rs @@ -75,6 +75,7 @@ impl Ruleset { /// prefix behavior is preserved while typed ask records can make /// `AskForApproval::Never` reject invocations that cannot be approved. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct ToolAskRule { /// Name of the tool this rule applies to (e.g. `"exec_shell"`, `"edit_file"`). pub tool: String, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index de9a03a6a..41251e7a3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -601,6 +601,12 @@ If you are upgrading from older releases: with process-tree containment only and must not be described as read-only filesystem isolation, workspace-write enforcement, network blocking, registry isolation, or AppContainer isolation until those are implemented. +- `permissions.toml` (sibling file, optional): ask-only typed permission rule + records loaded next to `config.toml`, for example + `~/.codewhale/permissions.toml`. This schema foundation accepts + `[[rules]]` entries with `tool` plus optional `command` or `path` fields. + It intentionally does not accept typed allow/deny records or provide approval + UI persistence yet. - `managed_config_path` (string, optional): managed config file loaded after user/env config. - `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values. - `max_subagents` (int, optional): defaults to `10` and is clamped to `1..=20`. From 69cff93754a4579ea88373e6e64f46d3f5be8540 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 05:43:45 -0700 Subject: [PATCH 04/98] docs(config): use exact path in permissions example --- config.example.toml | 2 +- crates/config/src/lib.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config.example.toml b/config.example.toml index 562fa14e8..b77755041 100644 --- a/config.example.toml +++ b/config.example.toml @@ -146,7 +146,7 @@ sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-acc # # [[rules]] # tool = "read_file" -# path = "secrets/**" +# path = "secrets/api_key.txt" # ───────────────────────────────────────────────────────────────────────────────── # External Sandbox Backend (pluggable remote execution) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 866ab5a59..870f3c674 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -2357,7 +2357,7 @@ mod tests { [[rules]] tool = "read_file" - path = "secrets/**" + path = "secrets/api_key.txt" "#, ) .expect("permissions toml"); @@ -2366,7 +2366,7 @@ mod tests { permissions.rules, vec![ ToolAskRule::exec_shell("cargo test"), - ToolAskRule::file_path("read_file", "secrets/**"), + ToolAskRule::file_path("read_file", "secrets/api_key.txt"), ] ); } @@ -2410,7 +2410,7 @@ mod tests { [[rules]] tool = "read_file" - path = "secrets/**" + path = "secrets/api_key.txt" "#, ) .expect("write permissions"); @@ -2422,7 +2422,7 @@ mod tests { store.permissions().rules.as_slice(), &[ ToolAskRule::exec_shell("cargo test"), - ToolAskRule::file_path("read_file", "secrets/**"), + ToolAskRule::file_path("read_file", "secrets/api_key.txt"), ] ); assert_eq!( From 49791905f9ad357aa27039e951e1d449ef4fed38 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Mon, 1 Jun 2026 20:30:24 +0800 Subject: [PATCH 05/98] feat(tools): add byte-level schema canonicalize for prefix-cache stability When MCP servers return tool schemas, the field order within each schema object and the order of entries in required / dependentRequired arrays can vary across reconnections. This causes the serialized tool catalog bytes to change even when the logical schema is unchanged, busting DeepSeek's KV prefix cache. Add schema_canonicalize::canonicalize_schema which recursively: - Sorts every required array alphabetically - Sorts every dependentRequired sub-array alphabetically - Rebuilds object keys in alphabetical order - Recurses into all nested objects and arrays The canonicalize step runs after schema_sanitize in build_api_tools, so each tool's input_schema is first cleaned then byte-stabilized. The existing OnceLock api_cache pins the result, ensuring the tool catalog bytes are identical across reads and across process restarts. 8 unit tests cover: required sorting, dependentRequired sorting, equivalent-ordering byte match, recursive nesting, empty schemas, deeply nested schemas, non-required array preservation, and key ordering. (cherry picked from commit 7cee9cd5e12a74e8072bf2f6a1b18555ed0db0bf) --- crates/tui/src/tools/mod.rs | 1 + crates/tui/src/tools/registry.rs | 2 + crates/tui/src/tools/schema_canonicalize.rs | 207 ++++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 crates/tui/src/tools/schema_canonicalize.rs diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index db1e0f707..15bf39cb6 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -41,6 +41,7 @@ pub mod remember; pub mod revert_turn; pub mod review; pub mod rlm; +pub mod schema_canonicalize; pub mod schema_sanitize; pub mod search; pub mod shell; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index b33c79c5e..57b485b1d 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -16,6 +16,7 @@ use serde_json::Value; use crate::client::DeepSeekClient; use crate::models::Tool; +use super::schema_canonicalize; use super::schema_sanitize; use super::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, @@ -224,6 +225,7 @@ impl ToolRegistry { .map(|tool| { let mut schema = tool.input_schema(); schema_sanitize::sanitize(&mut schema); + schema_canonicalize::canonicalize_schema(&mut schema); Tool { tool_type: None, name: tool.name().to_string(), diff --git a/crates/tui/src/tools/schema_canonicalize.rs b/crates/tui/src/tools/schema_canonicalize.rs new file mode 100644 index 000000000..ae5a7d071 --- /dev/null +++ b/crates/tui/src/tools/schema_canonicalize.rs @@ -0,0 +1,207 @@ +//! Byte-level canonicalization of JSON Schema for prefix-cache stability. +//! +//! When MCP servers return tool schemas, the field order within each schema +//! object and the order of entries in `required` / `dependentRequired` arrays +//! can vary across reconnections. This module normalizes those orderings so +//! that two logically equivalent schemas always produce identical bytes after +//! serialization. +//! +//! The approach mirrors `reasonix/internal/provider/schema_canonicalize.go`: +//! +//! 1. Sort every `"required"` array alphabetically. +//! 2. Sort every `"dependentRequired"` sub-array alphabetically. +//! 3. Recurse into all nested objects and arrays. +//! +//! `serde_json::Value::Object` uses `IndexMap` when `preserve_order` is +//! enabled (which this crate does). We therefore rebuild the map with sorted +//! keys to guarantee deterministic key ordering. + +use serde_json::Value; + +/// Recursively canonicalize a JSON Schema value in-place. +/// +/// After canonicalization, two schemas that are semantically equivalent +/// (same keys, same `required` set, same `dependentRequired` sets) will +/// serialize to byte-identical JSON regardless of the original field or +/// array order. +pub fn canonicalize_schema(value: &mut Value) { + match value { + Value::Object(map) => { + // Sort `required` arrays (they are sets per JSON Schema spec). + if let Some(Value::Array(req)) = map.get_mut("required") { + sort_string_array(req); + } + // Sort `dependentRequired` sub-arrays. + if let Some(Value::Object(deps)) = map.get_mut("dependentRequired") { + for dep_value in deps.values_mut() { + if let Value::Array(arr) = dep_value { + sort_string_array(arr); + } + } + } + // Recurse into every child value. + for v in map.values_mut() { + canonicalize_schema(v); + } + // Rebuild the map with sorted keys so serialization is deterministic. + // serde_json::Map backed by IndexMap (preserve_order) doesn't have + // drain(), so we swap to a temporary and rebuild. + let old = std::mem::take(map); + let mut entries: Vec<(String, Value)> = old.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + for (k, v) in entries { + map.insert(k, v); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + canonicalize_schema(v); + } + } + _ => {} + } +} + +/// Sort a JSON array of string values alphabetically in-place. +/// +/// Non-string entries are left at the end in their original relative order. +fn sort_string_array(arr: &mut [Value]) { + arr.sort_by(|a, b| match (a.as_str(), b.as_str()) { + (Some(x), Some(y)) => x.cmp(y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn sorts_required_array() { + let mut schema = json!({ + "type": "object", + "required": ["z", "a", "m"], + "properties": {} + }); + canonicalize_schema(&mut schema); + assert_eq!(schema["required"], json!(["a", "m", "z"])); + } + + #[test] + fn equivalent_ordering_matches() { + // Two schemas that differ only in field order and required order + // must serialize to identical bytes. + let mut a = json!({ + "required": ["b", "a"], + "properties": {"x": {}, "y": {}}, + "type": "object" + }); + let mut b = json!({ + "type": "object", + "properties": {"y": {}, "x": {}}, + "required": ["a", "b"] + }); + canonicalize_schema(&mut a); + canonicalize_schema(&mut b); + assert_eq!( + serde_json::to_string(&a).unwrap(), + serde_json::to_string(&b).unwrap(), + "logically equivalent schemas must produce identical bytes" + ); + } + + #[test] + fn sorts_dependent_required() { + let mut schema = json!({ + "type": "object", + "dependentRequired": { + "x": ["z", "a"], + "y": ["m", "b"] + } + }); + canonicalize_schema(&mut schema); + assert_eq!(schema["dependentRequired"]["x"], json!(["a", "z"])); + assert_eq!(schema["dependentRequired"]["y"], json!(["b", "m"])); + } + + #[test] + fn recursive_into_properties() { + let mut schema = json!({ + "type": "object", + "properties": { + "nested": { + "type": "object", + "required": ["z", "a"], + "properties": {} + } + } + }); + canonicalize_schema(&mut schema); + assert_eq!( + schema["properties"]["nested"]["required"], + json!(["a", "z"]) + ); + } + + #[test] + fn preserves_non_required_array_order() { + // Arrays that are not `required` or `dependentRequired` should + // keep their semantic order (e.g. enum values, oneOf items). + let mut schema = json!({ + "type": "string", + "enum": ["z", "a", "m"] + }); + canonicalize_schema(&mut schema); + assert_eq!(schema["enum"], json!(["z", "a", "m"])); + } + + #[test] + fn handles_empty_schema() { + let mut schema = json!({}); + canonicalize_schema(&mut schema); + assert_eq!(schema, json!({})); + } + + #[test] + fn handles_deeply_nested() { + let mut schema = json!({ + "type": "object", + "properties": { + "level1": { + "type": "object", + "properties": { + "level2": { + "type": "object", + "required": ["z", "a"] + } + } + } + } + }); + canonicalize_schema(&mut schema); + assert_eq!( + schema["properties"]["level1"]["properties"]["level2"]["required"], + json!(["a", "z"]) + ); + } + + #[test] + fn key_order_is_alphabetical_after_canonicalize() { + let mut schema = json!({ + "z_field": 1, + "a_field": 2, + "m_field": 3 + }); + canonicalize_schema(&mut schema); + let keys: Vec<&str> = schema + .as_object() + .unwrap() + .keys() + .map(|s| s.as_str()) + .collect(); + assert_eq!(keys, vec!["a_field", "m_field", "z_field"]); + } +} From cb4f660a2006bf1a24f7213c67a94fd162cf98af Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Mon, 1 Jun 2026 12:31:10 +0200 Subject: [PATCH 06/98] fix(tui): contain Windows shell process trees (cherry picked from commit 6cdea3288637267d282015fbdc1074da5d8a97db) --- crates/tui/Cargo.toml | 2 +- crates/tui/src/tools/shell.rs | 145 +++++++++++++++++++++++++++- crates/tui/src/tools/shell/tests.rs | 32 ++++++ 3 files changed, 174 insertions(+), 5 deletions(-) diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index b8183b5d3..095e4b79e 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -95,4 +95,4 @@ objc2 = "0.6.3" objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSError", "NSObject", "NSString", "NSURL"] } [target.'cfg(target_os = "windows")'.dependencies] -windows = { version = "0.60", features = ["Win32_Foundation", "Win32_System_Console", "Win32_UI_WindowsAndMessaging", "Win32_System_Diagnostics_Debug", "Win32_System_Threading"] } +windows = { version = "0.60", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 2cfae1929..7f46d2da2 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -21,6 +21,18 @@ use wait_timeout::ChildExt; #[cfg(unix)] use std::os::unix::process::CommandExt; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; +#[cfg(windows)] +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +#[cfg(windows)] +use windows::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, +}; +#[cfg(windows)] +use windows::core::PCWSTR; use portable_pty::{CommandBuilder, PtySize, native_pty_system}; @@ -223,6 +235,75 @@ fn install_parent_death_signal(_cmd: &mut Command) { // leak children on those platforms — tracked as a follow-up. } +#[cfg(windows)] +#[derive(Debug)] +struct WindowsJob { + handle: HANDLE, +} + +#[cfg(windows)] +// SAFETY: Windows job handles are process-wide kernel handles. Moving the +// wrapper between threads does not invalidate the handle, and access is +// externally synchronized by ShellManager's mutex. +unsafe impl Send for WindowsJob {} +#[cfg(windows)] +// SAFETY: The wrapper exposes only terminate/drop operations around a kernel +// handle; concurrent use is guarded by ShellManager. +unsafe impl Sync for WindowsJob {} + +#[cfg(windows)] +impl WindowsJob { + fn attach_to_child(child: &Child) -> std::io::Result { + let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? }; + let job = Self { handle }; + + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + unsafe { + SetInformationJobObject( + job.handle, + JobObjectExtendedLimitInformation, + &limits as *const _ as *const core::ffi::c_void, + std::mem::size_of::() as u32, + ) + .map_err(windows_io_error)?; + + let process_handle = HANDLE(child.as_raw_handle() as *mut core::ffi::c_void); + AssignProcessToJobObject(job.handle, process_handle).map_err(windows_io_error)?; + } + + Ok(job) + } + + fn terminate(&self) -> std::io::Result<()> { + unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) } + } +} + +#[cfg(windows)] +impl Drop for WindowsJob { + fn drop(&mut self) { + unsafe { + let _ = CloseHandle(self.handle); + } + } +} + +#[cfg(windows)] +fn windows_io_error(error: windows::core::Error) -> std::io::Error { + std::io::Error::other(error) +} + +#[cfg(windows)] +fn terminate_windows_job(job: Option<&WindowsJob>, child: &mut Child) -> std::io::Result<()> { + if let Some(job) = job { + job.terminate() + } else { + child.kill() + } +} + #[derive(Clone, Copy, Debug)] struct ShellExitStatus { code: Option, @@ -333,6 +414,8 @@ pub struct BackgroundShell { stderr_cursor: usize, stdin: Option, child: Option, + #[cfg(windows)] + windows_job: Option, stdout_thread: Option>, stderr_thread: Option>, } @@ -379,6 +462,10 @@ impl BackgroundShell { if let Some(ShellChild::Process(ref mut proc)) = self.child { let _ = kill_child_process_group(proc); } + #[cfg(windows)] + if let Some(job) = self.windows_job.as_ref() { + let _ = job.terminate(); + } if let Some(handle) = self.stdout_thread.take() { let _ = handle.join(); } @@ -386,6 +473,10 @@ impl BackgroundShell { let _ = handle.join(); } self.stdin = None; + #[cfg(windows)] + { + self.windows_job = None; + } self.child = None; } @@ -470,8 +561,22 @@ impl BackgroundShell { /// Kill the process fn kill(&mut self) -> Result<()> { if let Some(ref mut child) = self.child { - child.kill().context("Failed to kill process")?; - let _ = child.wait(); + if let ShellChild::Process(proc) = child { + #[cfg(windows)] + { + terminate_windows_job(self.windows_job.as_ref(), proc) + .context("Failed to kill process tree")?; + let _ = proc.wait(); + } + #[cfg(not(windows))] + { + proc.kill().context("Failed to kill process")?; + let _ = proc.wait(); + } + } else { + child.kill().context("Failed to kill process")?; + let _ = child.wait(); + } } self.status = ShellStatus::Killed; self.collect_output(); @@ -553,6 +658,13 @@ impl Drop for BackgroundShell { if self.status == ShellStatus::Running && let Some(ref mut child) = self.child { + #[cfg(windows)] + if let ShellChild::Process(proc) = child { + let _ = terminate_windows_job(self.windows_job.as_ref(), proc); + } else { + let _ = child.kill(); + } + #[cfg(not(windows))] let _ = child.kill(); let _ = child.wait(); } @@ -869,6 +981,8 @@ impl ShellManager { let mut child = cmd .spawn() .with_context(|| format!("Failed to execute: {original_command}"))?; + #[cfg(windows)] + let windows_job = WindowsJob::attach_to_child(&child).ok(); if let Some(input) = stdin_data && let Some(mut stdin) = child.stdin.take() @@ -899,6 +1013,10 @@ impl ShellManager { // Wait with timeout if let Some(status) = child.wait_timeout(timeout)? { + #[cfg(windows)] + if let Some(job) = windows_job.as_ref() { + let _ = job.terminate(); + } let stdout = stdout_thread.join().unwrap_or_default(); let stderr = stderr_thread.join().unwrap_or_default(); let stdout_str = String::from_utf8_lossy(&stdout).to_string(); @@ -939,7 +1057,9 @@ impl ShellManager { // Timeout - kill the process #[cfg(unix)] let _ = kill_child_process_group(&mut child); - #[cfg(not(unix))] + #[cfg(windows)] + let _ = terminate_windows_job(windows_job.as_ref(), &mut child); + #[cfg(all(not(unix), not(windows)))] let _ = child.kill(); let status = child.wait().ok(); let stdout = stdout_thread.join().unwrap_or_default(); @@ -1025,8 +1145,14 @@ impl ShellManager { let mut child = cmd .spawn() .with_context(|| format!("Failed to execute: {original_command}"))?; + #[cfg(windows)] + let windows_job = WindowsJob::attach_to_child(&child).ok(); if let Some(status) = child.wait_timeout(timeout)? { + #[cfg(windows)] + if let Some(job) = windows_job.as_ref() { + let _ = job.terminate(); + } Ok(ShellResult { task_id: None, status: if status.success() { @@ -1055,7 +1181,9 @@ impl ShellManager { } else { #[cfg(unix)] let _ = kill_child_process_group(&mut child); - #[cfg(not(unix))] + #[cfg(windows)] + let _ = terminate_windows_job(windows_job.as_ref(), &mut child); + #[cfg(all(not(unix), not(windows)))] let _ = child.kill(); let status = child.wait().ok(); @@ -1108,6 +1236,9 @@ impl ShellManager { Some(Arc::new(Mutex::new(Vec::new()))) }; + #[cfg(windows)] + let mut windows_job = None; + let (child, stdin, stdout_thread, stderr_thread) = if tty { let pty_system = native_pty_system(); let pair = pty_system @@ -1165,6 +1296,10 @@ impl ShellManager { let mut child = cmd .spawn() .with_context(|| format!("Failed to spawn background: {original_command}"))?; + #[cfg(windows)] + { + windows_job = WindowsJob::attach_to_child(&child).ok(); + } let stdout_handle = child.stdout.take().context("Failed to capture stdout")?; let stderr_handle = child.stderr.take().context("Failed to capture stderr")?; @@ -1201,6 +1336,8 @@ impl ShellManager { stderr_cursor: 0, stdin, child: Some(child), + #[cfg(windows)] + windows_job, stdout_thread, stderr_thread, }; diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 9bfb9d7de..4894df9bc 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -922,6 +922,38 @@ fn test_orphaned_subprocess_does_not_block_collect_output() { assert_eq!(done.status, ShellStatus::Completed); } +// Windows equivalent of the orphaned pipe-handle regression. `cmd /c start /b` +// launches a descendant process that inherits stdout/stderr and outlives the +// shell. Job-object cleanup must terminate that descendant before reader-thread +// joins, otherwise get_output() blocks until ping exits. +#[cfg(windows)] +#[test] +fn background_collection_does_not_block_on_detached_descendant_pipe() { + let tmp = tempdir().expect("tempdir"); + let mut manager = ShellManager::new(tmp.path().to_path_buf()); + + let result = manager + .execute( + r#"cmd /c start "" /b ping 127.0.0.1 -n 8"#, + None, + 5000, + true, + ) + .expect("execute"); + let task_id = result.task_id.expect("task id"); + + let started = std::time::Instant::now(); + let done = manager + .get_output(&task_id, true, 3000) + .expect("get_output must complete, not hang"); + + assert!( + started.elapsed() < std::time::Duration::from_secs(3), + "get_output blocked on descendant pipe handles" + ); + assert_eq!(done.status, ShellStatus::Completed); +} + #[test] fn test_list_jobs_cleans_up_completed_old_processes() { let tmp = tempdir().expect("tempdir"); From 382635e4aa4ef8e0a4f2b549c2b6afe51b7d47e8 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Mon, 1 Jun 2026 13:10:53 +0200 Subject: [PATCH 07/98] fix(tui): harden Windows job cleanup (cherry picked from commit 3ab06d92ab7c74f43473af5ddf4ffdd31cfd5f3d) --- crates/tui/src/tools/shell.rs | 34 ++++++++++++++++++++++++----- crates/tui/src/tools/shell/tests.rs | 4 ++-- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 7f46d2da2..f21b42ada 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -298,9 +298,31 @@ fn windows_io_error(error: windows::core::Error) -> std::io::Error { #[cfg(windows)] fn terminate_windows_job(job: Option<&WindowsJob>, child: &mut Child) -> std::io::Result<()> { if let Some(job) = job { - job.terminate() - } else { - child.kill() + match job.terminate() { + Ok(()) => return Ok(()), + Err(error) => { + tracing::warn!( + ?error, + "failed to terminate Windows job object; falling back to immediate child kill" + ); + } + } + } + child.kill() +} + +#[cfg(windows)] +fn attach_windows_job(child: &Child, command: &str) -> Option { + match WindowsJob::attach_to_child(child) { + Ok(job) => Some(job), + Err(error) => { + tracing::warn!( + ?error, + command, + "failed to attach Windows shell process to job object; descendant cleanup degraded" + ); + None + } } } @@ -982,7 +1004,7 @@ impl ShellManager { .spawn() .with_context(|| format!("Failed to execute: {original_command}"))?; #[cfg(windows)] - let windows_job = WindowsJob::attach_to_child(&child).ok(); + let windows_job = attach_windows_job(&child, original_command); if let Some(input) = stdin_data && let Some(mut stdin) = child.stdin.take() @@ -1146,7 +1168,7 @@ impl ShellManager { .spawn() .with_context(|| format!("Failed to execute: {original_command}"))?; #[cfg(windows)] - let windows_job = WindowsJob::attach_to_child(&child).ok(); + let windows_job = attach_windows_job(&child, original_command); if let Some(status) = child.wait_timeout(timeout)? { #[cfg(windows)] @@ -1298,7 +1320,7 @@ impl ShellManager { .with_context(|| format!("Failed to spawn background: {original_command}"))?; #[cfg(windows)] { - windows_job = WindowsJob::attach_to_child(&child).ok(); + windows_job = attach_windows_job(&child, original_command); } let stdout_handle = child.stdout.take().context("Failed to capture stdout")?; diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 4894df9bc..5a3ed92c5 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -934,7 +934,7 @@ fn background_collection_does_not_block_on_detached_descendant_pipe() { let result = manager .execute( - r#"cmd /c start "" /b ping 127.0.0.1 -n 8"#, + r#"cmd /c start "" /b ping 127.0.0.1 -n 4"#, None, 5000, true, @@ -948,7 +948,7 @@ fn background_collection_does_not_block_on_detached_descendant_pipe() { .expect("get_output must complete, not hang"); assert!( - started.elapsed() < std::time::Duration::from_secs(3), + started.elapsed() < std::time::Duration::from_secs(6), "get_output blocked on descendant pipe handles" ); assert_eq!(done.status, ShellStatus::Completed); From 998af56d6a14e2b730a7d3ad6d6f8c580305c4fd Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 06:01:03 -0700 Subject: [PATCH 08/98] chore(release): harden deepseek-tui deprecation path --- .github/workflows/auto-tag.yml | 1 - .github/workflows/release.yml | 11 ++++++----- README.ja-JP.md | 9 +++++---- README.md | 11 ++++++----- README.vi.md | 9 +++++---- README.zh-CN.md | 11 ++++++----- docs/GUIDE.md | 5 +++-- docs/REBRAND.md | 25 +++++++++++++------------ docs/RELEASE_CHECKLIST.md | 6 ++++-- npm/codewhale/README.md | 4 ++-- npm/deepseek-tui/README.md | 7 ++++--- npm/deepseek-tui/package.json | 6 ++---- scripts/release/check-published.sh | 16 ++++++++++++---- scripts/release/check-versions.sh | 17 ++++++++++------- 14 files changed, 78 insertions(+), 60 deletions(-) diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index f73794482..80ede91e1 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -19,7 +19,6 @@ on: paths: - 'Cargo.toml' - 'npm/codewhale/package.json' - - 'npm/deepseek-tui/package.json' workflow_dispatch: permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98ed24528..50bf8707c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -506,10 +506,11 @@ jobs: body: | > This release renames the project to **CodeWhale**. The legacy > `deepseek` and `deepseek-tui` binaries continue to ship as - > deprecation shims for one release cycle; they print a one-line - > warning and forward to `codewhale` / `codewhale-tui`. They will - > be removed in v0.9.0. See `docs/REBRAND.md` for the full - > migration story. + > compatibility-only deprecation shims during v0.8.x; they print a + > one-line warning and forward to `codewhale` / `codewhale-tui`. + > They will be removed in v0.9.0. The legacy npm package + > `deepseek-tui` is deprecated and receives no further releases. + > See `docs/REBRAND.md` for the full migration story. ## Install @@ -568,7 +569,7 @@ jobs: The **portable** Windows archive skips the install script — extract and run from any directory. - Individual binaries are also attached below for scripting and the npm wrapper. Legacy `deepseek-*` and `deepseek-tui-*` assets ship for one release cycle so that existing `deepseek update` invocations on v0.8.40 keep working; they install the deprecation shims, which forward to the canonical binaries. + Individual binaries are also attached below for scripting and the npm wrapper. Legacy `deepseek-*` and `deepseek-tui-*` assets are compatibility-only deprecation shims for v0.8.x so that existing `deepseek update` invocations on v0.8.40 keep working; they forward to the canonical binaries. The legacy npm package `deepseek-tui` is deprecated and is not republished. ### Verify (recommended) diff --git a/README.ja-JP.md b/README.ja-JP.md index f3379501d..4c2ab2747 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -9,7 +9,7 @@ ## インストール -`codewhale` は自己完結型の Rust リリースバイナリのペアとしてインストールされます。`codewhale` はディスパッチャーで、同じ場所にある `codewhale-tui` ランタイムを起動して対話セッションを実行します。npm、Homebrew、Docker は両方を自動でインストールします。Cargo や手動インストールでは、両方を同じディレクトリ(通常は `PATH` 上のディレクトリ)に置いてください。実行に Node.js や Python のランタイムは不要です。 +`codewhale` は自己完結型の Rust リリースバイナリのペアとしてインストールされます。`codewhale` はディスパッチャーで、同じ場所にある `codewhale-tui` ランタイムを起動して対話セッションを実行します。npm と Docker は両方を自動でインストールします。Cargo や手動インストールでは、両方を同じディレクトリ(通常は `PATH` 上のディレクトリ)に置いてください。実行に Node.js や Python のランタイムは不要です。 ```bash # 1. npm — すでに Node を使っているなら最も簡単。npm パッケージは @@ -21,8 +21,9 @@ npm install -g codewhale cargo install codewhale-cli --locked # `codewhale` (エントリーポイント) cargo install codewhale-tui --locked # `codewhale-tui` (TUI バイナリ) -# 3. Homebrew — macOS パッケージマネージャ。 -# tap/formula 名は旧名のままですが、codewhale と codewhale-tui をインストールします。 +# 3. Homebrew — 旧インストールとの互換用です。 +# tap/formula はまだ旧 deepseek-tui 名を使っています。新規インストールでは、 +# formula が改名されるまで npm、Cargo、Docker、直接ダウンロードを優先してください。 brew tap Hmbown/deepseek-tui brew install deepseek-tui @@ -48,7 +49,7 @@ docker run --rm -it \ ```bash codewhale update npm install -g codewhale@latest -brew update && brew upgrade deepseek-tui +brew update && brew upgrade deepseek-tui # 旧 Homebrew インストールのみ cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` diff --git a/README.md b/README.md index 177187d25..3e37aaba5 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ `codewhale` installs as a matched pair of self-contained Rust release binaries: the `codewhale` dispatcher command and the sibling `codewhale-tui` runtime it -launches for interactive sessions. npm, Homebrew, and Docker install both for -you; Cargo and manual installs must put both binaries in the same directory +launches for interactive sessions. npm and Docker install both for you; Cargo +and manual installs must put both binaries in the same directory (normally a directory on your `PATH`). The npm package is only an installer/wrapper for those release binaries; the agent does not run on Node. @@ -27,8 +27,9 @@ npm install -g codewhale cargo install codewhale-cli --locked # `codewhale` (entry point) cargo install codewhale-tui --locked # `codewhale-tui` (TUI binary) -# 3. Homebrew — macOS package manager. -# The tap/formula name is legacy; it installs codewhale and codewhale-tui. +# 3. Homebrew — legacy compatibility only. +# The tap/formula still uses the old deepseek-tui name. Prefer npm, Cargo, +# Docker, or direct downloads for new installs until the formula is renamed. brew tap Hmbown/deepseek-tui brew install deepseek-tui @@ -61,7 +62,7 @@ Already installed? Use the updater that matches the install path: ```bash codewhale update # release-binary updater npm install -g codewhale@latest # npm wrapper -brew update && brew upgrade deepseek-tui +brew update && brew upgrade deepseek-tui # legacy Homebrew installs only cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` diff --git a/README.vi.md b/README.vi.md index ab4d6b3c7..2f7d1aad3 100644 --- a/README.vi.md +++ b/README.vi.md @@ -9,7 +9,7 @@ ## Cài đặt `codewhale` được cài đặt dưới dạng một cặp binary tự chạy bằng Rust đồng bộ với nhau: -Lệnh điều phối `codewhale` (dispatcher) và môi trường chạy giao diện `codewhale-tui` (runtime) do nó khởi chạy để thực hiện các phiên làm việc tương tác. Các trình quản lý gói như npm, Homebrew, và Docker sẽ tự động cài đặt cả hai cho bạn; đối với Cargo hoặc cài đặt thủ công, bạn phải đặt cả hai tệp binary này trong cùng một thư mục (thông thường là một thư mục nằm trong biến môi trường `PATH` của bạn). Gói npm chỉ là một trình cài đặt/bao bọc (wrapper) cho các tệp binary phát hành này; agent không chạy trên môi trường Node.js. +Lệnh điều phối `codewhale` (dispatcher) và môi trường chạy giao diện `codewhale-tui` (runtime) do nó khởi chạy để thực hiện các phiên làm việc tương tác. npm và Docker sẽ tự động cài đặt cả hai cho bạn; đối với Cargo hoặc cài đặt thủ công, bạn phải đặt cả hai tệp binary này trong cùng một thư mục (thông thường là một thư mục nằm trong biến môi trường `PATH` của bạn). Gói npm chỉ là một trình cài đặt/bao bọc (wrapper) cho các tệp binary phát hành này; agent không chạy trên môi trường Node.js. ```bash # 1. npm — dễ nhất nếu bạn đã cài đặt Node. Gói này sẽ tự động tải các @@ -22,8 +22,9 @@ npm install -g codewhale cargo install codewhale-cli --locked # cài đặt `codewhale` (điểm truy cập CLI chính) cargo install codewhale-tui --locked # cài đặt `codewhale-tui` (giao diện TUI) -# 3. Homebrew — trình quản lý gói dành cho macOS. -# Tên tap/formula là tên cũ (legacy); nó sẽ cài đặt cả codewhale và codewhale-tui. +# 3. Homebrew — chỉ dành cho khả năng tương thích với cài đặt cũ. +# Tap/formula vẫn dùng tên deepseek-tui cũ. Với cài đặt mới, hãy ưu tiên +# npm, Cargo, Docker hoặc tải trực tiếp cho đến khi formula được đổi tên. brew tap Hmbown/deepseek-tui brew install deepseek-tui @@ -56,7 +57,7 @@ docker run --rm -it \ ```bash codewhale update # trình cập nhật binary phát hành trực tiếp npm install -g codewhale@latest # thông qua trình bao bọc npm -brew update && brew upgrade deepseek-tui +brew update && brew upgrade deepseek-tui # chỉ cho cài đặt Homebrew cũ cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index d1adce362..d59bca063 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,8 +10,8 @@ ## 安装 `codewhale` 以一组自包含 Rust 发布二进制安装:`codewhale` 调度器命令, -以及它在交互会话中启动的同级 `codewhale-tui` 运行时。npm、Homebrew 和 -Docker 会自动安装这两个二进制;Cargo 或手动下载时必须把两者放在同一目录 +以及它在交互会话中启动的同级 `codewhale-tui` 运行时。npm 和 Docker +会自动安装这两个二进制;Cargo 或手动下载时必须把两者放在同一目录 (通常是 `PATH` 上的某个目录)。运行时不依赖 Node.js 或 Python。 ```bash @@ -24,8 +24,9 @@ npm install -g codewhale cargo install codewhale-cli --locked # `codewhale` 入口 cargo install codewhale-tui --locked # `codewhale-tui` TUI 二进制 -# 3. Homebrew —— macOS 包管理器。 -# tap/formula 名称仍是旧名;实际安装 codewhale 和 codewhale-tui。 +# 3. Homebrew —— 仅用于旧安装兼容。 +# tap/formula 仍使用旧的 deepseek-tui 名称。新安装请优先使用 +# npm、Cargo、Docker 或直接下载,直到 formula 完成改名。 brew tap Hmbown/deepseek-tui brew install deepseek-tui @@ -57,7 +58,7 @@ docker run --rm -it \ ```bash codewhale update # release 二进制更新器 npm install -g codewhale@latest # npm 包装器 -brew update && brew upgrade deepseek-tui +brew update && brew upgrade deepseek-tui # 仅旧 Homebrew 安装 cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` diff --git a/docs/GUIDE.md b/docs/GUIDE.md index fa58b6966..8a8407a33 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -56,8 +56,9 @@ npm install -g codewhale cargo install codewhale-cli --locked cargo install codewhale-tui --locked -# Homebrew -# The tap/formula name is legacy; it installs codewhale and codewhale-tui. +# Homebrew, legacy installs only +# The tap/formula still uses the old deepseek-tui name. Prefer npm, Cargo, +# Docker, or direct downloads for new installs until the formula is renamed. brew tap Hmbown/deepseek-tui brew install deepseek-tui ``` diff --git a/docs/REBRAND.md b/docs/REBRAND.md index 4ce7c9ba2..1e67d9f9b 100644 --- a/docs/REBRAND.md +++ b/docs/REBRAND.md @@ -14,9 +14,9 @@ npm uninstall -g deepseek-tui # or cargo uninstall deepseek-tui-cli deepsee # 2. Install under the new name. npm install -g codewhale # or cargo install codewhale-cli codewhale-tui --locked - # or brew install deepseek-tui (Homebrew tap still - # uses the legacy name during the transition; - # it installs the new binaries underneath.) + # legacy Homebrew installs may still use + # brew install deepseek-tui until the tap + # formula is renamed. # 3. Run with the new command. codewhale doctor @@ -57,9 +57,10 @@ Anything that targets the DeepSeek provider API stays exactly as it was: and audit log. - **GitHub repository URL**: `https://github.com/Hmbown/CodeWhale`. The old `Hmbown/DeepSeek-TUI` URL redirects there during the transition. -- **Homebrew tap and formula** (`Hmbown/homebrew-deepseek-tui`): still - installs by the legacy name during the transition. The tap's formula - will be flipped to the new names in a follow-up. +- **Homebrew tap and formula** (`Hmbown/homebrew-deepseek-tui`): still uses + the legacy formula name for existing installs. Treat it as compatibility-only + until the tap is renamed; new install docs prefer `codewhale` npm, Cargo, + Docker, or direct downloads. - **Docker image**: `ghcr.io/hmbown/codewhale`. ## Deprecation shims (through v0.8.x) @@ -70,8 +71,8 @@ v0.8.41 and later v0.8.x releases ship **deprecation shims**: - A `deepseek` binary that prints a one-line warning to stderr and forwards argv to `codewhale`. - A `deepseek-tui` binary that does the same for `codewhale-tui`. -- An `npm` package at `deepseek-tui@0.8.x` with no `bin` and a postinstall - that prints a clear rename notice. +- The legacy `deepseek-tui` npm package is deprecated and no longer receives + new releases. Install the `codewhale` npm package instead. These shims will be removed in **v0.9.0**. Please migrate before then. @@ -100,10 +101,10 @@ cargo install --path crates/tui --locked --force ### Homebrew -The tap formula still installs `deepseek-tui` during the transition. -Existing `brew install deepseek-tui` invocations continue to work and land -the new binaries underneath the legacy formula name. The formula and tap -repo will follow up with their own rename. +The tap formula still installs through the legacy `deepseek-tui` name for +existing Homebrew users. Keep using `brew upgrade deepseek-tui` only for that +compatibility path. New installs should prefer npm, Cargo, Docker, or direct +downloads until the formula and tap repo are renamed. ### Manual / GitHub Releases diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 277f90281..626aafb7e 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -34,8 +34,8 @@ publish-crates), see [`RELEASE_RUNBOOK.md`](RELEASE_RUNBOOK.md). pins match the new workspace version. - [ ] `npm/codewhale/package.json` `version` AND `codewhaleBinaryVersion` are both bumped. -- [ ] `npm/deepseek-tui/package.json` `version` is bumped for the one-release - deprecation shim. +- [ ] `npm/deepseek-tui/package.json` remains private/compatibility-only and + is **not** bumped or published. - [ ] `Cargo.lock` is refreshed (`cargo update --workspace --offline`). - [ ] `./scripts/release/check-versions.sh` reports `Version state OK: workspace=X.Y.Z, npm=X.Y.Z, lockfile in sync.` @@ -95,6 +95,8 @@ Run, in order, from the repo root: ``` - [ ] `npm view codewhale@X.Y.Z version codewhaleBinaryVersion --json` reports the new version on the npm registry. +- [ ] `npm view deepseek-tui deprecated` is non-empty. The legacy npm package + is deprecated and must not receive an `X.Y.Z` publish. - [ ] `crates.io` has the new version (or the `publish-crates.sh` job has pushed it). - [ ] `ghcr.io/hmbown/codewhale:vX.Y.Z` and `:latest` are updated. diff --git a/npm/codewhale/README.md b/npm/codewhale/README.md index d6a8c44be..25fda9dc4 100644 --- a/npm/codewhale/README.md +++ b/npm/codewhale/README.md @@ -4,8 +4,8 @@ Install and run CodeWhale, the agentic terminal for open-source and open-weight models, from GitHub release artifacts. > Previously published as `deepseek-tui`. See `docs/REBRAND.md` in the upstream -> repository for the migration notes; the legacy `deepseek-tui` npm package -> remains a deprecation shim through the v0.8.x transition. +> repository for the migration notes; the legacy `deepseek-tui` npm package is +> deprecated and receives no further releases. ## Install diff --git a/npm/deepseek-tui/README.md b/npm/deepseek-tui/README.md index 9b3dd161a..898ae5304 100644 --- a/npm/deepseek-tui/README.md +++ b/npm/deepseek-tui/README.md @@ -7,9 +7,10 @@ npm uninstall -g deepseek-tui npm install -g codewhale ``` -`codewhale` ships the same `codewhale` and `codewhale-tui` binaries plus -deprecation shims under the old `deepseek` / `deepseek-tui` names so existing -scripts keep working through the v0.8.x transition. +This legacy npm package is deprecated and receives no further releases. +`codewhale` ships the canonical `codewhale` and `codewhale-tui` binaries, plus +compatibility-only deprecation shims under the old `deepseek` / +`deepseek-tui` binary names for v0.8.x. See [docs/REBRAND.md](https://github.com/Hmbown/CodeWhale/blob/main/docs/REBRAND.md) for the full migration story. diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index 5ff87ed0f..fcf9b373b 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,8 @@ { "name": "deepseek-tui", "version": "0.8.49", - "description": "Legacy compatibility package. Renamed to `codewhale`; run `npm install -g codewhale` for new installs.", + "private": true, + "description": "Deprecated legacy package name. Install `codewhale` instead; this package is not republished.", "author": "Hmbown", "license": "MIT", "funding": [ @@ -34,9 +35,6 @@ "engines": { "node": ">=18" }, - "publishConfig": { - "access": "public" - }, "files": [ "scripts/*.js", "README.md", diff --git a/scripts/release/check-published.sh b/scripts/release/check-published.sh index 093179388..f7bf4b217 100755 --- a/scripts/release/check-published.sh +++ b/scripts/release/check-published.sh @@ -92,14 +92,22 @@ else fail=1 fi -# Legacy `deepseek-tui` deprecation shim package. Best-effort check — -# absence after the transition release is expected and not fatal. +# Legacy `deepseek-tui` npm package. It is deprecated and must not be +# republished under the release version. if legacy_version="$(npm view "deepseek-tui@${version}" version 2>/dev/null)"; then - echo "npm deepseek-tui@${legacy_version} (deprecation shim) is published." + echo "npm deepseek-tui@${legacy_version} exists, but the legacy npm package must not be republished." >&2 + fail=1 +fi +if legacy_deprecated="$(npm view deepseek-tui deprecated 2>/dev/null)" && [[ -n "${legacy_deprecated}" ]]; then + echo "npm deepseek-tui is deprecated: ${legacy_deprecated}" +else + echo "npm deepseek-tui is not marked deprecated." >&2 + fail=1 fi +crates_user_agent="CodeWhale release check (https://github.com/Hmbown/CodeWhale)" for crate in "${release_crates[@]}"; do - if curl -fsSL "https://crates.io/api/v1/crates/${crate}/${version}" >/dev/null 2>&1; then + if curl -fsSL -A "${crates_user_agent}" "https://crates.io/api/v1/crates/${crate}/${version}" >/dev/null 2>&1; then echo "crates.io ${crate}@${version} is published." else echo "crates.io ${crate}@${version} is not published." >&2 diff --git a/scripts/release/check-versions.sh b/scripts/release/check-versions.sh index e73c68917..f260b803c 100755 --- a/scripts/release/check-versions.sh +++ b/scripts/release/check-versions.sh @@ -7,8 +7,8 @@ # crate must inherit `version.workspace = true`. # 2. `npm/codewhale/package.json` `version` matches the workspace # `version` in the root `Cargo.toml`. (`npm/deepseek-tui/` still -# exists during the transition as a deprecation shim package; its -# version is also checked.) +# exists only as an unpublished compatibility notice and must stay +# private.) # 3. Internal `codewhale-*` path dependency pins match the workspace version. # 4. The TUI crate's packaged changelog copy matches root `CHANGELOG.md`. # 5. The current release has a dated Keep a Changelog entry and compare link. @@ -37,12 +37,15 @@ if [[ "${workspace_version}" != "${npm_version}" ]]; then echo "::error::npm/codewhale/package.json version (${npm_version}) does not match workspace Cargo.toml (${workspace_version})." >&2 fail=1 fi -# Also pin the legacy deprecation shim package to the same workspace version -# so a stale `deepseek-tui` doesn't ship pointing at a different release. if [[ -f npm/deepseek-tui/package.json ]]; then - legacy_npm_version="$(node -p "require('./npm/deepseek-tui/package.json').version")" - if [[ "${workspace_version}" != "${legacy_npm_version}" ]]; then - echo "::error::npm/deepseek-tui/package.json version (${legacy_npm_version}) does not match workspace Cargo.toml (${workspace_version})." >&2 + legacy_private="$(node -p "Boolean(require('./npm/deepseek-tui/package.json').private)")" + legacy_publish_config="$(node -p "Boolean(require('./npm/deepseek-tui/package.json').publishConfig)")" + if [[ "${legacy_private}" != "true" ]]; then + echo "::error::npm/deepseek-tui/package.json must stay private so the legacy package is not republished." >&2 + fail=1 + fi + if [[ "${legacy_publish_config}" == "true" ]]; then + echo "::error::npm/deepseek-tui/package.json must not define publishConfig; the legacy package is deprecated." >&2 fail=1 fi fi From bc34cd13ea9c45151464078772c612922a75bc85 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Mon, 1 Jun 2026 20:56:00 +0800 Subject: [PATCH 09/98] fix(tui): hold subagent cap until status reconciles (cherry picked from commit 5f01dda291e8354e779cc9220f38754fe0c3786f) --- crates/tui/src/tools/subagent/mod.rs | 10 ++++++---- crates/tui/src/tools/subagent/tests.rs | 15 ++++++--------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 67d3cd17f..c00e5e5c7 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -1249,11 +1249,13 @@ impl SubAgentManager { return false; } // Exclude persisted agents with no task_handle (they're not actually running) - let Some(handle) = agent.task_handle.as_ref() else { + if agent.task_handle.is_none() { return false; - }; - // Exclude agents whose task has finished (status will be updated to Completed shortly) - !handle.is_finished() + } + // Keep recently finished handles counted until the terminal + // status update has reconciled. Otherwise a fanout burst can + // refill the cap before the UI/state catches up (#2211). + true }) .count() } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 9c53604ed..29d5fc861 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -938,7 +938,7 @@ fn test_running_count_ignores_running_status_without_task_handle() { } #[tokio::test] -async fn test_running_count_ignores_finished_task_handles() { +async fn test_running_count_counts_running_agents_until_status_reconciles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); let (input_tx, _input_rx) = mpsc::unbounded_channel(); let mut agent = SubAgent::new( @@ -953,17 +953,14 @@ async fn test_running_count_ignores_finished_task_handles() { "boot_test".to_string(), ); agent.status = SubAgentStatus::Running; - let handle = tokio::spawn(async {}); - handle.await.expect("dummy task should finish immediately"); - agent.task_handle = Some(tokio::spawn(async {})); - if let Some(handle) = agent.task_handle.as_ref() { - while !handle.is_finished() { - tokio::task::yield_now().await; - } + let finished_handle = tokio::spawn(async {}); + while !finished_handle.is_finished() { + tokio::task::yield_now().await; } + agent.task_handle = Some(finished_handle); manager.agents.insert(agent.id.clone(), agent); - assert_eq!(manager.running_count(), 0); + assert_eq!(manager.running_count(), 1); } #[test] From a09af2024a2c4bb65a4d189e83fc8e9c2fcca31e Mon Sep 17 00:00:00 2001 From: implecao Date: Mon, 1 Jun 2026 22:16:18 +0800 Subject: [PATCH 10/98] feat(web_search): enable parallel execution for read-only search tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Override `supports_parallel()` to return `true` in `WebSearchTool`, allowing the engine to batch multiple concurrent web_search calls into a `FuturesUnordered` parallel group instead of serializing them. The tool is already read-only, auto-approved, and non-interactive — parallel-safe by all other criteria. This change removes the final gate (`supports_parallel() -> false` default) so co-issued searches run concurrently rather than one-at-a-time. Closes the ~55s serial wall-clock for 3 simultaneous web searches (now ~20s, the slowest individual call). Co-authored-by: Cursor (cherry picked from commit a7dcf63c556268b53ff430747ae2e141e4cd4451) --- crates/tui/src/tools/web_search.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index 3e36ae5d4..8516cabd1 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -186,6 +186,10 @@ impl ToolSpec for WebSearchTool { ApprovalRequirement::Auto } + fn supports_parallel(&self) -> bool { + true + } + async fn execute(&self, input: Value, context: &ToolContext) -> Result { let query = extract_search_query(&input)?; if query.is_empty() { From 14ea0721a81c51ad01f3a8e1424d29e335d933d3 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Mon, 1 Jun 2026 14:56:48 +0200 Subject: [PATCH 11/98] fix(tui): close Windows job before output joins (cherry picked from commit 4db07a451682fb120c902d4254a11b532b24b82a) --- crates/tui/src/tools/shell.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index f21b42ada..1dd9e8ae2 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -488,6 +488,13 @@ impl BackgroundShell { if let Some(job) = self.windows_job.as_ref() { let _ = job.terminate(); } + #[cfg(windows)] + { + // Close the job handle before joining reader threads so + // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE can still release inherited + // pipe handles if explicit termination failed. + self.windows_job = None; + } if let Some(handle) = self.stdout_thread.take() { let _ = handle.join(); } @@ -495,10 +502,6 @@ impl BackgroundShell { let _ = handle.join(); } self.stdin = None; - #[cfg(windows)] - { - self.windows_job = None; - } self.child = None; } From 54a93994f64d1ec81d1352fe3e39a28c4c59169b Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Mon, 1 Jun 2026 15:08:55 +0200 Subject: [PATCH 12/98] test(tui): cover Windows job cleanup fallbacks (cherry picked from commit f46eb7e6644acc20d0ad9be8f9a86e1733bc7b00) --- crates/tui/src/tools/shell/tests.rs | 104 ++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 5a3ed92c5..c52a5d06e 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -4,6 +4,11 @@ use crate::tools::spec::ToolContext; use serde_json::{Value, json}; use tempfile::tempdir; +#[cfg(windows)] +use windows::Win32::Foundation::{DUPLICATE_HANDLE_OPTIONS, DuplicateHandle, HANDLE}; +#[cfg(windows)] +use windows::Win32::System::Threading::GetCurrentProcess; + // `env_lock` exists only to serialize Unix-only env-mutating tests. // Windows builds gate that test out, so the helper would be dead code // under `-Dwarnings` if the import + helper were unconditional. @@ -16,6 +21,33 @@ fn env_lock() -> &'static Mutex<()> { LOCK.get_or_init(|| Mutex::new(())) } +#[cfg(windows)] +const JOB_OBJECT_QUERY_ACCESS: u32 = 0x0004; + +#[cfg(windows)] +fn duplicate_job_without_terminate_access(job: WindowsJob) -> WindowsJob { + let process = unsafe { GetCurrentProcess() }; + let mut limited_handle = HANDLE::default(); + + unsafe { + DuplicateHandle( + process, + job.handle, + process, + &mut limited_handle, + JOB_OBJECT_QUERY_ACCESS, + false, + DUPLICATE_HANDLE_OPTIONS(0), + ) + .expect("duplicate job handle without terminate access"); + } + + drop(job); + WindowsJob { + handle: limited_handle, + } +} + fn echo_command(message: &str) -> String { format!("echo {message}") } @@ -954,6 +986,78 @@ fn background_collection_does_not_block_on_detached_descendant_pipe() { assert_eq!(done.status, ShellStatus::Completed); } +#[cfg(windows)] +#[test] +fn windows_job_terminate_denied_falls_back_to_child_kill() { + let mut child = Command::new("ping") + .args(["127.0.0.1", "-n", "20"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn ping"); + + let job = WindowsJob::attach_to_child(&child).expect("attach job"); + let limited_job = duplicate_job_without_terminate_access(job); + + assert!( + limited_job.terminate().is_err(), + "limited job handle should not allow TerminateJobObject" + ); + + terminate_windows_job(Some(&limited_job), &mut child).expect("fallback child kill"); + + let status = child + .wait_timeout(std::time::Duration::from_secs(3)) + .expect("wait after fallback kill"); + assert!( + status.is_some(), + "fallback child kill should terminate child" + ); +} + +#[cfg(windows)] +#[test] +fn windows_job_kill_on_close_releases_reader_threads_when_terminate_denied() { + let tmp = tempdir().expect("tempdir"); + let mut manager = ShellManager::new(tmp.path().to_path_buf()); + + let result = manager + .execute( + r#"cmd /c start "" /b ping 127.0.0.1 -n 8"#, + None, + 5000, + true, + ) + .expect("execute"); + let task_id = result.task_id.expect("task id"); + + { + let shell = manager + .processes + .get_mut(&task_id) + .expect("background shell"); + let job = shell.windows_job.take().expect("windows job attached"); + let limited_job = duplicate_job_without_terminate_access(job); + assert!( + limited_job.terminate().is_err(), + "limited job handle should not allow TerminateJobObject" + ); + shell.windows_job = Some(limited_job); + } + + let started = std::time::Instant::now(); + let done = manager + .get_output(&task_id, true, 3000) + .expect("get_output must complete via kill-on-close fallback"); + + assert!( + started.elapsed() < std::time::Duration::from_secs(4), + "get_output waited for natural descendant exit instead of kill-on-close" + ); + assert_eq!(done.status, ShellStatus::Completed); +} + #[test] fn test_list_jobs_cleans_up_completed_old_processes() { let tmp = tempdir().expect("tempdir"); From 2c256d7b3aa54e8bafa3c60ed833fdfbcded1ae4 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Mon, 1 Jun 2026 15:25:13 +0200 Subject: [PATCH 13/98] fix(tui): close Windows job before foreground joins (cherry picked from commit 96adffb243801dcef6c6332611728930e438f1a1) --- crates/tui/src/tools/shell.rs | 46 ++++++++++++++++---------- crates/tui/src/tools/shell/tests.rs | 51 ++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 1dd9e8ae2..983d36e91 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -311,6 +311,29 @@ fn terminate_windows_job(job: Option<&WindowsJob>, child: &mut Child) -> std::io child.kill() } +#[cfg(windows)] +fn terminate_and_close_windows_job(windows_job: Option) { + if let Some(job) = windows_job.as_ref() + && let Err(err) = job.terminate() + { + tracing::warn!( + ?err, + "failed to terminate Windows shell job before closing job handle" + ); + } + drop(windows_job); +} + +#[cfg(windows)] +fn terminate_child_and_close_windows_job( + windows_job: Option, + child: &mut Child, +) -> std::io::Result<()> { + let result = terminate_windows_job(windows_job.as_ref(), child); + drop(windows_job); + result +} + #[cfg(windows)] fn attach_windows_job(child: &Child, command: &str) -> Option { match WindowsJob::attach_to_child(child) { @@ -485,16 +508,7 @@ impl BackgroundShell { let _ = kill_child_process_group(proc); } #[cfg(windows)] - if let Some(job) = self.windows_job.as_ref() { - let _ = job.terminate(); - } - #[cfg(windows)] - { - // Close the job handle before joining reader threads so - // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE can still release inherited - // pipe handles if explicit termination failed. - self.windows_job = None; - } + terminate_and_close_windows_job(self.windows_job.take()); if let Some(handle) = self.stdout_thread.take() { let _ = handle.join(); } @@ -1039,9 +1053,7 @@ impl ShellManager { // Wait with timeout if let Some(status) = child.wait_timeout(timeout)? { #[cfg(windows)] - if let Some(job) = windows_job.as_ref() { - let _ = job.terminate(); - } + terminate_and_close_windows_job(windows_job); let stdout = stdout_thread.join().unwrap_or_default(); let stderr = stderr_thread.join().unwrap_or_default(); let stdout_str = String::from_utf8_lossy(&stdout).to_string(); @@ -1083,7 +1095,7 @@ impl ShellManager { #[cfg(unix)] let _ = kill_child_process_group(&mut child); #[cfg(windows)] - let _ = terminate_windows_job(windows_job.as_ref(), &mut child); + let _ = terminate_child_and_close_windows_job(windows_job, &mut child); #[cfg(all(not(unix), not(windows)))] let _ = child.kill(); let status = child.wait().ok(); @@ -1175,9 +1187,7 @@ impl ShellManager { if let Some(status) = child.wait_timeout(timeout)? { #[cfg(windows)] - if let Some(job) = windows_job.as_ref() { - let _ = job.terminate(); - } + terminate_and_close_windows_job(windows_job); Ok(ShellResult { task_id: None, status: if status.success() { @@ -1207,7 +1217,7 @@ impl ShellManager { #[cfg(unix)] let _ = kill_child_process_group(&mut child); #[cfg(windows)] - let _ = terminate_windows_job(windows_job.as_ref(), &mut child); + let _ = terminate_child_and_close_windows_job(windows_job, &mut child); #[cfg(all(not(unix), not(windows)))] let _ = child.kill(); let status = child.wait().ok(); diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index c52a5d06e..f24923c1d 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -1005,7 +1005,8 @@ fn windows_job_terminate_denied_falls_back_to_child_kill() { "limited job handle should not allow TerminateJobObject" ); - terminate_windows_job(Some(&limited_job), &mut child).expect("fallback child kill"); + terminate_child_and_close_windows_job(Some(limited_job), &mut child) + .expect("fallback child kill"); let status = child .wait_timeout(std::time::Duration::from_secs(3)) @@ -1016,6 +1017,54 @@ fn windows_job_terminate_denied_falls_back_to_child_kill() { ); } +#[cfg(windows)] +#[test] +fn windows_job_close_releases_foreground_reader_threads_when_terminate_denied() { + let mut child = Command::new("ping") + .args(["127.0.0.1", "-n", "8"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn ping"); + + let job = WindowsJob::attach_to_child(&child).expect("attach job"); + let limited_job = duplicate_job_without_terminate_access(job); + assert!( + limited_job.terminate().is_err(), + "limited job handle should not allow TerminateJobObject" + ); + + let stdout_handle = child.stdout.take().expect("stdout pipe"); + let stderr_handle = child.stderr.take().expect("stderr pipe"); + let stdout_thread = std::thread::spawn(move || { + let mut reader = stdout_handle; + let mut buf = Vec::new(); + let _ = reader.read_to_end(&mut buf); + buf + }); + let stderr_thread = std::thread::spawn(move || { + let mut reader = stderr_handle; + let mut buf = Vec::new(); + let _ = reader.read_to_end(&mut buf); + buf + }); + + let started = std::time::Instant::now(); + terminate_and_close_windows_job(Some(limited_job)); + let _ = stdout_thread.join().unwrap_or_default(); + let _ = stderr_thread.join().unwrap_or_default(); + let status = child + .wait_timeout(std::time::Duration::from_secs(3)) + .expect("wait after kill-on-close"); + + assert!( + started.elapsed() < std::time::Duration::from_secs(4), + "reader joins waited for natural descendant exit instead of kill-on-close" + ); + assert!(status.is_some(), "kill-on-close should terminate child"); +} + #[cfg(windows)] #[test] fn windows_job_kill_on_close_releases_reader_threads_when_terminate_denied() { From 242899d4b6dffb6836a4a4de43a5ddc9f4dd3c51 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Thu, 28 May 2026 00:47:37 +0200 Subject: [PATCH 14/98] feat: run ToolCallBefore hooks before tool execution --- crates/tui/src/core/engine.rs | 9 ++ crates/tui/src/core/engine/turn_loop.rs | 140 ++++++++++++++++++++++++ crates/tui/src/core/ops.rs | 3 + crates/tui/src/main.rs | 2 + crates/tui/src/runtime_threads.rs | 2 + crates/tui/src/tui/ui.rs | 2 + 6 files changed, 158 insertions(+) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 5813b5381..2f920b3fc 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -166,6 +166,9 @@ pub struct EngineConfig { /// Tool restriction from custom slash command frontmatter. /// `None` means the current turn may use the normal tool set. pub allowed_tools: Option>, + /// Hook executor for control-plane hooks. + /// `ToolCallBefore` hooks may deny a tool call with exit code 2. + pub hook_executor: Option>, /// Resolved BCP-47 locale tag (e.g. `"en"`, `"zh-Hans"`, `"ja"`) /// for the `## Environment` block in the system prompt. The /// caller resolves this from `Settings` once at engine @@ -237,6 +240,7 @@ impl Default for EngineConfig { strict_tool_mode: false, goal_objective: None, allowed_tools: None, + hook_executor: None, locale_tag: "en".to_string(), workshop: None, search_provider: crate::config::SearchProvider::default(), @@ -650,6 +654,7 @@ impl Engine { translation_enabled, show_thinking, allowed_tools, + hook_executor, } => { self.handle_send_message( content, @@ -666,6 +671,7 @@ impl Engine { translation_enabled, show_thinking, allowed_tools, + hook_executor, ) .await; } @@ -884,6 +890,7 @@ impl Engine { self.config.translation_enabled, self.config.show_thinking, self.config.allowed_tools.clone(), + self.config.hook_executor.clone(), ) .await; } @@ -1008,6 +1015,7 @@ impl Engine { translation_enabled: bool, show_thinking: bool, allowed_tools: Option>, + hook_executor: Option>, ) { // Reset cancel token for fresh turn (in case previous was cancelled) self.reset_cancel_token(); @@ -1114,6 +1122,7 @@ impl Engine { ); } self.config.allowed_tools = allowed_tools; + self.config.hook_executor = hook_executor; self.session.reasoning_effort = reasoning_effort; self.session.reasoning_effort_auto = reasoning_effort_auto; self.session.auto_model = auto_model; diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 9a3245e16..9d0bdd918 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1261,6 +1261,45 @@ impl Engine { ))); } + if blocked_error.is_none() + && let Some(hook_executor) = self.config.hook_executor.as_ref() + && hook_executor.has_hooks_for_event(crate::hooks::HookEvent::ToolCallBefore) + { + let hook_context = crate::hooks::HookContext::new() + .with_tool_name(&tool_name) + .with_tool_args(&tool_input) + .with_mode(&format!("{mode:?}")) + .with_workspace(self.session.workspace.clone()) + .with_model(&self.config.model) + .with_session_id(&self.session.id); + let hook_results = hook_executor + .execute(crate::hooks::HookEvent::ToolCallBefore, &hook_context); + if let Some(denial) = hook_results + .iter() + .find(|result| result.exit_code == Some(2)) + { + let reason = denial + .stdout + .trim() + .lines() + .next() + .filter(|line| !line.is_empty()) + .or_else(|| { + denial + .stderr + .trim() + .lines() + .next() + .filter(|line| !line.is_empty()) + }) + .or(denial.error.as_deref()) + .unwrap_or("ToolCallBefore hook denied tool execution"); + blocked_error = Some(ToolError::permission_denied(format!( + "ToolCallBefore hook denied tool '{tool_name}': {reason}" + ))); + } + } + if !caller_allowed_for_tool(tool_caller.as_ref(), tool_def) { blocked_error = Some(ToolError::permission_denied(format!( "Tool '{tool_name}' does not allow caller '{}'", @@ -2514,4 +2553,105 @@ mod tests { let allowed = vec!["read_file".to_string()]; assert!(command_allows_tool(Some(&allowed), &tool_name)); } + + #[test] + fn hook_gate_denies_with_exit_code_2() { + use crate::hooks::{Hook, HookContext, HookEvent, HookExecutor, HooksConfig}; + + let deny_cmd = if cfg!(windows) { "exit /b 2" } else { "exit 2" }; + let config = HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::ToolCallBefore, deny_cmd)], + ..HooksConfig::default() + }; + let executor = HookExecutor::new(config, std::path::PathBuf::from(".")); + let ctx = HookContext::new() + .with_tool_name("exec_shell") + .with_tool_args(&serde_json::json!({})); + let results = executor.execute(HookEvent::ToolCallBefore, &ctx); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].exit_code, Some(2)); + } + + #[test] + fn hook_gate_allows_with_exit_code_0() { + use crate::hooks::{Hook, HookContext, HookEvent, HookExecutor, HooksConfig}; + + let allow_cmd = if cfg!(windows) { "exit /b 0" } else { "exit 0" }; + let config = HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::ToolCallBefore, allow_cmd)], + ..HooksConfig::default() + }; + let executor = HookExecutor::new(config, std::path::PathBuf::from(".")); + let ctx = HookContext::new() + .with_tool_name("read_file") + .with_tool_args(&serde_json::json!({})); + let results = executor.execute(HookEvent::ToolCallBefore, &ctx); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].exit_code, Some(0)); + assert!(results[0].success); + } + + #[test] + fn hook_gate_failure_exit_code_1_is_not_denial() { + use crate::hooks::{Hook, HookContext, HookEvent, HookExecutor, HooksConfig}; + + let fail_cmd = if cfg!(windows) { "exit /b 1" } else { "exit 1" }; + let config = HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::ToolCallBefore, fail_cmd)], + ..HooksConfig::default() + }; + let executor = HookExecutor::new(config, std::path::PathBuf::from(".")); + let ctx = HookContext::new() + .with_tool_name("write_file") + .with_tool_args(&serde_json::json!({})); + let results = executor.execute(HookEvent::ToolCallBefore, &ctx); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].exit_code, Some(1)); + assert_ne!(results[0].exit_code, Some(2)); + } + + #[test] + fn hook_gate_no_hooks_returns_no_results() { + use crate::hooks::{HookContext, HookEvent, HookExecutor, HooksConfig}; + + let config = HooksConfig { + enabled: true, + hooks: vec![], + ..HooksConfig::default() + }; + let executor = HookExecutor::new(config, std::path::PathBuf::from(".")); + let ctx = HookContext::new().with_tool_name("grep_files"); + let results = executor.execute(HookEvent::ToolCallBefore, &ctx); + + assert!(results.is_empty()); + } + + #[test] + fn hook_gate_denial_reason_can_come_from_stdout() { + use crate::hooks::{Hook, HookContext, HookEvent, HookExecutor, HooksConfig}; + + let deny_cmd = if cfg!(windows) { + "echo Tool blocked by security policy & exit /b 2" + } else { + "echo 'Tool blocked by security policy' && exit 2" + }; + let config = HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::ToolCallBefore, deny_cmd)], + ..HooksConfig::default() + }; + let executor = HookExecutor::new(config, std::path::PathBuf::from(".")); + let ctx = HookContext::new().with_tool_name("exec_shell"); + let results = executor.execute(HookEvent::ToolCallBefore, &ctx); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].exit_code, Some(2)); + assert!(results[0].stdout.contains("security")); + } } diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 87f479457..df6f0aa0d 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -35,6 +35,9 @@ pub enum Op { /// Tool restriction from custom slash command frontmatter. /// `None` means the current turn may use the normal tool set. allowed_tools: Option>, + /// Hook executor for control-plane hooks. + /// `ToolCallBefore` hooks may deny a tool call with exit code 2. + hook_executor: Option>, }, /// Cancel the current request diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 9feaaac46..47468b73c 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -5379,6 +5379,7 @@ async fn run_exec_agent( strict_tool_mode: config.strict_tool_mode.unwrap_or(false), goal_objective: None, allowed_tools: None, + hook_executor: None, locale_tag: crate::localization::resolve_locale(&settings.locale) .tag() .to_string(), @@ -5435,6 +5436,7 @@ async fn run_exec_agent( model: effective_model.clone(), goal_objective: None, allowed_tools: None, + hook_executor: None, reasoning_effort: effective_reasoning_effort, reasoning_effort_auto: auto_model, auto_model, diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 51f79922c..39c009ed7 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1654,6 +1654,7 @@ impl RuntimeThreadManager { translation_enabled: false, show_thinking, allowed_tools: None, + hook_executor: None, approval_mode: if auto_approve { crate::tui::approval::ApprovalMode::Auto } else { @@ -2020,6 +2021,7 @@ impl RuntimeThreadManager { strict_tool_mode: self.config.strict_tool_mode.unwrap_or(false), goal_objective: None, allowed_tools: None, + hook_executor: None, locale_tag: crate::localization::resolve_locale(&settings.locale) .tag() .to_string(), diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index e92a2a056..d0dc64066 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -754,6 +754,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { ), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, allowed_tools: app.active_allowed_tools.clone(), + hook_executor: Some(std::sync::Arc::new(app.hooks.clone())), network_policy: config.network.clone().map(|toml_cfg| { crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) }), @@ -4706,6 +4707,7 @@ async fn dispatch_user_message( translation_enabled: app.translation_enabled, show_thinking: app.show_thinking, allowed_tools: app.active_allowed_tools.clone(), + hook_executor: Some(std::sync::Arc::new(app.hooks.clone())), }) .await { From 796e95caa68917cb45f153834ee120956fac1f04 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Mon, 1 Jun 2026 17:40:13 +0200 Subject: [PATCH 15/98] fix: address PR #2511 review comments --- crates/tui/src/core/engine/turn_loop.rs | 58 +++++++++++++++---------- crates/tui/src/hooks.rs | 14 ++++++ crates/tui/src/tui/ui.rs | 10 ++--- 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 9d0bdd918..3416b2efc 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1255,16 +1255,50 @@ impl Engine { ))); } - if !command_allows_tool(self.config.allowed_tools.as_deref(), &tool_name) { + if blocked_error.is_none() + && !command_allows_tool(self.config.allowed_tools.as_deref(), &tool_name) { blocked_error = Some(ToolError::permission_denied(format!( "Tool '{tool_name}' is not in the allowed-tools list for the current command" ))); } + if blocked_error.is_none() + && !caller_allowed_for_tool(tool_caller.as_ref(), tool_def) { + blocked_error = Some(ToolError::permission_denied(format!( + "Tool '{tool_name}' does not allow caller '{}'", + caller_type_for_tool_use(tool_caller.as_ref()) + ))); + } + + if blocked_error.is_none() + && tool_def.is_none() + && !McpPool::is_mcp_tool(&tool_name) + && tool_name != CODE_EXECUTION_TOOL_NAME + && tool_name != JS_EXECUTION_TOOL_NAME + && !is_tool_search_tool(&tool_name) + { + blocked_error = Some(ToolError::not_available(missing_tool_error_message( + &tool_name, + &tool_catalog, + ))); + } + if blocked_error.is_none() && let Some(hook_executor) = self.config.hook_executor.as_ref() && hook_executor.has_hooks_for_event(crate::hooks::HookEvent::ToolCallBefore) { + // Warn if any ToolCallBefore hook is configured as background + // — background hooks return exit_code: None immediately, so + // the denial check (exit_code == Some(2)) can never match. + if hook_executor.has_background_hooks_for_event(crate::hooks::HookEvent::ToolCallBefore) + { + tracing::warn!( + "ToolCallBefore hook(s) configured with background=true — \ + background hooks cannot deny tool calls because they exit \ + immediately with no result" + ); + } + let hook_context = crate::hooks::HookContext::new() .with_tool_name(&tool_name) .with_tool_args(&tool_input) @@ -1300,26 +1334,6 @@ impl Engine { } } - if !caller_allowed_for_tool(tool_caller.as_ref(), tool_def) { - blocked_error = Some(ToolError::permission_denied(format!( - "Tool '{tool_name}' does not allow caller '{}'", - caller_type_for_tool_use(tool_caller.as_ref()) - ))); - } - - if blocked_error.is_none() - && tool_def.is_none() - && !McpPool::is_mcp_tool(&tool_name) - && tool_name != CODE_EXECUTION_TOOL_NAME - && tool_name != JS_EXECUTION_TOOL_NAME - && !is_tool_search_tool(&tool_name) - { - blocked_error = Some(ToolError::not_available(missing_tool_error_message( - &tool_name, - &tool_catalog, - ))); - } - if McpPool::is_mcp_tool(&tool_name) { read_only = mcp_tool_is_read_only(&tool_name); supports_parallel = mcp_tool_is_parallel_safe(&tool_name); @@ -2654,4 +2668,4 @@ mod tests { assert_eq!(results[0].exit_code, Some(2)); assert!(results[0].stdout.contains("security")); } -} +} \ No newline at end of file diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index e5715dc2c..68dd6e4ab 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -551,6 +551,20 @@ impl HookExecutor { self.config.enabled && self.config.hooks.iter().any(|h| h.event == event) } + /// Check if there are any background hooks configured for a specific event. + /// + /// Background hooks fire and forget — their `exit_code` is always `None`, + /// so they cannot deny tool calls. This is a known limitation; the check + /// is used to warn operators when a `ToolCallBefore` hook is configured + /// as background but expects to block a tool. + #[must_use] + pub fn has_background_hooks_for_event(&self, event: HookEvent) -> bool { + if !self.config.enabled { + return false; + } + self.config.hooks.iter().any(|h| h.event == event && h.background) + } + /// Run configured `message_submit` hooks as a mutable submit pipeline. /// /// This is deliberately separate from [`Self::execute`]: most hook events diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d0dc64066..cf92cdcc3 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -511,8 +511,8 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { active_task_id: None, active_thread_id: None, // #456: plumb the App's HookExecutor so `exec_shell` can surface - // the configured `shell_env` hooks. Wrapped in Arc once and shared. - hook_executor: Some(std::sync::Arc::new(app.hooks.clone())), + // the configured `shell_env` hooks. Clone the shared Arc. + hook_executor: app.runtime_services.hook_executor.clone(), handle_store: app.runtime_services.handle_store.clone(), rlm_sessions: app.runtime_services.rlm_sessions.clone(), }; @@ -754,7 +754,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { ), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, allowed_tools: app.active_allowed_tools.clone(), - hook_executor: Some(std::sync::Arc::new(app.hooks.clone())), + hook_executor: app.runtime_services.hook_executor.clone(), network_policy: config.network.clone().map(|toml_cfg| { crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) }), @@ -4707,7 +4707,7 @@ async fn dispatch_user_message( translation_enabled: app.translation_enabled, show_thinking: app.show_thinking, allowed_tools: app.active_allowed_tools.clone(), - hook_executor: Some(std::sync::Arc::new(app.hooks.clone())), + hook_executor: app.runtime_services.hook_executor.clone(), }) .await { @@ -8875,4 +8875,4 @@ fn parse_semver(v: &str) -> Option<(u32, u32, u32)> { } #[cfg(test)] -mod tests; +mod tests; \ No newline at end of file From 2622db49351b61a1a60cf3eb3e969270276e5f5e Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Mon, 1 Jun 2026 21:50:47 +0200 Subject: [PATCH 16/98] fix: cargo fmt formatting for lint compliance --- crates/tui/src/core/engine/turn_loop.rs | 11 +++++++---- crates/tui/src/hooks.rs | 5 ++++- crates/tui/src/tui/ui.rs | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 3416b2efc..8c576a7ba 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1256,14 +1256,16 @@ impl Engine { } if blocked_error.is_none() - && !command_allows_tool(self.config.allowed_tools.as_deref(), &tool_name) { + && !command_allows_tool(self.config.allowed_tools.as_deref(), &tool_name) + { blocked_error = Some(ToolError::permission_denied(format!( "Tool '{tool_name}' is not in the allowed-tools list for the current command" ))); } if blocked_error.is_none() - && !caller_allowed_for_tool(tool_caller.as_ref(), tool_def) { + && !caller_allowed_for_tool(tool_caller.as_ref(), tool_def) + { blocked_error = Some(ToolError::permission_denied(format!( "Tool '{tool_name}' does not allow caller '{}'", caller_type_for_tool_use(tool_caller.as_ref()) @@ -1290,7 +1292,8 @@ impl Engine { // Warn if any ToolCallBefore hook is configured as background // — background hooks return exit_code: None immediately, so // the denial check (exit_code == Some(2)) can never match. - if hook_executor.has_background_hooks_for_event(crate::hooks::HookEvent::ToolCallBefore) + if hook_executor + .has_background_hooks_for_event(crate::hooks::HookEvent::ToolCallBefore) { tracing::warn!( "ToolCallBefore hook(s) configured with background=true — \ @@ -2668,4 +2671,4 @@ mod tests { assert_eq!(results[0].exit_code, Some(2)); assert!(results[0].stdout.contains("security")); } -} \ No newline at end of file +} diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index 68dd6e4ab..6450d9e6a 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -562,7 +562,10 @@ impl HookExecutor { if !self.config.enabled { return false; } - self.config.hooks.iter().any(|h| h.event == event && h.background) + self.config + .hooks + .iter() + .any(|h| h.event == event && h.background) } /// Run configured `message_submit` hooks as a mutable submit pipeline. diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index cf92cdcc3..3e91c42f6 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -8875,4 +8875,4 @@ fn parse_semver(v: &str) -> Option<(u32, u32, u32)> { } #[cfg(test)] -mod tests; \ No newline at end of file +mod tests; From cc923d634c42070f0789ee534e35817a40fd6dce Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Mon, 1 Jun 2026 21:53:23 +0200 Subject: [PATCH 17/98] =?UTF-8?q?fix:=20address=20greptile=20review=20comm?= =?UTF-8?q?ents=20=E2=80=94=20remove=20double-firing,=20wrap=20blocking=20?= =?UTF-8?q?execute=20in=20spawn=5Fblocking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove ToolCallBefore observer firing from tool_routing.rs (the turn-loop gate now handles it) to prevent double-firing hooks for each tool call (greptile P1). - Wrap hook_executor.execute() call in tokio::task::spawn_blocking so the Tokio worker thread is not blocked by child.wait_timeout() during hook execution (greptile P1). --- crates/tui/src/core/engine/turn_loop.rs | 14 ++++++++++++-- crates/tui/src/tui/tool_routing.rs | 18 +++++------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 8c576a7ba..7b6a8faab 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1309,8 +1309,18 @@ impl Engine { .with_workspace(self.session.workspace.clone()) .with_model(&self.config.model) .with_session_id(&self.session.id); - let hook_results = hook_executor - .execute(crate::hooks::HookEvent::ToolCallBefore, &hook_context); + // Run hooks off the Tokio worker thread: `execute()` calls + // `child.wait_timeout()` which is a blocking syscall that + // would stall all other async tasks on this thread. + let executor = hook_executor.clone(); + let hook_results = tokio::task::spawn_blocking(move || { + executor.execute(crate::hooks::HookEvent::ToolCallBefore, &hook_context) + }) + .await + .unwrap_or_else(|join_err| { + tracing::error!("Hook executor task panicked: {join_err}"); + Vec::new() + }); if let Some(denial) = hook_results .iter() .find(|result| result.exit_code == Some(2)) diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs index e0e35bdd0..f76e1cd16 100644 --- a/crates/tui/src/tui/tool_routing.rs +++ b/crates/tui/src/tui/tool_routing.rs @@ -22,19 +22,11 @@ pub(super) fn handle_tool_call_started( name: &str, input: &serde_json::Value, ) { - // #455 (observer-only): fire `tool_call_before` hooks here, before - // any UI bookkeeping. Hooks are read-only observers in this slice - // — they can log, notify, or audit, but cannot mutate the args. - // Fast-path skip when no hooks are configured so per-tool - // dispatch doesn't pay for context construction in the common - // case (most users have no hooks). - if app.hooks.has_hooks_for_event(HookEvent::ToolCallBefore) { - let context = app - .base_hook_context() - .with_tool_name(name) - .with_tool_args(input); - let _ = app.execute_hooks(HookEvent::ToolCallBefore, &context); - } + // #2511: ToolCallBefore gate moved to turn-loop planning loop + // (Engine::handle_deepseek_turn). Removing observer-only firing + // here to avoid double-firing hooks for each tool call. + // Hooks that need observation can configure ToolCallBefore on + // the turn-loop gate — it processes the denial (exit code 2). let id = id.to_string(); From 77b57bd9039096203a8052708b765eefdf2839c7 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Mon, 1 Jun 2026 22:14:13 +0200 Subject: [PATCH 18/98] fix: initialize hook_executor for fresh sessions to fix greptile P1 review --- crates/tui/src/tui/ui.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3e91c42f6..7ebaa8166 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -503,6 +503,14 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { .shell_manager .clone() .unwrap_or_else(|| crate::tools::shell::new_shared_shell_manager(app.workspace.clone())); + // #2511: ensure hook_executor is initialized for fresh sessions — it is + // only set by apply_workspace_runtime_state (session resume / workspace + // switch), so a brand-new session would otherwise leave it None and both + // exec_shell shell_env hooks and ToolCallBefore gate would silently no-op. + if app.runtime_services.hook_executor.is_none() { + app.runtime_services.hook_executor = + Some(std::sync::Arc::new(app.hooks.clone())); + } app.runtime_services = RuntimeToolServices { shell_manager: Some(shell_manager), task_manager: Some(task_manager.clone()), @@ -8875,4 +8883,4 @@ fn parse_semver(v: &str) -> Option<(u32, u32, u32)> { } #[cfg(test)] -mod tests; +mod tests; \ No newline at end of file From 2ca29276577b6f7fbdb1bd04f611936da38fc235 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Mon, 1 Jun 2026 22:19:48 +0200 Subject: [PATCH 19/98] fix: clippy needless_return and fmt compliance Remove unneeded return in utils.rs (crates/tui/src/utils.rs:256) that was caught by clippy on the new commit. Also run cargo fmt to satisfy format checks. --- crates/tui/src/tui/ui.rs | 5 ++--- crates/tui/src/utils.rs | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 7ebaa8166..b5fa3f081 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -508,8 +508,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { // switch), so a brand-new session would otherwise leave it None and both // exec_shell shell_env hooks and ToolCallBefore gate would silently no-op. if app.runtime_services.hook_executor.is_none() { - app.runtime_services.hook_executor = - Some(std::sync::Arc::new(app.hooks.clone())); + app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone())); } app.runtime_services = RuntimeToolServices { shell_manager: Some(shell_manager), @@ -8883,4 +8882,4 @@ fn parse_semver(v: &str) -> Option<(u32, u32, u32)> { } #[cfg(test)] -mod tests; \ No newline at end of file +mod tests; diff --git a/crates/tui/src/utils.rs b/crates/tui/src/utils.rs index 756c483db..3cb7972a2 100644 --- a/crates/tui/src/utils.rs +++ b/crates/tui/src/utils.rs @@ -253,7 +253,7 @@ fn browser_open_command(url: &str) -> Result { { let mut cmd = Command::new("cmd"); cmd.args(["/C", "start", "", url]); - return Ok(cmd); + Ok(cmd) } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] From bb64018a151f5103bf5a41b1b4ecbbf6ec6e69db Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 1 Jun 2026 14:09:49 -0700 Subject: [PATCH 20/98] feat(tui): add configurable auto-compact threshold Refs #1722 Preserves auto_compact as opt-in, adds the saved threshold setting, keeps the 500K hard floor, and wires Ctrl+L as a manual compaction shortcut for context-pressure recovery. Harvested from PR #1723 by @aboimpinto Co-authored-by: Paulo Aboim Pinto --- config.example.toml | 6 ++- crates/tui/src/prompts.rs | 2 +- crates/tui/src/prompts/base.md | 2 +- crates/tui/src/prompts/base.txt | 2 +- crates/tui/src/prompts/modes/agent.md | 2 +- crates/tui/src/settings.rs | 43 ++++++++++++++++++++ crates/tui/src/tui/app.rs | 3 ++ crates/tui/src/tui/ui.rs | 49 +++++++++++++++++++---- crates/tui/src/tui/ui/tests.rs | 57 +++++++++++++++++++++------ docs/CONFIGURATION.md | 14 ++++--- 10 files changed, 150 insertions(+), 30 deletions(-) diff --git a/config.example.toml b/config.example.toml index b77755041..d80a8aa44 100644 --- a/config.example.toml +++ b/config.example.toml @@ -481,8 +481,10 @@ exponential_base = 2.0 # Context Compaction # ───────────────────────────────────────────────────────────────────────────────── # Auto-compaction is a saved UI setting edited with `/config` (`auto_compact`). -# There is no config-file `[compaction]` table yet; detailed thresholds are -# chosen by the TUI from the active model/context budget. +# The optional saved threshold setting is `auto_compact_threshold_percent` +# (default 70, still gated by the 500K-token floor). There is no config-file +# `[compaction]` table yet; runtime compaction budgets are chosen by the TUI +# from the active model/context window. # Append-only Flash seams are experimental and opt-in while the v0.7.5 # context/cache audit validates prefix-cache behavior. diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 3f9475eec..8cd945801 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -995,7 +995,7 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( 1. Use `/compact` to summarize earlier context and free up space\n\ 2. The system will preserve important information (files you're working on, recent messages, tool results)\n\ 3. After compaction, you'll see a summary of what was discussed and can continue seamlessly\n\n\ - If you notice context is getting long (>60% during sustained work), proactively suggest using `/compact` to the user.\n\n\ + If you notice context is getting long (>60% during sustained work), proactively suggest using `/compact` or Ctrl+L to the user. If auto_compact is enabled, the engine can compact before the next send once the configured threshold is crossed.\n\n\ ### Prompt-cache awareness\n\n\ DeepSeek caches the longest *byte-stable prefix* of every request and charges roughly 100× less for cache-hit tokens than miss tokens. The system prompt above is layered most-static-first specifically so the prefix stays stable turn-over-turn. To keep cache hits high:\n\ - **Working set location:** the current repo working set is stored on new user messages inside a `` block. Treat it as high-priority turn metadata, not as a stable system-prompt section.\n\ diff --git a/crates/tui/src/prompts/base.md b/crates/tui/src/prompts/base.md index 061ff92cb..c692e03ad 100644 --- a/crates/tui/src/prompts/base.md +++ b/crates/tui/src/prompts/base.md @@ -204,7 +204,7 @@ For exact counts or structured aggregates, compute them directly in Python insid ## Context Management -You have a 1M-token context window. During long coding sessions, suggest `/compact` when usage approaches ~60% or when the app marks context pressure as high. It summarizes earlier turns so you can keep working without losing thread. +You have a 1M-token context window. During long coding sessions, suggest `/compact` or Ctrl+L when usage approaches ~60% or when the app marks context pressure as high. If auto_compact is enabled, the engine can compact before the next send once the configured threshold is crossed. Compaction summarizes earlier turns so you can keep working without losing thread. Model notes: DeepSeek V4 models emit *thinking tokens* (`ContentBlock::Thinking`) before final answers. These are invisible to the user but count against context. Cost/token estimates are approximate; treat them as a rough guide. diff --git a/crates/tui/src/prompts/base.txt b/crates/tui/src/prompts/base.txt index 775d50056..0add02a7c 100644 --- a/crates/tui/src/prompts/base.txt +++ b/crates/tui/src/prompts/base.txt @@ -31,7 +31,7 @@ RLM works by keeping the long input and intermediate values as symbolic REPL sta The Python helpers visible inside the REPL (`sub_query`, `sub_query_batch`, `sub_query_map`, `sub_rlm`, `finalize`, and related context helpers) are NOT separately-callable tools — they are functions the sub-agent uses inside its Python code. ## Context -You have a 1M-token context window. During long coding sessions, suggest `/compact` when usage approaches ~60% or when the app marks context pressure as high. It summarizes earlier turns so you can keep working without losing thread. +You have a 1M-token context window. During long coding sessions, suggest `/compact` or Ctrl+L when usage approaches ~60% or when the app marks context pressure as high. If auto_compact is enabled, the engine can compact before the next send once the configured threshold is crossed. Compaction summarizes earlier turns so you can keep working without losing thread. Model notes: DeepSeek V4 models emit *thinking tokens* (`ContentBlock::Thinking`) before final answers. These are invisible to the user but count against context. Cost/token estimates are approximate; treat them as a rough guide. diff --git a/crates/tui/src/prompts/modes/agent.md b/crates/tui/src/prompts/modes/agent.md index 1eea5c0ea..7e591799d 100644 --- a/crates/tui/src/prompts/modes/agent.md +++ b/crates/tui/src/prompts/modes/agent.md @@ -26,6 +26,6 @@ Don't sequence approvals one at a time — the user wants context, not interrupt Long sessions accumulate context. To stay fast: - Open sub-agent sessions for independent work instead of doing everything sequentially - Batch reads/searches/git-inspections into parallel tool calls -- Suggest `/compact` when context nears 60% during sustained work — the compaction relay preserves open blockers +- Suggest `/compact` or Ctrl+L when context nears 60% during sustained work — the compaction relay preserves open blockers - Use `note` for decisions you'll need across compaction boundaries - A 3-turn session that fans out to sub-agents finishes faster AND stays responsive longer than a 15-turn sequential grind diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 6dd40791c..d33aab644 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -171,6 +171,9 @@ impl TuiPrefs { pub struct Settings { /// Auto-compact conversations when they approach the model limit. pub auto_compact: bool, + /// Context-window percentage that triggers pre-send auto-compaction when + /// `auto_compact` is enabled. The hard token floor still applies. + pub auto_compact_threshold_percent: f64, /// Reduce status noise and collapse details more aggressively pub calm_mode: bool, /// Streaming pacing mode. `true` pins the chunker to one-character-per- @@ -299,6 +302,7 @@ impl Default for Settings { // available for users / agents that decide compaction is // worth the cache hit on their workload (#664). auto_compact: false, + auto_compact_threshold_percent: 70.0, calm_mode: false, low_motion: false, fancy_animations: true, @@ -497,6 +501,10 @@ impl Settings { "auto_compact" | "compact" => { self.auto_compact = parse_bool(value)?; } + "auto_compact_threshold" | "auto_compact_threshold_percent" => { + self.auto_compact_threshold_percent = + parse_percent_setting("auto_compact_threshold_percent", value)?; + } "calm_mode" | "calm" => { self.calm_mode = parse_bool(value)?; } @@ -701,6 +709,10 @@ impl Settings { lines.push(tr(locale, MessageId::SettingsTitle).to_string()); lines.push("─────────────────────────────".to_string()); lines.push(format!(" auto_compact: {}", self.auto_compact)); + lines.push(format!( + " auto_compact_pct: {:.0}", + self.auto_compact_threshold_percent + )); lines.push(format!(" calm_mode: {}", self.calm_mode)); lines.push(format!(" low_motion: {}", self.low_motion)); lines.push(format!(" fancy_animations: {}", self.fancy_animations)); @@ -768,6 +780,10 @@ impl Settings { "auto_compact", "Auto-compact near the hard context limit: on/off (default off)", ), + ( + "auto_compact_threshold_percent", + "Auto-compact trigger threshold percent when auto_compact is on: 10-100 (default 70)", + ), ("calm_mode", "Calmer UI defaults: on/off"), ( "low_motion", @@ -932,6 +948,21 @@ fn parse_usize_setting(key: &str, value: &str) -> Result { }) } +fn parse_percent_setting(key: &str, value: &str) -> Result { + let trimmed = value.trim().trim_end_matches('%').trim(); + let percent = trimmed.parse::().map_err(|_| { + anyhow::anyhow!( + "Failed to update setting: invalid {key} '{value}'. Expected a number from 10 to 100." + ) + })?; + if !(10.0..=100.0).contains(&percent) { + anyhow::bail!( + "Failed to update setting: invalid {key} '{value}'. Expected a number from 10 to 100." + ); + } + Ok(percent) +} + fn normalize_mode(value: &str) -> &str { match value.trim().to_ascii_lowercase().as_str() { "edit" => "agent", @@ -1103,6 +1134,7 @@ mod tests { // flipped so the cache-friendly path is the one users get // without configuring anything (#664). assert!(!settings.auto_compact); + assert_eq!(settings.auto_compact_threshold_percent, 70.0); } #[test] @@ -1114,6 +1146,17 @@ mod tests { assert!(!settings.auto_compact); } + #[test] + fn auto_compact_threshold_is_validated() { + let mut settings = Settings::default(); + settings + .set("auto_compact_threshold", "65%") + .expect("threshold"); + assert_eq!(settings.auto_compact_threshold_percent, 65.0); + assert!(settings.set("auto_compact_threshold", "9").is_err()); + assert!(settings.set("auto_compact_threshold", "101").is_err()); + } + #[test] fn default_settings_show_footer_water_strip() { let settings = Settings::default(); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 7a8e46bbe..e0d2eb4b3 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1220,6 +1220,7 @@ pub struct App { #[allow(dead_code)] pub system_prompt: Option, pub auto_compact: bool, + pub auto_compact_threshold_percent: f64, pub calm_mode: bool, pub low_motion: bool, /// Pending #61 (animated working strip). Set from config but not read @@ -1748,6 +1749,7 @@ impl App { crate::config::active_provider_uses_env_only_api_key(&effective_auth_config); let was_onboarded = crate::tui::onboarding::is_onboarded(); let auto_compact = settings.auto_compact; + let auto_compact_threshold_percent = settings.auto_compact_threshold_percent; let calm_mode = settings.calm_mode; let low_motion = settings.low_motion; let fancy_animations = settings.fancy_animations; @@ -1946,6 +1948,7 @@ impl App { bracketed_paste_seen: false, system_prompt: None, auto_compact, + auto_compact_threshold_percent, calm_mode, low_motion, fancy_animations, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b5fa3f081..7d08f9e96 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -40,7 +40,7 @@ use crate::client::{ inspect_prompt_for_request, }; use crate::commands; -use crate::compaction::estimate_input_tokens_conservative; +use crate::compaction::{MINIMUM_AUTO_COMPACTION_TOKENS, estimate_input_tokens_conservative}; use crate::config::{ ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, ProviderConfig, ProvidersConfig, StatusItem, UpdateConfig, save_provider_auth_mode_for, @@ -145,6 +145,7 @@ const MIN_CHAT_HEIGHT: u16 = 3; const MIN_COMPOSER_HEIGHT: u16 = 2; const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0; const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0; +const CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT: f64 = 60.0; const UI_IDLE_POLL_MS: u64 = 48; const UI_ACTIVE_POLL_MS: u64 = 24; const WEB_CONFIG_POLL_MS: u64 = 16; @@ -2934,6 +2935,22 @@ async fn run_event_loop( continue; } + if matches!(key.code, KeyCode::Char('l') | KeyCode::Char('L')) + && key.modifiers.contains(KeyModifiers::CONTROL) + && app.view_stack.is_empty() + { + app.status_message = Some(if app.is_compacting { + "Context compaction already in progress...".to_string() + } else { + "Compacting context (Ctrl+L)...".to_string() + }); + if !app.is_compacting { + let _ = engine_handle.send(Op::CompactContext).await; + } + app.needs_redraw = true; + continue; + } + if matches!(key.code, KeyCode::Char('b') | KeyCode::Char('B')) && key.modifiers.contains(KeyModifiers::CONTROL) && app.view_stack.is_empty() @@ -4634,7 +4651,8 @@ async fn dispatch_user_message( }); maybe_warn_context_pressure(app); if should_auto_compact_before_send(app) { - app.status_message = Some("Context critical; compacting before send...".to_string()); + app.status_message = + Some("Context threshold reached; compacting before send...".to_string()); let _ = engine_handle.send(Op::CompactContext).await; } app.session.last_prompt_tokens = None; @@ -7869,14 +7887,21 @@ fn maybe_warn_context_pressure(app: &mut App) { return; }; - if percent < CONTEXT_WARNING_THRESHOLD_PERCENT { + let configured_threshold = app.auto_compact_threshold_percent.clamp(10.0, 100.0); + let warning_threshold = CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT.min(configured_threshold); + if percent < warning_threshold { return; } - let recommendation = if app.auto_compact { - "Auto-compaction is enabled." + let below_auto_floor = used < MINIMUM_AUTO_COMPACTION_TOKENS as i64; + let recommendation = if !app.auto_compact { + "Consider enabling auto_compact or use /compact." + } else if below_auto_floor { + "Auto-compaction is enabled but waits for the 500K token floor." + } else if percent >= configured_threshold { + "Auto-compaction will run before the next send." } else { - "Consider /compact or /clear." + "Auto-compaction is enabled." }; if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT { @@ -7887,8 +7912,13 @@ fn maybe_warn_context_pressure(app: &mut App) { } if app.status_message.is_none() { + let status_prefix = if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT { + "Context high" + } else { + "Context building" + }; app.status_message = Some(format!( - "Context high: {percent:.0}% ({used}/{max} tokens). {recommendation}" + "{status_prefix}: {percent:.0}% ({used}/{max} tokens). {recommendation}" )); } } @@ -7898,7 +7928,10 @@ fn should_auto_compact_before_send(app: &App) -> bool { return false; } context_usage_snapshot(app) - .map(|(_, _, pct)| pct >= CONTEXT_CRITICAL_THRESHOLD_PERCENT) + .map(|(used, _, pct)| { + used >= MINIMUM_AUTO_COMPACTION_TOKENS as i64 + && pct >= app.auto_compact_threshold_percent.clamp(10.0, 100.0) + }) .unwrap_or(false) } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 166031371..ed786ad62 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -3347,19 +3347,31 @@ fn context_usage_snapshot_prefers_live_estimate_while_loading() { #[test] fn should_auto_compact_before_send_respects_threshold_and_setting() { let mut app = create_test_app(); - let big_buffer = vec![Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: "context ".repeat(400_000), - cache_control: None, - }], - }]; + let messages_for_repeats = |repeats: usize| { + vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "context ".repeat(repeats), + cache_control: None, + }], + }] + }; // High estimated context + auto_compact ON → auto-compact triggers. - app.api_messages = big_buffer.clone(); + app.api_messages = messages_for_repeats(240_000); app.auto_compact = true; + app.auto_compact_threshold_percent = 70.0; assert!(should_auto_compact_before_send(&app)); + let (_, _, high_percent) = + context_usage_snapshot(&app).expect("high context snapshot should be available"); + assert!( + (70.0..90.0).contains(&high_percent), + "test fixture should sit between default and high custom thresholds; got {high_percent:.2}%" + ); + app.auto_compact_threshold_percent = 90.0; + assert!(!should_auto_compact_before_send(&app)); + // Same high context but auto_compact OFF → never triggers. app.auto_compact = false; assert!(!should_auto_compact_before_send(&app)); @@ -3369,16 +3381,39 @@ fn should_auto_compact_before_send_respects_threshold_and_setting() { // #115 fix: the estimate is the primary signal, not the engine's // turn-cumulative reported value (which used to rule the displayed // % and could spuriously trigger / suppress auto-compact). + app.api_messages = messages_for_repeats(80_000); + app.auto_compact = true; + app.auto_compact_threshold_percent = 10.0; + app.session.last_prompt_tokens = Some(10_000); + let (used, _, percent) = + context_usage_snapshot(&app).expect("floor context snapshot should be available"); + assert!( + used < crate::compaction::MINIMUM_AUTO_COMPACTION_TOKENS as i64 && percent >= 10.0, + "test fixture should cross percent threshold but stay below the 500K floor; used={used} percent={percent:.2}" + ); + assert!(!should_auto_compact_before_send(&app)); +} + +#[test] +fn context_pressure_warning_reflects_auto_compact_threshold_state() { + let mut app = create_test_app(); app.api_messages = vec![Message { role: "user".to_string(), content: vec![ContentBlock::Text { - text: "small".to_string(), + text: "context ".repeat(240_000), cache_control: None, }], }]; app.auto_compact = true; - app.session.last_prompt_tokens = Some(10_000); - assert!(!should_auto_compact_before_send(&app)); + app.auto_compact_threshold_percent = 70.0; + + maybe_warn_context_pressure(&mut app); + + let status = app.status_message.expect("context warning"); + assert!( + status.contains("Auto-compaction will run before the next send."), + "unexpected status: {status}" + ); } // ============================================================================ diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 41251e7a3..8c3919b0a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -482,11 +482,13 @@ codewhale also stores user preferences in: - `~/.config/deepseek/settings.toml` Notable settings include `auto_compact` (default `false`), which opts into -replacement-style summarization only near the active model limit. The default -V4 path preserves the stable message prefix for cache reuse; use manual -`/compact` or enable `auto_compact` only when you explicitly want automatic -replacement compaction. You can inspect or update these from the TUI with -`/settings` and `/config` (interactive editor). +replacement-style summarization before the active model limit. The trigger +defaults to `auto_compact_threshold_percent = 70`, but the 500K-token floor +still blocks early compaction. The default V4 path preserves the stable message +prefix for cache reuse; use manual `/compact` / Ctrl+L or enable +`auto_compact` only when you explicitly want automatic replacement compaction. +You can inspect or update these from the TUI with `/settings` and `/config` +(interactive editor). Common settings keys: @@ -497,6 +499,8 @@ Common settings keys: community presets apply across the TUI. Aliases such as `whale`, `mono`, `black-white`, `tokyonight`, and `gruvbox` are accepted. - `auto_compact` (on/off, default off) +- `auto_compact_threshold_percent` (10-100, default `70`): pre-send + auto-compaction threshold used only when `auto_compact` is enabled. - `paste_burst_detection` (on/off, default on): fallback rapid-key paste detection for terminals that do not emit bracketed-paste events. This is independent of terminal bracketed-paste mode. From 472cd442ba418d474e5dc1259f2518568fef5ca3 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 1 Jun 2026 14:14:20 -0700 Subject: [PATCH 21/98] fix(tui): expose auto-compact threshold in config view --- crates/tui/src/tui/views/mod.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 2f79796f5..3ca085845 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -803,6 +803,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::History, + key: "auto_compact_threshold_percent".to_string(), + value: format!("{:.0}", settings.auto_compact_threshold_percent), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::History, key: "max_history".to_string(), @@ -1180,6 +1187,7 @@ fn config_hint_for_key(key: &str) -> &'static str { "sidebar_width" => "10..=50", "sidebar_focus" => "auto | work | tasks | agents | context | hidden", "max_history" => "integer (0 allowed)", + "auto_compact_threshold_percent" => "10..=100", "default_model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-* | none/default", "reasoning_effort" => "auto | off | low | medium | high | max | default", "mcp_config_path" => "path to mcp.json", From 4ff9bba7501b9d15b79f78ce6a10b7f7eb706d1a Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 1 Jun 2026 14:23:41 -0700 Subject: [PATCH 22/98] fix(tui): keep config scope column visible --- crates/tui/src/tui/views/mod.rs | 58 ++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 3ca085845..616eef6f4 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -582,6 +582,10 @@ pub struct ConfigView { const CONFIG_MIN_KEY_COLUMN_WIDTH: usize = 19; const CONFIG_VALUE_COLUMN_WIDTH: usize = 44; +const CONFIG_MIN_VALUE_COLUMN_WIDTH: usize = 10; +const CONFIG_SCOPE_COLUMN_WIDTH: usize = 7; +const CONFIG_ROW_PREFIX_WIDTH: usize = 2; +const CONFIG_COLUMN_GAPS_WIDTH: usize = 2; impl ConfigView { pub fn new_for_app(app: &App) -> Self { @@ -911,6 +915,27 @@ impl ConfigView { .max(CONFIG_MIN_KEY_COLUMN_WIDTH) } + fn table_column_widths(&self, content_width: usize) -> (usize, usize, usize) { + let fixed_width = + CONFIG_ROW_PREFIX_WIDTH + CONFIG_COLUMN_GAPS_WIDTH + CONFIG_SCOPE_COLUMN_WIDTH; + let key_value_width = content_width.saturating_sub(fixed_width); + let desired_key_width = self.key_column_width(); + + if key_value_width == 0 { + return (0, 0, CONFIG_SCOPE_COLUMN_WIDTH); + } + + let minimum_key_width = CONFIG_MIN_KEY_COLUMN_WIDTH.min(key_value_width); + let key_width = desired_key_width + .min(key_value_width.saturating_sub(CONFIG_MIN_VALUE_COLUMN_WIDTH)) + .max(minimum_key_width); + let value_width = key_value_width + .saturating_sub(key_width) + .min(CONFIG_VALUE_COLUMN_WIDTH); + + (key_width, value_width, CONFIG_SCOPE_COLUMN_WIDTH) + } + fn selected_row_index(&self) -> Option { let selected = self.selected; self.matching_row_indices() @@ -1439,7 +1464,8 @@ impl ModalView for ConfigView { self.filter.clone() }; - let key_column_width = self.key_column_width(); + let (key_column_width, value_column_width, scope_column_width) = + self.table_column_widths(usize::from(inner.width)); let mut lines: Vec = vec![ Line::from(vec![Span::styled( self.tr(MessageId::ConfigTitle), @@ -1455,15 +1481,22 @@ impl ModalView for ConfigView { ]), Line::from(""), Line::from(format!( - " {: Date: Tue, 2 Jun 2026 01:42:02 +0800 Subject: [PATCH 23/98] fix(config): honor workspace shell opt-in --- crates/tui/src/config.rs | 2 +- crates/tui/src/main.rs | 160 ++++++++++++++++++++++++++++++++++++++- docs/CONFIGURATION.md | 16 +++- 3 files changed, 174 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 10dd8493b..ecce5effc 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -2704,7 +2704,7 @@ fn expand_pathbuf(path: PathBuf) -> PathBuf { path } -fn resolve_load_config_path(path: Option) -> Option { +pub(crate) fn resolve_load_config_path(path: Option) -> Option { if let Some(path) = path { return Some(expand_pathbuf(path)); } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 47468b73c..206b8bb35 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -898,11 +898,13 @@ async fn main() -> Result<()> { } Commands::Exec(args) => { let config = load_config_from_cli(&cli)?; - let model = resolve_exec_model(&config, args.model.as_deref()); - let prompt = join_prompt_parts(&args.prompt); let workspace = cli.workspace.clone().unwrap_or_else(|| { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) }); + let mut config = config.clone(); + merge_user_workspace_config(&mut config, cli.config.clone(), &workspace); + let model = resolve_exec_model(&config, args.model.as_deref()); + let prompt = join_prompt_parts(&args.prompt); let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?; // The `deepseek` launcher forwards `--yolo` to this binary via // the DEEPSEEK_YOLO env var (which the config loader folds into @@ -4952,6 +4954,67 @@ fn merge_project_config(config: &mut Config, workspace: &Path) { } } +fn merge_user_workspace_config( + config: &mut Config, + config_path: Option, + workspace: &Path, +) { + if config.managed_config_path.is_some() || config.requirements_path.is_some() { + return; + } + let allow_shell_before = config.allow_shell; + let allow_shell_from_env = std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some(); + let Some(path) = crate::config::resolve_load_config_path(config_path) else { + return; + }; + let Ok(raw) = std::fs::read_to_string(path) else { + return; + }; + let Ok(doc) = toml::from_str::(&raw) else { + return; + }; + merge_user_workspace_config_from_doc(config, &doc, workspace); + if allow_shell_from_env { + config.allow_shell = allow_shell_before; + } +} + +fn merge_user_workspace_config_from_doc(config: &mut Config, doc: &toml::Value, workspace: &Path) { + for table_name in ["workspace", "projects"] { + let Some(entries) = doc.get(table_name).and_then(toml::Value::as_table) else { + continue; + }; + for (raw_path, entry) in entries { + if !workspace_config_path_matches(raw_path, workspace) { + continue; + } + if let Some(allow_shell) = entry.get("allow_shell").and_then(toml::Value::as_bool) { + config.allow_shell = Some(allow_shell); + } + } + } +} + +fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool { + let configured = crate::config::expand_path(raw_path); + let configured = configured.canonicalize().unwrap_or(configured); + let workspace = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.to_path_buf()); + paths_equal_for_config(&configured, &workspace) +} + +#[cfg(windows)] +fn paths_equal_for_config(left: &Path, right: &Path) -> bool { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) +} + +#[cfg(not(windows))] +fn paths_equal_for_config(left: &Path, right: &Path) -> bool { + left == right +} + async fn run_interactive( cli: &Cli, config: &Config, @@ -4967,6 +5030,7 @@ async fn run_interactive( // or legacy $WORKSPACE/.deepseek/config.toml // unless --no-project-config was passed (#485). let mut merged_config = config.clone(); + merge_user_workspace_config(&mut merged_config, cli.config.clone(), &workspace); if !cli.no_project_config { merge_project_config(&mut merged_config, &workspace); } @@ -6805,6 +6869,98 @@ allow_shell = false assert_eq!(config.allow_shell, Some(false)); } + #[test] + fn user_workspace_overlay_can_enable_shell_for_matching_workspace() { + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().join("project"); + fs::create_dir_all(&workspace).expect("mkdir workspace"); + let raw = format!( + "[workspace.'{}']\nallow_shell = true\n", + workspace.display() + ); + let doc: toml::Value = toml::from_str(&raw).expect("parse config"); + + let mut config = Config::default(); + merge_user_workspace_config_from_doc(&mut config, &doc, &workspace); + + assert_eq!(config.allow_shell, Some(true)); + } + + #[test] + fn user_workspace_overlay_ignores_non_matching_workspace() { + let tmp = tempdir().expect("tempdir"); + let configured_workspace = tmp.path().join("configured"); + let active_workspace = tmp.path().join("active"); + fs::create_dir_all(&configured_workspace).expect("mkdir configured workspace"); + fs::create_dir_all(&active_workspace).expect("mkdir active workspace"); + let raw = format!( + "[workspace.'{}']\nallow_shell = true\n", + configured_workspace.display() + ); + let doc: toml::Value = toml::from_str(&raw).expect("parse config"); + + let mut config = Config::default(); + merge_user_workspace_config_from_doc(&mut config, &doc, &active_workspace); + + assert_eq!(config.allow_shell, None); + } + + #[test] + fn user_workspace_overlay_preserves_allow_shell_env_override() { + let _guard = crate::test_support::lock_test_env(); + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().join("project"); + fs::create_dir_all(&workspace).expect("mkdir workspace"); + let config_path = tmp.path().join("config.toml"); + fs::write( + &config_path, + format!( + "[workspace.'{}']\nallow_shell = true\n", + workspace.display() + ), + ) + .expect("write config"); + + unsafe { + std::env::set_var("DEEPSEEK_ALLOW_SHELL", "false"); + } + let mut config = Config { + allow_shell: Some(false), + ..Config::default() + }; + merge_user_workspace_config(&mut config, Some(config_path), &workspace); + unsafe { + std::env::remove_var("DEEPSEEK_ALLOW_SHELL"); + } + + assert_eq!(config.allow_shell, Some(false)); + } + + #[test] + fn user_workspace_overlay_does_not_override_managed_config() { + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().join("project"); + fs::create_dir_all(&workspace).expect("mkdir workspace"); + let config_path = tmp.path().join("config.toml"); + fs::write( + &config_path, + format!( + "[workspace.'{}']\nallow_shell = true\n", + workspace.display() + ), + ) + .expect("write config"); + + let mut config = Config { + allow_shell: Some(false), + managed_config_path: Some("managed.toml".to_string()), + ..Config::default() + }; + merge_user_workspace_config(&mut config, Some(config_path), &workspace); + + assert_eq!(config.allow_shell, Some(false)); + } + #[test] fn project_overlay_clamps_max_subagents_to_safe_range() { let tmp = workspace_with_project_config( diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8c3919b0a..4aa2d8e3e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -20,6 +20,20 @@ Overrides: If both are set, `--config` wins. Environment variable overrides are applied after the file is loaded. +### User workspace entries + +For a shell opt-in that should live in the user's global config rather than in +the repository, add a workspace-scoped entry: + +```toml +[workspace.'/absolute/path/to/project'] +allow_shell = true +``` + +The entry applies only when the launched workspace path matches the table key. +The legacy `[projects."/absolute/path/to/project"]` table is also accepted for +this user-owned override. + ### Per-project overlay (#485) When the TUI starts in a workspace that contains a @@ -596,7 +610,7 @@ If you are upgrading from older releases: - `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.xiaomimimo.com/v1` for `xiaomi-mimo`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. - `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-35b-a3b`, `google/gemma-4-31b-it`, and `moonshotai/kimi-k2.6`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, and Ollama model IDs are passed through unchanged. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. -- `allow_shell` (bool, optional): defaults to `true` (sandboxed). +- `allow_shell` (bool, optional): defaults to `false`; shell tools must be explicitly enabled. - `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases. - `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`. Platform support is not identical. macOS uses Seatbelt for policy From 1d8cbbd40cdf071377306c2f38046f14b3ce5939 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 01:51:47 +0800 Subject: [PATCH 24/98] fix(config): normalize windows workspace paths --- crates/tui/src/main.rs | 43 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 206b8bb35..e51a19005 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -5006,8 +5006,8 @@ fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool { #[cfg(windows)] fn paths_equal_for_config(left: &Path, right: &Path) -> bool { - left.to_string_lossy() - .eq_ignore_ascii_case(&right.to_string_lossy()) + normalize_windows_config_path_for_compare(left) + == normalize_windows_config_path_for_compare(right) } #[cfg(not(windows))] @@ -5015,6 +5015,25 @@ fn paths_equal_for_config(left: &Path, right: &Path) -> bool { left == right } +#[cfg(windows)] +fn normalize_windows_config_path_for_compare(path: &Path) -> String { + normalize_windows_config_path_str(&path.to_string_lossy()) +} + +#[cfg(any(windows, test))] +fn normalize_windows_config_path_str(path: &str) -> String { + let mut normalized = path.replace('/', "\\"); + if let Some(rest) = normalized.strip_prefix(r"\\?\UNC\") { + normalized = format!("\\\\{rest}"); + } else if let Some(rest) = normalized.strip_prefix(r"\\?\") { + normalized = rest.to_string(); + } + while normalized.len() > 3 && normalized.ends_with('\\') { + normalized.pop(); + } + normalized.to_ascii_lowercase() +} + async fn run_interactive( cli: &Cli, config: &Config, @@ -6961,6 +6980,26 @@ allow_shell = false assert_eq!(config.allow_shell, Some(false)); } + #[test] + fn windows_config_path_compare_normalizes_mixed_separators() { + assert_eq!( + normalize_windows_config_path_str(r"C:\Users\me\repo"), + normalize_windows_config_path_str(r"C:/Users/me/repo/") + ); + } + + #[test] + fn windows_config_path_compare_normalizes_verbatim_and_unc_prefixes() { + assert_eq!( + normalize_windows_config_path_str(r"\\?\C:\Users\me\repo"), + normalize_windows_config_path_str(r"C:/Users/me/repo") + ); + assert_eq!( + normalize_windows_config_path_str(r"\\?\UNC\server\share\repo"), + normalize_windows_config_path_str(r"\\server/share/repo/") + ); + } + #[test] fn project_overlay_clamps_max_subagents_to_safe_range() { let tmp = workspace_with_project_config( From 3d5edfee802bbb2d2a25d9ef15cceba0f71a61a0 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 1 Jun 2026 15:30:47 -0700 Subject: [PATCH 25/98] test(config): cover legacy workspace shell opt-in Refs #2523 --- crates/tui/src/main.rs | 14 ++++++++++++++ docs/CONFIGURATION.md | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index e51a19005..376514f8a 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -6905,6 +6905,20 @@ allow_shell = false assert_eq!(config.allow_shell, Some(true)); } + #[test] + fn user_workspace_overlay_accepts_legacy_projects_table() { + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().join("project"); + fs::create_dir_all(&workspace).expect("mkdir workspace"); + let raw = format!("[projects.'{}']\nallow_shell = true\n", workspace.display()); + let doc: toml::Value = toml::from_str(&raw).expect("parse config"); + + let mut config = Config::default(); + merge_user_workspace_config_from_doc(&mut config, &doc, &workspace); + + assert_eq!(config.allow_shell, Some(true)); + } + #[test] fn user_workspace_overlay_ignores_non_matching_workspace() { let tmp = tempdir().expect("tempdir"); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4aa2d8e3e..e1698f2a5 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -34,6 +34,10 @@ The entry applies only when the launched workspace path matches the table key. The legacy `[projects."/absolute/path/to/project"]` table is also accepted for this user-owned override. +In interactive mode, the per-project overlay +`/.codewhale/config.toml` is applied after this user entry. A +project-level `allow_shell = false` still takes precedence. + ### Per-project overlay (#485) When the TUI starts in a workspace that contains a From 588e54f84dfc47f7f2ebb903aef69a542eada47f Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 03:43:04 +0800 Subject: [PATCH 26/98] fix(mcp): surface invalid stdio output --- crates/tui/src/mcp.rs | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index c07fe5acf..dda1daa05 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -196,6 +196,22 @@ async fn bounded_body_excerpt(response: reqwest::Response, max_bytes: usize) -> format!("{}{}", redact_body_preview(&one_line), suffix) } +fn invalid_json_preview(bytes: &[u8]) -> String { + let body_text = String::from_utf8_lossy(bytes); + if body_text.is_empty() { + return "".to_string(); + } + + let trimmed: String = body_text.chars().take(ERROR_BODY_PREVIEW_BYTES).collect(); + let suffix = if body_text.len() > trimmed.len() { + "…" + } else { + "" + }; + let one_line = trimmed.replace(['\n', '\r'], " "); + format!("{}{}", redact_body_preview(&one_line), suffix) +} + // === Configuration Types === /// Full MCP configuration from mcp.json @@ -1824,7 +1840,11 @@ impl McpConnection { self.state = ConnectionState::Disconnected; })?; let value: serde_json::Value = serde_json::from_slice(&bytes).with_context(|| { - format!("Invalid MCP JSON-RPC message from server '{}'", self.name) + format!( + "Invalid MCP JSON-RPC message from server '{}': {}", + self.name, + invalid_json_preview(&bytes) + ) })?; // Check if this is a response with the expected id. We emit @@ -3379,6 +3399,25 @@ mod tests { assert_eq!(sent[0]["method"], "tools/call"); } + #[tokio::test] + async fn call_method_invalid_json_includes_server_output_preview() { + let sent = Arc::new(Mutex::new(Vec::new())); + let transport = ScriptedValueTransport { + sent: Arc::clone(&sent), + responses: VecDeque::from([b"Allow Burp MCP connection? [y/N]".to_vec()]), + }; + let mut conn = test_connection(Box::new(transport)); + + let err = conn + .call_method("tools/call", serde_json::json!({"name": "burp"}), 1) + .await + .expect_err("non-json MCP stdout should fail"); + let msg = err.to_string(); + + assert!(msg.contains("Invalid MCP JSON-RPC message from server 'mock'")); + assert!(msg.contains("Allow Burp MCP connection")); + } + #[tokio::test] async fn call_method_times_out_while_waiting_for_response() { let sent = Arc::new(Mutex::new(Vec::new())); From c81d1c272f8afbbc55f37e810184ecc2de5b441e Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 1 Jun 2026 15:31:45 -0700 Subject: [PATCH 27/98] test(mcp): cover invalid stdio preview redaction Refs #2475 --- crates/tui/src/mcp.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index dda1daa05..4cdbdc140 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -203,7 +203,7 @@ fn invalid_json_preview(bytes: &[u8]) -> String { } let trimmed: String = body_text.chars().take(ERROR_BODY_PREVIEW_BYTES).collect(); - let suffix = if body_text.len() > trimmed.len() { + let suffix = if body_text.chars().count() > ERROR_BODY_PREVIEW_BYTES { "…" } else { "" @@ -4013,6 +4013,26 @@ mod tests { ); } + #[test] + fn invalid_json_preview_collapses_lines_and_redacts_secrets() { + let preview = invalid_json_preview( + b"Authorization: Bearer PLACEHOLDER_TOKEN\nAllow connection? api_key=PLACEHOLDER_KEY", + ); + + assert!( + preview.contains("Authorization: Bearer *** Allow connection? api_key=***"), + "preview: {preview}" + ); + assert!( + !preview.contains('\n'), + "preview should be single-line: {preview}" + ); + assert!( + !preview.contains("PLACEHOLDER_TOKEN") && !preview.contains("PLACEHOLDER_KEY"), + "secret leaked: {preview}" + ); + } + /// #420: `StdioTransport::shutdown` reaps the child process by sending /// SIGTERM and giving it a brief grace period before drop fires SIGKILL. /// The test spawns `cat` (which exits immediately on stdin EOF / SIGTERM) From a97675824568cf6cc1fe7d46c13957d100c23e82 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 02:02:40 +0800 Subject: [PATCH 28/98] fix(tui): hint mention depth cap on misses --- crates/tui/src/tui/file_mention.rs | 15 ++++++++++++++- crates/tui/src/tui/ui/tests.rs | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index 86d237c22..3a04e7073 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -270,7 +270,10 @@ pub fn try_autocomplete_file_mention(app: &mut App) -> bool { let ws = workspace_for_app(app); let candidates = find_file_mention_completions(&ws, &partial, FILE_MENTION_COMPLETION_LIMIT); if candidates.is_empty() { - app.status_message = Some(format!("No files match @{partial}")); + app.status_message = Some(no_file_mention_matches_status( + &partial, + app.mention_walk_depth, + )); return true; } if candidates.len() == 1 { @@ -297,6 +300,16 @@ pub fn try_autocomplete_file_mention(app: &mut App) -> bool { true } +fn no_file_mention_matches_status(partial: &str, walk_depth: usize) -> String { + if walk_depth > 0 && (partial.contains('/') || partial.contains('\\')) { + format!( + "No files match @{partial} (mention_walk_depth={walk_depth}; use /config set mention_walk_depth 0 to search deeper)" + ) + } else { + format!("No files match @{partial}") + } +} + /// Splice a completion into the input, replacing the `@` token at /// `byte_start` with `@`. Cursor moves to the end of the new /// token so further keystrokes extend (or escape via space) naturally. diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index ed786ad62..4f24373c1 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4729,6 +4729,25 @@ fn try_autocomplete_file_mention_no_match_reports_status() { ); } +#[test] +fn try_autocomplete_file_mention_no_match_mentions_depth_cap_for_path_like_partial() { + let tmpdir = TempDir::new().expect("tempdir"); + + let mut app = create_test_app(); + app.workspace = tmpdir.path().to_path_buf(); + app.mention_walk_depth = 6; + app.input = "@a/b/c/d/e/f/g/target".to_string(); + app.cursor_position = app.input.chars().count(); + + assert!(try_autocomplete_file_mention(&mut app)); + assert_eq!( + app.status_message.as_deref(), + Some( + "No files match @a/b/c/d/e/f/g/target (mention_walk_depth=6; use /config set mention_walk_depth 0 to search deeper)" + ) + ); +} + #[test] fn try_autocomplete_file_mention_returns_false_outside_mention() { let mut app = create_test_app(); From 29f57665eb2dc5c1461257ff9b4662004e74069b Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 02:11:31 +0800 Subject: [PATCH 29/98] fix(tui): narrow mention depth hint --- crates/tui/src/tui/file_mention.rs | 13 +++++++++++- crates/tui/src/tui/ui/tests.rs | 34 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index 3a04e7073..4e9e2d368 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -301,7 +301,7 @@ pub fn try_autocomplete_file_mention(app: &mut App) -> bool { } fn no_file_mention_matches_status(partial: &str, walk_depth: usize) -> String { - if walk_depth > 0 && (partial.contains('/') || partial.contains('\\')) { + if path_partial_reaches_walk_depth(partial, walk_depth) { format!( "No files match @{partial} (mention_walk_depth={walk_depth}; use /config set mention_walk_depth 0 to search deeper)" ) @@ -310,6 +310,17 @@ fn no_file_mention_matches_status(partial: &str, walk_depth: usize) -> String { } } +fn path_partial_reaches_walk_depth(partial: &str, walk_depth: usize) -> bool { + if walk_depth == 0 { + return false; + } + let component_count = partial + .split(['/', '\\']) + .filter(|component| !component.is_empty()) + .count(); + component_count >= walk_depth +} + /// Splice a completion into the input, replacing the `@` token at /// `byte_start` with `@`. Cursor moves to the end of the new /// token so further keystrokes extend (or escape via space) naturally. diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 4f24373c1..ffc4e525b 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4748,6 +4748,40 @@ fn try_autocomplete_file_mention_no_match_mentions_depth_cap_for_path_like_parti ); } +#[test] +fn try_autocomplete_file_mention_no_match_skips_depth_hint_for_shallow_path() { + let tmpdir = TempDir::new().expect("tempdir"); + + let mut app = create_test_app(); + app.workspace = tmpdir.path().to_path_buf(); + app.mention_walk_depth = 6; + app.input = "@shallow_missing/main.rs".to_string(); + app.cursor_position = app.input.chars().count(); + + assert!(try_autocomplete_file_mention(&mut app)); + assert_eq!( + app.status_message.as_deref(), + Some("No files match @shallow_missing/main.rs") + ); +} + +#[test] +fn try_autocomplete_file_mention_no_match_skips_depth_hint_when_unlimited() { + let tmpdir = TempDir::new().expect("tempdir"); + + let mut app = create_test_app(); + app.workspace = tmpdir.path().to_path_buf(); + app.mention_walk_depth = 0; + app.input = "@a/b/c/d/e/f/g/target".to_string(); + app.cursor_position = app.input.chars().count(); + + assert!(try_autocomplete_file_mention(&mut app)); + assert_eq!( + app.status_message.as_deref(), + Some("No files match @a/b/c/d/e/f/g/target") + ); +} + #[test] fn try_autocomplete_file_mention_returns_false_outside_mention() { let mut app = create_test_app(); From e2201b87ddd1674c8da485bc8169d8b3e7771f82 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 04:44:57 +0800 Subject: [PATCH 30/98] fix(tui): read Wayland clipboard via wl-paste --- crates/tui/src/tui/clipboard.rs | 52 +++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tui/clipboard.rs b/crates/tui/src/tui/clipboard.rs index d0f839340..82b4a2417 100644 --- a/crates/tui/src/tui/clipboard.rs +++ b/crates/tui/src/tui/clipboard.rs @@ -10,9 +10,12 @@ #[cfg(not(test))] use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; -#[cfg(all( - any(target_os = "macos", target_os = "windows", target_os = "linux"), - not(test) +#[cfg(any( + all(test, unix), + all( + any(target_os = "macos", target_os = "windows", target_os = "linux"), + not(test) + ) ))] use std::process::{Command, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -107,6 +110,11 @@ impl ClipboardHandler { /// `workspace` is used as a fallback location when `~/.codewhale/` cannot /// be resolved (e.g. running with a stripped HOME in CI sandboxes). pub fn read(&mut self, workspace: &Path) -> Option { + #[cfg(all(target_os = "linux", not(test)))] + if let Ok(text) = read_text_with_wlpaste() { + return Some(ClipboardContent::Text(text)); + } + self.ensure_clipboard(); let clipboard = self.clipboard.as_mut()?; if let Ok(text) = clipboard.get_text() { @@ -212,6 +220,26 @@ fn write_text_with_wlcopy(text: &str) -> Result<()> { write_text_with_wlcopy_using_argv("wl-copy", text) } +#[cfg(all(target_os = "linux", not(test)))] +fn read_text_with_wlpaste() -> Result { + read_text_with_wlpaste_using_argv("wl-paste") +} + +#[cfg(any(all(test, unix), target_os = "linux"))] +fn read_text_with_wlpaste_using_argv(program: &str) -> Result { + let output = Command::new(program) + .arg("--type") + .arg("text/plain") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .map_err(|e| anyhow::anyhow!("Failed to run {program}: {e}"))?; + if !output.status.success() { + bail!("{program} exited with {}", output.status); + } + String::from_utf8(output.stdout).context("wl-paste returned non-UTF-8 text") +} + #[cfg(all(target_os = "linux", not(test)))] fn write_text_with_wlcopy_using_argv(program: &str, text: &str) -> Result<()> { let mut child = Command::new(program) @@ -332,6 +360,8 @@ fn save_image_as_png_in(dir: &Path, image: &ImageData) -> Result { mod tests { use super::*; use std::borrow::Cow; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; fn solid_rgba(width: u16, height: u16, rgba: [u8; 4]) -> ImageData<'static> { let mut bytes = Vec::with_capacity((width as usize) * (height as usize) * 4); @@ -419,4 +449,20 @@ mod tests { "unexpected error: {err}" ); } + + #[cfg(unix)] + #[test] + fn wl_paste_helper_reads_text_from_stdout() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("wl-paste"); + std::fs::write(&script, "#!/bin/sh\nprintf 'from-wayland'\n").unwrap(); + let mut perms = std::fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).unwrap(); + + let text = read_text_with_wlpaste_using_argv(script.to_str().unwrap()) + .expect("read text through wl-paste helper"); + + assert_eq!(text, "from-wayland"); + } } From 9f33c4d59431bcbfc73c3cbe955908c32558c4d9 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 1 Jun 2026 16:01:05 -0700 Subject: [PATCH 31/98] fix(tui): suppress wl-paste trailing newline Refs #1920 Harvested from PR #2540 by @cyq1017 --- crates/tui/src/tui/clipboard.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/clipboard.rs b/crates/tui/src/tui/clipboard.rs index 82b4a2417..ac63e7093 100644 --- a/crates/tui/src/tui/clipboard.rs +++ b/crates/tui/src/tui/clipboard.rs @@ -228,6 +228,7 @@ fn read_text_with_wlpaste() -> Result { #[cfg(any(all(test, unix), target_os = "linux"))] fn read_text_with_wlpaste_using_argv(program: &str) -> Result { let output = Command::new(program) + .arg("--no-newline") .arg("--type") .arg("text/plain") .stdout(Stdio::piped()) @@ -455,7 +456,30 @@ mod tests { fn wl_paste_helper_reads_text_from_stdout() { let dir = tempfile::tempdir().unwrap(); let script = dir.path().join("wl-paste"); - std::fs::write(&script, "#!/bin/sh\nprintf 'from-wayland'\n").unwrap(); + std::fs::write( + &script, + r#"#!/bin/sh +seen_no_newline=0 +seen_text_plain=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --no-newline) seen_no_newline=1 ;; + --type) + shift + [ "${1:-}" = "text/plain" ] && seen_text_plain=1 + ;; + esac + shift +done +[ "$seen_text_plain" -eq 1 ] || exit 40 +if [ "$seen_no_newline" -eq 1 ]; then + printf 'from-wayland' +else + printf 'from-wayland\n' +fi +"#, + ) + .unwrap(); let mut perms = std::fs::metadata(&script).unwrap().permissions(); perms.set_mode(0o755); std::fs::set_permissions(&script, perms).unwrap(); From 1605d8de449e34d96a1adf42befd6defd103d221 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 00:39:12 +0800 Subject: [PATCH 32/98] fix(sandbox): allow tty device in seatbelt profile Refs #2372 Harvested from PR #2524 by @cyq1017 --- crates/tui/src/sandbox/seatbelt.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/tui/src/sandbox/seatbelt.rs b/crates/tui/src/sandbox/seatbelt.rs index a31607b25..199eab64f 100644 --- a/crates/tui/src/sandbox/seatbelt.rs +++ b/crates/tui/src/sandbox/seatbelt.rs @@ -69,6 +69,7 @@ const SEATBELT_BASE_POLICY: &str = r#" ; Terminal support (essential for shell commands) (allow pseudo-tty) (allow file-read* file-write* file-ioctl (literal "/dev/ptmx")) +(allow file-read* file-write* file-ioctl (literal "/dev/tty")) (allow file-read* file-write* file-ioctl (regex #"^/dev/ttys[0-9]+$")) ; macOS-specific device access @@ -651,6 +652,19 @@ mod tests { } } + #[test] + fn test_generate_policy_allows_dev_tty() { + let policy = SandboxPolicy::default(); + let cwd = Path::new("/tmp/test"); + let policy_text = generate_policy(&policy, cwd); + + assert!( + policy_text + .contains(r#"(allow file-read* file-write* file-ioctl (literal "/dev/tty"))"#), + "TTY-mode shells need /dev/tty access for sshpass/sudo prompts" + ); + } + #[test] fn test_create_seatbelt_args() { let policy = SandboxPolicy::default(); From 46de1a9b2d2bc577feb0fe4296862c89a7051bb7 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 02:54:32 +0800 Subject: [PATCH 33/98] fix(tui): refresh prompt on model switch --- crates/tui/src/core/engine.rs | 4 ++- crates/tui/src/core/engine/tests.rs | 55 +++++++++++++++++++++++++++++ crates/tui/src/core/ops.rs | 4 +-- crates/tui/src/tui/ui.rs | 13 +++++-- crates/tui/src/tui/ui/tests.rs | 5 +-- 5 files changed, 73 insertions(+), 8 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 2f920b3fc..e5e4c6122 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -789,10 +789,12 @@ impl Engine { .send(Event::status(format!("Mode changed to: {mode:?}"))) .await; } - Op::SetModel { model } => { + Op::SetModel { model, mode } => { self.session.auto_model = model.trim().eq_ignore_ascii_case("auto"); self.session.model = model; self.config.model.clone_from(&self.session.model); + self.refresh_system_prompt(mode); + self.emit_session_updated().await; let _ = self .tx_event .send(Event::status(format!( diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 783f31283..d12a2af4d 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1238,6 +1238,61 @@ async fn session_update_preserves_reasoning_tool_only_turn() { assert_eq!(messages, vec![assistant]); } +#[tokio::test] +async fn set_model_reloads_instruction_sources_and_updates_session_prompt() { + let tmp = tempdir().expect("tempdir"); + let instructions = tmp.path().join("instructions.md"); + fs::write(&instructions, "FLASH_INSTRUCTIONS_MARKER").expect("write instructions"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + model: "deepseek-v4-flash".to_string(), + instructions: vec![instructions.clone().into()], + ..Default::default() + }; + let (engine, handle) = Engine::new(config, &Config::default()); + fs::write(&instructions, "PRO_INSTRUCTIONS_MARKER").expect("rewrite instructions"); + + let run = tokio::spawn(engine.run()); + handle + .send(Op::SetModel { + model: "deepseek-v4-pro".to_string(), + mode: AppMode::Agent, + }) + .await + .expect("send set model"); + + let (model, prompt) = { + let mut rx = handle.rx_event.write().await; + loop { + let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + .await + .expect("session update after model switch") + .expect("event"); + if let Event::SessionUpdated { + model, + system_prompt, + .. + } = event + { + let prompt = match system_prompt.expect("system prompt") { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(blocks) => blocks + .into_iter() + .map(|block| block.text) + .collect::>() + .join("\n"), + }; + break (model, prompt); + } + } + }; + run.abort(); + + assert_eq!(model, "deepseek-v4-pro"); + assert!(prompt.contains("PRO_INSTRUCTIONS_MARKER")); + assert!(!prompt.contains("FLASH_INSTRUCTIONS_MARKER")); +} + #[test] fn detects_context_length_errors_from_provider_payloads() { let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#; diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index df6f0aa0d..ab61659e4 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -63,9 +63,9 @@ pub enum Op { #[allow(dead_code)] ChangeMode { mode: AppMode }, - /// Update the model being used + /// Update the model being used and refresh the prompt for the current mode. #[allow(dead_code)] - SetModel { model: String }, + SetModel { model: String, mode: AppMode }, /// Update auto-compaction settings SetCompaction { config: CompactionConfig }, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 7d08f9e96..50c4b0b03 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3437,6 +3437,7 @@ async fn run_event_loop( let _ = engine_handle .send(Op::SetModel { model: app.model.clone(), + mode: app.mode, }) .await; } @@ -4748,10 +4749,12 @@ async fn dispatch_user_message( async fn apply_model_and_compaction_update( engine_handle: &EngineHandle, compaction: crate::compaction::CompactionConfig, + mode: AppMode, ) { let _ = engine_handle .send(Op::SetModel { model: compaction.model.clone(), + mode, }) .await; let _ = engine_handle @@ -4779,6 +4782,7 @@ async fn drain_web_config_events( apply_model_and_compaction_update( engine_handle, app.compaction_config(), + app.mode, ) .await; } @@ -4803,6 +4807,7 @@ async fn drain_web_config_events( apply_model_and_compaction_update( engine_handle, app.compaction_config(), + app.mode, ) .await; } @@ -4888,7 +4893,7 @@ async fn apply_model_picker_choice( } if model_changed { - apply_model_and_compaction_update(engine_handle, app.compaction_config()).await; + apply_model_and_compaction_update(engine_handle, app.compaction_config(), app.mode).await; } let model_summary = if model_is_auto { @@ -5237,7 +5242,7 @@ async fn apply_command_result( } } AppAction::UpdateCompaction(compaction) => { - apply_model_and_compaction_update(engine_handle, compaction).await; + apply_model_and_compaction_update(engine_handle, compaction, app.mode).await; } AppAction::OpenConfigEditor(mode) => match mode { ConfigUiMode::Native => { @@ -5267,6 +5272,7 @@ async fn apply_command_result( apply_model_and_compaction_update( engine_handle, app.compaction_config(), + app.mode, ) .await; } @@ -6743,7 +6749,8 @@ async fn handle_view_events( if let Some(action) = result.action { match action { AppAction::UpdateCompaction(compaction) => { - apply_model_and_compaction_update(engine_handle, compaction).await; + apply_model_and_compaction_update(engine_handle, compaction, app.mode) + .await; } AppAction::OpenConfigView => {} _ => {} diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index ffc4e525b..a03c92582 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2021,11 +2021,12 @@ async fn model_change_update_syncs_engine_model_before_compaction() { let compaction = app.compaction_config(); let mut engine = crate::core::engine::mock_engine_handle(); - apply_model_and_compaction_update(&engine.handle, compaction).await; + apply_model_and_compaction_update(&engine.handle, compaction, app.mode).await; match engine.rx_op.recv().await.expect("set model op") { - crate::core::ops::Op::SetModel { model } => { + crate::core::ops::Op::SetModel { model, mode } => { assert_eq!(model, "deepseek-v4-flash"); + assert_eq!(mode, app.mode); } other => panic!("expected SetModel, got {other:?}"), } From bc7f98a6a00cd563e7f7fcaf6bcde3b248bbb1ae Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 1 Jun 2026 16:14:59 -0700 Subject: [PATCH 34/98] fix(tui): refresh prompt on mode changes Refs #2379 Harvested from PR #2534 by @cyq1017 --- crates/tui/src/commands/config.rs | 30 ++++++++++++++--- crates/tui/src/core/engine.rs | 2 ++ crates/tui/src/core/engine/tests.rs | 43 +++++++++++++++++++++++++ crates/tui/src/tui/app.rs | 2 ++ crates/tui/src/tui/ui.rs | 50 +++++++++++++++++++++-------- crates/tui/src/tui/ui/tests.rs | 16 +++++++++ 6 files changed, 125 insertions(+), 18 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index fad755e1a..24d4e4910 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -725,16 +725,33 @@ pub fn mode(app: &mut App, arg: Option<&str>) -> CommandResult { return CommandResult::action(AppAction::OpenModePicker); }; match parse_mode_arg(arg) { - Some(mode) => CommandResult::message(switch_mode(app, mode)), + Some(mode) => { + let (message, changed) = switch_mode_with_status(app, mode); + if changed { + CommandResult::with_message_and_action(message, AppAction::ModeChanged(mode)) + } else { + CommandResult::message(message) + } + } None => CommandResult::error("Usage: /mode [agent|plan|yolo|1|2|3]"), } } pub fn switch_mode(app: &mut App, mode: AppMode) -> String { + switch_mode_with_status(app, mode).0 +} + +fn switch_mode_with_status(app: &mut App, mode: AppMode) -> (String, bool) { if app.set_mode(mode) { - format!("Switched to {} mode.", mode_display_name(mode)) + ( + format!("Switched to {} mode.", mode_display_name(mode)), + true, + ) } else { - format!("Already in {} mode.", mode_display_name(mode)) + ( + format!("Already in {} mode.", mode_display_name(mode)), + false, + ) } } @@ -1499,6 +1516,7 @@ mod tests { let _ = mode(&mut app, Some("agent")); let result = mode(&mut app, Some("yolo")); assert!(result.message.unwrap().contains("Switched to YOLO mode")); + assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Yolo))); assert!(app.allow_shell); assert!(app.trust_mode); assert!(app.yolo); @@ -1511,9 +1529,11 @@ mod tests { let mut app = create_test_app(); let _ = mode(&mut app, Some("agent")); assert_eq!(app.mode, AppMode::Agent); - let _ = mode(&mut app, Some("2")); + let result = mode(&mut app, Some("2")); + assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Plan))); assert_eq!(app.mode, AppMode::Plan); - let _ = mode(&mut app, Some("3")); + let result = mode(&mut app, Some("3")); + assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Yolo))); assert_eq!(app.mode, AppMode::Yolo); } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index e5e4c6122..9483315a7 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -784,6 +784,8 @@ impl Engine { let _ = self.tx_event.send(Event::AgentList { agents }).await; } Op::ChangeMode { mode } => { + self.refresh_system_prompt(mode); + self.emit_session_updated().await; let _ = self .tx_event .send(Event::status(format!("Mode changed to: {mode:?}"))) diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index d12a2af4d..ecd1d239c 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1293,6 +1293,49 @@ async fn set_model_reloads_instruction_sources_and_updates_session_prompt() { assert!(!prompt.contains("FLASH_INSTRUCTIONS_MARKER")); } +#[tokio::test] +async fn change_mode_refreshes_session_prompt_and_updates_session() { + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + model: "deepseek-v4-pro".to_string(), + ..Default::default() + }; + let (engine, handle) = Engine::new(config, &Config::default()); + + let run = tokio::spawn(engine.run()); + handle + .send(Op::ChangeMode { + mode: AppMode::Yolo, + }) + .await + .expect("send change mode"); + + let prompt = { + let mut rx = handle.rx_event.write().await; + loop { + let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + .await + .expect("session update after mode switch") + .expect("event"); + if let Event::SessionUpdated { system_prompt, .. } = event { + break match system_prompt.expect("system prompt") { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(blocks) => blocks + .into_iter() + .map(|block| block.text) + .collect::>() + .join("\n"), + }; + } + } + }; + run.abort(); + + assert!(prompt.contains("Mode: YOLO")); + assert!(prompt.contains("Approval Policy: Auto")); +} + #[test] fn detects_context_length_errors_from_provider_payloads() { let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#; diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index e0d2eb4b3..d907e7d72 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -4803,6 +4803,8 @@ pub enum AppAction { OpenProviderPicker, /// Open the `/mode` picker modal for Agent / Plan / YOLO. OpenModePicker, + /// Refresh the engine prompt after the UI operating mode changes. + ModeChanged(AppMode), /// Open the `/statusline` multi-select picker for footer items. OpenStatusPicker, /// Open the `/feedback` picker for GitHub issue/security destinations. diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 50c4b0b03..3421e65e8 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3132,7 +3132,7 @@ async fn run_event_loop( app.set_sidebar_focus(SidebarFocus::Work); app.status_message = Some("Sidebar focus: work".to_string()); } else { - app.set_mode(AppMode::Plan); + apply_mode_update(app, &engine_handle, AppMode::Plan).await; } continue; } @@ -3141,7 +3141,7 @@ async fn run_event_loop( app.set_sidebar_focus(SidebarFocus::Tasks); app.status_message = Some("Sidebar focus: tasks".to_string()); } else { - app.set_mode(AppMode::Agent); + apply_mode_update(app, &engine_handle, AppMode::Agent).await; } continue; } @@ -3150,7 +3150,7 @@ async fn run_event_loop( app.set_sidebar_focus(SidebarFocus::Agents); app.status_message = Some("Sidebar focus: agents".to_string()); } else { - app.set_mode(AppMode::Yolo); + apply_mode_update(app, &engine_handle, AppMode::Yolo).await; } continue; } @@ -3432,7 +3432,11 @@ async fn run_event_loop( continue; } let prior_model = app.model.clone(); + let prior_mode = app.mode; app.cycle_mode(); + if app.mode != prior_mode { + sync_mode_update(&engine_handle, app.mode).await; + } if app.model != prior_model { let _ = engine_handle .send(Op::SetModel { @@ -3899,34 +3903,34 @@ async fn run_event_loop( AppMode::Agent => AppMode::Yolo, AppMode::Yolo => AppMode::Plan, }; - app.set_mode(new_mode); + apply_mode_update(app, &engine_handle, new_mode).await; } } _ if key_shortcuts::is_paste_shortcut(&key) => { app.paste_from_clipboard(); } KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Agent); + apply_mode_update(app, &engine_handle, AppMode::Agent).await; continue; } KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Yolo); + apply_mode_update(app, &engine_handle, AppMode::Yolo).await; continue; } KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Plan); + apply_mode_update(app, &engine_handle, AppMode::Plan).await; continue; } KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Agent); + apply_mode_update(app, &engine_handle, AppMode::Agent).await; continue; } KeyCode::Char('Y') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Yolo); + apply_mode_update(app, &engine_handle, AppMode::Yolo).await; continue; } KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Plan); + apply_mode_update(app, &engine_handle, AppMode::Plan).await; continue; } KeyCode::Char('v') | KeyCode::Char('V') @@ -4746,6 +4750,19 @@ async fn dispatch_user_message( Ok(()) } +async fn sync_mode_update(engine_handle: &EngineHandle, mode: AppMode) { + let _ = engine_handle.send(Op::ChangeMode { mode }).await; +} + +async fn apply_mode_update(app: &mut App, engine_handle: &EngineHandle, mode: AppMode) -> bool { + if app.set_mode(mode) { + sync_mode_update(engine_handle, mode).await; + true + } else { + false + } +} + async fn apply_model_and_compaction_update( engine_handle: &EngineHandle, compaction: crate::compaction::CompactionConfig, @@ -5137,6 +5154,9 @@ async fn apply_command_result( persistence_actor::persist(PersistRequest::ClearCheckpoint); } } + AppAction::ModeChanged(mode) => { + sync_mode_update(engine_handle, mode).await; + } AppAction::SendMessage(content) => { let queued = build_queued_message(app, content); submit_or_steer_message(app, config, engine_handle, queued).await?; @@ -6014,7 +6034,7 @@ async fn apply_plan_choice( ) -> Result<()> { match choice { PlanChoice::AcceptAgent => { - app.set_mode(AppMode::Agent); + apply_mode_update(app, engine_handle, AppMode::Agent).await; app.add_message(HistoryCell::System { content: "Plan accepted. Switching to Agent mode and starting implementation." .to_string(), @@ -6029,7 +6049,7 @@ async fn apply_plan_choice( } } PlanChoice::AcceptYolo => { - app.set_mode(AppMode::Yolo); + apply_mode_update(app, engine_handle, AppMode::Yolo).await; app.add_message(HistoryCell::System { content: "Plan accepted. Switching to YOLO mode and starting implementation." .to_string(), @@ -6050,7 +6070,7 @@ async fn apply_plan_choice( app.status_message = Some("Revise the plan and press Enter.".to_string()); } PlanChoice::ExitPlan => { - app.set_mode(AppMode::Agent); + apply_mode_update(app, engine_handle, AppMode::Agent).await; app.add_message(HistoryCell::System { content: "Exited Plan mode. Switched to Agent mode.".to_string(), }); @@ -6840,7 +6860,11 @@ async fn handle_view_events( .await; } ViewEvent::ModeSelected { mode } => { + let prior_mode = app.mode; let msg = commands::switch_mode(app, mode); + if app.mode != prior_mode { + sync_mode_update(engine_handle, app.mode).await; + } app.add_message(HistoryCell::System { content: msg }); } ViewEvent::BacktrackStep { direction } => { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index a03c92582..6ea4de95f 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2039,6 +2039,22 @@ async fn model_change_update_syncs_engine_model_before_compaction() { } } +#[tokio::test] +async fn mode_change_update_notifies_engine() { + let mut app = create_test_app(); + let _ = app.set_mode(crate::tui::app::AppMode::Plan); + let mut engine = crate::core::engine::mock_engine_handle(); + + assert!(apply_mode_update(&mut app, &engine.handle, crate::tui::app::AppMode::Yolo).await); + + match engine.rx_op.recv().await.expect("change mode op") { + crate::core::ops::Op::ChangeMode { mode } => { + assert_eq!(mode, crate::tui::app::AppMode::Yolo); + } + other => panic!("expected ChangeMode, got {other:?}"), + } +} + #[test] fn saved_default_provider_syncs_back_to_runtime_config() { let _home = SettingsHomeGuard::new(); From 3b5727f283ff72fd781d68bc8b5673852a7a6be2 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Mon, 1 Jun 2026 18:55:15 +0800 Subject: [PATCH 35/98] fix(tui): prefer codewhale settings path --- crates/tui/src/settings.rs | 127 +++++++++++++++++++++++++++-- crates/tui/src/tui/app.rs | 6 +- crates/tui/src/tui/model_picker.rs | 3 +- docs/ACCESSIBILITY.md | 4 +- docs/CONFIGURATION.md | 4 +- 5 files changed, 129 insertions(+), 15 deletions(-) diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index d33aab644..e9af23598 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -1,9 +1,9 @@ //! Settings system - Persistent user preferences //! -//! Settings are stored at ~/.config/deepseek/settings.toml +//! Settings are stored at ~/.codewhale/settings.toml, with legacy fallbacks. //! //! TUI-specific preferences (theme, keybinds, font_size) that survive project -//! switches are stored separately at ~/.deepseek/tui.toml. See [`TuiPrefs`]. +//! switches are stored separately in tui.toml. See [`TuiPrefs`]. use std::path::PathBuf; @@ -14,6 +14,8 @@ use crate::config::{expand_path, normalize_model_name}; use crate::localization::normalize_configured_locale; use crate::palette::{normalize_hex_rgb_color, normalize_theme_name}; +const SETTINGS_FILE_NAME: &str = "settings.toml"; + // ============================================================================ // TuiPrefs — ~/.deepseek/tui.toml // ============================================================================ @@ -347,15 +349,38 @@ impl Settings { if !config_path.is_empty() { let p = expand_path(config_path); if let Some(parent) = p.parent() { - return Ok(parent.join("settings.toml")); + return Ok(parent.join(SETTINGS_FILE_NAME)); } } } - let config_dir = dirs::config_dir() + let primary = codewhale_config::codewhale_home() + .ok() + .map(|home| home.join(SETTINGS_FILE_NAME)); + if let Some(path) = primary.as_ref() + && path.exists() + { + return Ok(path.clone()); + } + + let legacy_home = codewhale_config::legacy_deepseek_home() + .ok() + .map(|home| home.join(SETTINGS_FILE_NAME)); + if let Some(path) = legacy_home + && path.exists() + { + return Ok(path); + } + + let legacy_config_dir = dirs::config_dir() .context("Failed to resolve config directory: not found.")? - .join("deepseek"); - Ok(config_dir.join("settings.toml")) + .join("deepseek") + .join(SETTINGS_FILE_NAME); + if legacy_config_dir.exists() { + return Ok(legacy_config_dir); + } + + Ok(primary.unwrap_or(legacy_config_dir)) } /// Load settings from disk, or return defaults if not found @@ -471,8 +496,8 @@ impl Settings { // // Only flip `auto` to `off`; respect an explicit `"on"` so users // who upgrade Ptyxis or want to confirm the fix landed upstream - // can override the heuristic from `~/.config/deepseek/settings.toml` - // or `/set synchronized_output on`. + // can override the heuristic from the persisted settings.toml or + // `/set synchronized_output on`. if self.synchronized_output.eq_ignore_ascii_case("auto") && detected_ptyxis_terminal() { self.synchronized_output = "off".to_string(); } @@ -2135,6 +2160,92 @@ mod tests { crate::test_support::lock_test_env() } + struct EnvVarRestore { + key: &'static str, + previous: Option, + } + + impl EnvVarRestore { + fn set(key: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(key); + // SAFETY: tests using this helper hold config_path_test_guard. + unsafe { + std::env::set_var(key, value); + } + Self { key, previous } + } + + fn remove(key: &'static str) -> Self { + let previous = std::env::var_os(key); + // SAFETY: tests using this helper hold config_path_test_guard. + unsafe { + std::env::remove_var(key); + } + Self { key, previous } + } + } + + impl Drop for EnvVarRestore { + fn drop(&mut self) { + // SAFETY: tests using this helper hold config_path_test_guard. + unsafe { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + } + + #[test] + fn settings_path_defaults_to_codewhale_home_for_new_writes() { + let _g = config_path_test_guard(); + let tmp = tempfile::tempdir().expect("tempdir"); + let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH"); + let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale")); + let _home = EnvVarRestore::set("HOME", tmp.path()); + + let got = Settings::path().expect("settings path"); + + assert_eq!(got, tmp.path().join(".codewhale").join("settings.toml")); + } + + #[test] + fn settings_path_reads_legacy_deepseek_home_when_present() { + let _g = config_path_test_guard(); + let tmp = tempfile::tempdir().expect("tempdir"); + let legacy_dir = tmp.path().join(".deepseek"); + std::fs::create_dir_all(&legacy_dir).expect("legacy dir"); + std::fs::write(legacy_dir.join("settings.toml"), "low_motion = true\n") + .expect("legacy settings"); + let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH"); + let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale")); + let _home = EnvVarRestore::set("HOME", tmp.path()); + + let got = Settings::path().expect("settings path"); + + assert_eq!(got, legacy_dir.join("settings.toml")); + } + + #[test] + fn settings_path_keeps_platform_config_dir_as_last_legacy_fallback() { + let _g = config_path_test_guard(); + let tmp = tempfile::tempdir().expect("tempdir"); + let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH"); + let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale")); + let _home = EnvVarRestore::set("HOME", tmp.path()); + + let config_dir = dirs::config_dir().expect("config dir"); + let legacy_settings = config_dir.join("deepseek").join("settings.toml"); + std::fs::create_dir_all(legacy_settings.parent().expect("parent")) + .expect("legacy config dir"); + std::fs::write(&legacy_settings, "low_motion = true\n").expect("legacy settings"); + + let got = Settings::path().expect("settings path"); + + assert_eq!(got, legacy_settings); + } + #[test] fn tui_prefs_defaults_are_dark_theme_zero_font() { let prefs = TuiPrefs::default(); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index d907e7d72..8ee44f610 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -50,7 +50,7 @@ pub enum OnboardingState { Welcome, /// Pick the UI locale before any other config decisions (#566). /// Defaults to auto-detection from `LC_ALL` / `LANG`; explicit picks - /// land in `~/.deepseek/settings.toml` via `Settings::set("locale", …)`. + /// land in the persisted settings.toml via `Settings::set("locale", …)`. Language, ApiKey, TrustDirectory, @@ -2156,7 +2156,7 @@ impl App { } /// Apply a locale tag selected from the onboarding language picker (#566). - /// Persists the value to `~/.deepseek/settings.toml` and immediately + /// Persists the value to settings.toml and immediately /// re-resolves `ui_locale` so the rest of onboarding renders in the new /// language. `App` doesn't keep `Settings` resident — it loads on entry /// and rewrites on exit, mirroring the pattern used by the `/config` @@ -2170,7 +2170,7 @@ impl App { Ok(()) } - /// Locale tag currently persisted in `~/.deepseek/settings.toml` (or + /// Locale tag currently persisted in settings.toml (or /// `"auto"` when no settings file exists). Used by the onboarding /// language picker to highlight the current selection without `App` /// having to keep `Settings` resident. diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index a6cc22f98..7d1af66b7 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -543,8 +543,7 @@ mod tests { initial_input: None, }; let mut app = App::new(options, &Config::default()); - // App::new merges in `~/.config/deepseek/settings.toml` / - // `Application Support/deepseek/settings.toml`, which can override + // App::new merges in the user's persisted settings.toml, which can override // the model, effort, and provider with whatever the developer // happens to have saved. Pin all three back to known values so // the picker tests below exercise the picker logic, not the diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index cdb2382d3..036abd7f8 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -46,7 +46,9 @@ The same toggles are reachable from the command palette: * `/settings set calm_mode on` * `/settings set status_indicator off` -Settings written this way persist to `~/.config/deepseek/settings.toml`. +Settings written this way persist to `~/.codewhale/settings.toml` on new +installs, with legacy `~/.deepseek/settings.toml` and platform config-dir +settings kept as compatibility fallbacks. The `NO_ANIMATIONS` env var still wins at startup if it's set, so unsetting the env var is the way to honor your saved choice. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e1698f2a5..e8463569f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -497,7 +497,9 @@ round-trip intact. codewhale also stores user preferences in: -- `~/.config/deepseek/settings.toml` +- `~/.codewhale/settings.toml` on new installs +- `~/.deepseek/settings.toml` or the legacy platform config-dir + `deepseek/settings.toml` when an existing settings file is present Notable settings include `auto_compact` (default `false`), which opts into replacement-style summarization before the active model limit. The trigger From 0eb2ff59aea3949aa321d21e7eabfd02e2643cf1 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 00:07:04 +0800 Subject: [PATCH 36/98] fix(tui): isolate settings path fallback tests --- crates/tui/src/settings.rs | 115 +++++++++++++++++++++++++------------ 1 file changed, 78 insertions(+), 37 deletions(-) diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index e9af23598..561441b87 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -357,30 +357,13 @@ impl Settings { let primary = codewhale_config::codewhale_home() .ok() .map(|home| home.join(SETTINGS_FILE_NAME)); - if let Some(path) = primary.as_ref() - && path.exists() - { - return Ok(path.clone()); - } - let legacy_home = codewhale_config::legacy_deepseek_home() .ok() .map(|home| home.join(SETTINGS_FILE_NAME)); - if let Some(path) = legacy_home - && path.exists() - { - return Ok(path); - } + let legacy_config_dir = + dirs::config_dir().map(|dir| dir.join("deepseek").join(SETTINGS_FILE_NAME)); - let legacy_config_dir = dirs::config_dir() - .context("Failed to resolve config directory: not found.")? - .join("deepseek") - .join(SETTINGS_FILE_NAME); - if legacy_config_dir.exists() { - return Ok(legacy_config_dir); - } - - Ok(primary.unwrap_or(legacy_config_dir)) + resolve_settings_path_from_candidates(primary, legacy_home, legacy_config_dir) } /// Load settings from disk, or return defaults if not found @@ -918,6 +901,34 @@ impl Settings { } } +fn resolve_settings_path_from_candidates( + primary: Option, + legacy_home: Option, + legacy_config_dir: Option, +) -> Result { + if let Some(path) = primary.as_ref() + && path.exists() + { + return Ok(path.clone()); + } + + if let Some(path) = legacy_home + && path.exists() + { + return Ok(path); + } + + if let Some(path) = legacy_config_dir.as_ref() + && path.exists() + { + return Ok(path.clone()); + } + + primary.or(legacy_config_dir).ok_or_else(|| { + anyhow::anyhow!("Failed to resolve settings path: no config directory found.") + }) +} + fn normalize_default_model(value: &str) -> Option { let trimmed = value.trim(); if trimmed.eq_ignore_ascii_case("auto") { @@ -2214,36 +2225,66 @@ mod tests { fn settings_path_reads_legacy_deepseek_home_when_present() { let _g = config_path_test_guard(); let tmp = tempfile::tempdir().expect("tempdir"); + let primary = tmp.path().join(".codewhale").join("settings.toml"); let legacy_dir = tmp.path().join(".deepseek"); std::fs::create_dir_all(&legacy_dir).expect("legacy dir"); - std::fs::write(legacy_dir.join("settings.toml"), "low_motion = true\n") - .expect("legacy settings"); - let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH"); - let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale")); - let _home = EnvVarRestore::set("HOME", tmp.path()); + let legacy_home = legacy_dir.join("settings.toml"); + std::fs::write(&legacy_home, "low_motion = true\n").expect("legacy settings"); + let legacy_config_dir = tmp + .path() + .join("platform-config") + .join("deepseek") + .join("settings.toml"); + std::fs::create_dir_all(legacy_config_dir.parent().expect("parent")) + .expect("legacy config dir"); + std::fs::write(&legacy_config_dir, "low_motion = false\n") + .expect("platform legacy settings"); - let got = Settings::path().expect("settings path"); + let got = resolve_settings_path_from_candidates( + Some(primary), + Some(legacy_home.clone()), + Some(legacy_config_dir), + ) + .expect("settings path"); - assert_eq!(got, legacy_dir.join("settings.toml")); + assert_eq!(got, legacy_home); } #[test] fn settings_path_keeps_platform_config_dir_as_last_legacy_fallback() { let _g = config_path_test_guard(); let tmp = tempfile::tempdir().expect("tempdir"); - let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH"); - let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale")); - let _home = EnvVarRestore::set("HOME", tmp.path()); - - let config_dir = dirs::config_dir().expect("config dir"); - let legacy_settings = config_dir.join("deepseek").join("settings.toml"); - std::fs::create_dir_all(legacy_settings.parent().expect("parent")) + let primary = tmp.path().join(".codewhale").join("settings.toml"); + let legacy_home = tmp.path().join(".deepseek").join("settings.toml"); + let legacy_config_dir = tmp + .path() + .join("platform-config") + .join("deepseek") + .join("settings.toml"); + std::fs::create_dir_all(legacy_config_dir.parent().expect("parent")) .expect("legacy config dir"); - std::fs::write(&legacy_settings, "low_motion = true\n").expect("legacy settings"); + std::fs::write(&legacy_config_dir, "low_motion = true\n").expect("legacy settings"); - let got = Settings::path().expect("settings path"); + let got = resolve_settings_path_from_candidates( + Some(primary), + Some(legacy_home), + Some(legacy_config_dir.clone()), + ) + .expect("settings path"); + + assert_eq!(got, legacy_config_dir); + } + + #[test] + fn settings_path_uses_primary_when_platform_config_dir_is_unavailable() { + let _g = config_path_test_guard(); + let tmp = tempfile::tempdir().expect("tempdir"); + let primary = tmp.path().join(".codewhale").join("settings.toml"); + + let got = resolve_settings_path_from_candidates(Some(primary.clone()), None, None) + .expect("settings path"); - assert_eq!(got, legacy_settings); + assert_eq!(got, primary); } #[test] From 91c5bb64bd045461fa39c67de4ef25b08e1e2f66 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 1 Jun 2026 16:22:40 -0700 Subject: [PATCH 37/98] fix(tui): keep tui prefs under codewhale home Refs #2369 Harvested from PR #2516 by @cyq1017 --- crates/tui/src/settings.rs | 107 +++++++++++++++++++++++++++---------- 1 file changed, 79 insertions(+), 28 deletions(-) diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 561441b87..8e82757d3 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -15,19 +15,22 @@ use crate::localization::normalize_configured_locale; use crate::palette::{normalize_hex_rgb_color, normalize_theme_name}; const SETTINGS_FILE_NAME: &str = "settings.toml"; +const TUI_PREFS_FILE_NAME: &str = "tui.toml"; // ============================================================================ -// TuiPrefs — ~/.deepseek/tui.toml +// TuiPrefs — ~/.codewhale/tui.toml // ============================================================================ /// TUI-specific preferences that are decoupled from agent/project config so /// they survive project switches (issue #437). /// -/// Stored at `~/.deepseek/tui.toml`. When the file is absent the values fall -/// back to the `[tui]` section of the normal `config.toml` (via -/// [`TuiPrefs::load`]), and then to the struct's own defaults. +/// Stored at `~/.codewhale/tui.toml` on new installs, with +/// `~/.deepseek/tui.toml` retained as a legacy read fallback. When the file is +/// absent the values fall back to the `[tui]` section of the normal +/// `config.toml` (via [`TuiPrefs::load`]), and then to the struct's own +/// defaults. /// -/// # Example `~/.deepseek/tui.toml` +/// # Example `~/.codewhale/tui.toml` /// /// ```toml /// theme = "dark" # "system" | "dark" | "light" | "grayscale" | "catppuccin-mocha" | ... @@ -91,7 +94,7 @@ pub struct KeybindPrefs { #[allow(dead_code)] // see TuiPrefs note above; deferred to a later settings pass (#657). impl TuiPrefs { /// Return the canonical path of the TUI preferences file: - /// `~/.deepseek/tui.toml`. + /// `~/.codewhale/tui.toml`, or legacy `~/.deepseek/tui.toml` when present. /// /// Tests may override the home directory through the /// `DEEPSEEK_CONFIG_PATH` environment variable (the parent directory of @@ -109,16 +112,17 @@ impl TuiPrefs { } } - let home = dirs::home_dir() - .context("Failed to resolve home directory: cannot determine tui.toml path.")?; - let primary = home.join(".codewhale").join("tui.toml"); - if primary.exists() { - return Ok(primary); - } - Ok(home.join(".deepseek").join("tui.toml")) + let primary = codewhale_config::codewhale_home() + .ok() + .map(|home| home.join(TUI_PREFS_FILE_NAME)); + let legacy_home = codewhale_config::legacy_deepseek_home() + .ok() + .map(|home| home.join(TUI_PREFS_FILE_NAME)); + + resolve_tui_prefs_path_from_candidates(primary, legacy_home) } - /// Load TUI preferences from `~/.deepseek/tui.toml`. + /// Load TUI preferences from `~/.codewhale/tui.toml` or a legacy fallback. /// /// If the file does not exist the struct defaults are returned — no error /// is produced. Parse errors surface as `Err` so the caller can warn the @@ -135,8 +139,8 @@ impl TuiPrefs { Ok(prefs) } - /// Save TUI preferences to `~/.deepseek/tui.toml`, creating the - /// `~/.deepseek` directory if needed. + /// Save TUI preferences to `~/.codewhale/tui.toml` (or a legacy file when + /// it already exists), creating the target directory if needed. pub fn save(&self) -> Result<()> { let path = Self::path()?; if let Some(parent) = path.parent() { @@ -167,6 +171,27 @@ impl TuiPrefs { } } +fn resolve_tui_prefs_path_from_candidates( + primary: Option, + legacy_home: Option, +) -> Result { + if let Some(path) = primary.as_ref() + && path.exists() + { + return Ok(path.clone()); + } + + if let Some(path) = legacy_home.as_ref() + && path.exists() + { + return Ok(path.clone()); + } + + primary.or(legacy_home).ok_or_else(|| { + anyhow::anyhow!("Failed to resolve tui preferences path: no home directory found.") + }) +} + /// User settings with defaults #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -2287,6 +2312,35 @@ mod tests { assert_eq!(got, primary); } + #[test] + fn tui_prefs_path_defaults_to_codewhale_home_for_new_writes() { + let _g = config_path_test_guard(); + let tmp = tempfile::tempdir().expect("tempdir"); + let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH"); + let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale")); + let _home = EnvVarRestore::set("HOME", tmp.path()); + + let got = TuiPrefs::path().expect("tui prefs path"); + + assert_eq!(got, tmp.path().join(".codewhale").join("tui.toml")); + } + + #[test] + fn tui_prefs_path_reads_legacy_deepseek_home_when_present() { + let _g = config_path_test_guard(); + let tmp = tempfile::tempdir().expect("tempdir"); + let primary = tmp.path().join(".codewhale").join("tui.toml"); + let legacy_dir = tmp.path().join(".deepseek"); + std::fs::create_dir_all(&legacy_dir).expect("legacy dir"); + let legacy_home = legacy_dir.join("tui.toml"); + std::fs::write(&legacy_home, "theme = \"light\"\n").expect("legacy prefs"); + + let got = resolve_tui_prefs_path_from_candidates(Some(primary), Some(legacy_home.clone())) + .expect("tui prefs path"); + + assert_eq!(got, legacy_home); + } + #[test] fn tui_prefs_defaults_are_dark_theme_zero_font() { let prefs = TuiPrefs::default(); @@ -2427,18 +2481,15 @@ mod tests { } #[test] - fn tui_prefs_path_uses_home_deepseek_subdir_by_default() { + fn tui_prefs_path_uses_home_codewhale_subdir_by_default() { let _g = config_path_test_guard(); - // Without DEEPSEEK_CONFIG_PATH the path should end with - // .deepseek/tui.toml relative to the home directory. - // We skip this check if home_dir() is unavailable (CI without HOME). - if let Some(home) = dirs::home_dir() { - let expected = home.join(".deepseek").join("tui.toml"); - // Only compare when no env override is active. - if std::env::var("DEEPSEEK_CONFIG_PATH").is_err() { - let got = TuiPrefs::path().expect("path should resolve"); - assert_eq!(got, expected); - } - } + let tmp = tempfile::tempdir().expect("tempdir"); + let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH"); + let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale")); + let _home = EnvVarRestore::set("HOME", tmp.path()); + + let got = TuiPrefs::path().expect("path should resolve"); + + assert_eq!(got, tmp.path().join(".codewhale").join("tui.toml")); } } From c52769e5f51be858bd1954249bef17c553a37793 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 1 Jun 2026 16:40:30 -0700 Subject: [PATCH 38/98] feat(tools): add parallel verifier ensemble --- crates/tui/src/core/engine/tool_catalog.rs | 1 + crates/tui/src/prompts.rs | 5 +- crates/tui/src/prompts/base.md | 2 +- crates/tui/src/prompts/base.txt | 2 +- crates/tui/src/tools/mod.rs | 1 + crates/tui/src/tools/registry.rs | 2 + crates/tui/src/tools/subagent/mod.rs | 1 + crates/tui/src/tools/subagent/tests.rs | 1 + crates/tui/src/tools/verifier.rs | 1067 ++++++++++++++++++++ docs/TOOL_SURFACE.md | 1 + 10 files changed, 1079 insertions(+), 4 deletions(-) create mode 100644 crates/tui/src/tools/verifier.rs diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index 517896326..f643abe2f 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -51,6 +51,7 @@ pub(super) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[ "list_dir", "read_file", "run_tests", + "run_verifiers", "task_create", "task_list", "task_read", diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 8cd945801..4e4771d28 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -698,7 +698,7 @@ fn apply_model_template(prompt: &str, model_id: &str) -> String { const TOOL_TAXONOMY_DISCOVERY: &[&str] = &["grep_files", "file_search"]; const TOOL_TAXONOMY_GIT: &[&str] = &["git_status", "git_diff"]; -const TOOL_TAXONOMY_VERIFICATION: &[&str] = &["run_tests"]; +const TOOL_TAXONOMY_VERIFICATION: &[&str] = &["run_tests", "run_verifiers"]; fn render_core_tool_taxonomy_block(mode: AppMode) -> String { let core_tools = core_taxonomy_tools_for_mode(mode); @@ -726,7 +726,7 @@ fn core_taxonomy_tools_for_mode(mode: AppMode) -> Vec<&'static str> { core_tools .iter() .copied() - .filter(|tool| mode != AppMode::Plan || *tool != "run_tests") + .filter(|tool| mode != AppMode::Plan || !matches!(*tool, "run_tests" | "run_verifiers")) .collect() } @@ -1290,6 +1290,7 @@ mod tests { ); assert!( !expected_taxonomy.contains("run_tests") + && !expected_taxonomy.contains("run_verifiers") && !expected_taxonomy.contains("for verification") && !expected_taxonomy.contains("Use "), "Plan taxonomy must not advertise unavailable verification tools: {expected_taxonomy:?}" diff --git a/crates/tui/src/prompts/base.md b/crates/tui/src/prompts/base.md index c692e03ad..2a576f9e9 100644 --- a/crates/tui/src/prompts/base.md +++ b/crates/tui/src/prompts/base.md @@ -247,7 +247,7 @@ When context is deep (past a soft seam): cache reasoning conclusions in concise - **Shell**: `task_shell_start` + `task_shell_wait` for long-running commands, diagnostics, tests, searches, and servers; `exec_shell` for bounded cancellable foreground commands; `exec_shell_wait`, `exec_shell_interact`. If foreground `exec_shell` times out, the process was killed; rerun long work with `task_shell_start` or `exec_shell` using `background: true`, then poll/wait. - **Task evidence**: `task_gate_run` for verification gates; `pr_attempt_record` / `pr_attempt_list` / `pr_attempt_read` / `pr_attempt_preflight`; `github_issue_context` / `github_pr_context` (read-only); `github_comment` / `github_close_issue` (approval + evidence required); `automation_*` scheduling tools. - **Structured search**: `grep_files`, `file_search`, `web_search`, `fetch_url`, `web.run` (browse). -- **Git / diag / tests**: `git_status`, `git_diff`, `git_show`, `git_log`, `git_blame`, `diagnostics`, `run_tests`, `review`. +- **Git / diag / tests**: `git_status`, `git_diff`, `git_show`, `git_log`, `git_blame`, `diagnostics`, `run_tests`, `run_verifiers`, `review`. - **Sub-agents**: `agent_open`, `agent_eval`, `agent_close`. Open fresh sessions by default; pass `fork_context: true` only when the child needs the current parent context and prefix-cache continuity. - **Recursive LM (long inputs / parallel reasoning)**: `rlm_open`, `rlm_eval`, `rlm_configure`, `rlm_close` — open a named Python REPL over a file/string/URL, run deterministic and semantic analysis, return compact results or `var_handle`s, then close when done. - **Large symbolic outputs**: `handle_read` — read bounded slices, counts, ranges, or JSONPath projections from returned `var_handle`s without replaying the whole payload. diff --git a/crates/tui/src/prompts/base.txt b/crates/tui/src/prompts/base.txt index 0add02a7c..c347cafb5 100644 --- a/crates/tui/src/prompts/base.txt +++ b/crates/tui/src/prompts/base.txt @@ -42,7 +42,7 @@ Model notes: DeepSeek V4 models emit *thinking tokens* (`ContentBlock::Thinking` - **Shell**: `task_shell_start` + `task_shell_wait` for long-running commands, diagnostics, tests, searches, and servers; `exec_shell` for bounded cancellable foreground commands; `exec_shell_wait`, `exec_shell_interact`. - **Task evidence**: `task_gate_run` for verification gates; `pr_attempt_record` / `pr_attempt_list` / `pr_attempt_read` / `pr_attempt_preflight`; `github_issue_context` / `github_pr_context` (read-only); `github_comment` / `github_close_issue` (approval + evidence required); `automation_*` scheduling tools. - **Structured search**: `grep_files`, `file_search`, `web_search`, `fetch_url`, `web.run` (browse). -- **Git / diag / tests**: `git_status`, `git_diff`, `git_show`, `git_log`, `git_blame`, `diagnostics`, `run_tests`, `review`. +- **Git / diag / tests**: `git_status`, `git_diff`, `git_show`, `git_log`, `git_blame`, `diagnostics`, `run_tests`, `run_verifiers`, `review`. - **Sub-agents**: `agent_open`, `agent_eval`, `agent_close`. Fresh sessions are the default; use `fork_context: true` when multiple perspectives need the current parent context and byte-identical prefill/prompt prefix for DeepSeek prefix-cache reuse. Use `tool_agent` for experimental Fin fast-lane execution: simple tool-bound OCR/search/fetch/probe work on Flash V4 with thinking off. - **Recursive LM (long inputs / parallel reasoning)**: `rlm_open`, `rlm_eval`, `rlm_configure`, `rlm_close` — open a named Python REPL over a file/string/URL, run deterministic and semantic analysis, return compact results or `var_handle`s, then close when done. - **Large symbolic outputs**: `handle_read` — read bounded slices, counts, ranges, or JSONPath projections from returned `var_handle`s only. For `art_...`, `call_...`, SHA, or spilled tool-output refs, use `retrieve_tool_result`. diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index 15bf39cb6..ebe147522 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -56,6 +56,7 @@ pub mod tool_result_retrieval; pub mod truncate; pub mod user_input; pub mod validate_data; +pub mod verifier; pub mod web_run; pub mod web_search; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 57b485b1d..168d5bbc9 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -612,7 +612,9 @@ impl ToolRegistryBuilder { #[must_use] pub fn with_test_runner_tool(self) -> Self { use super::test_runner::RunTestsTool; + use super::verifier::RunVerifiersTool; self.with_tool(Arc::new(RunTestsTool)) + .with_tool(Arc::new(RunVerifiersTool)) } /// Include structured data validation tool (`validate_data`). diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index c00e5e5c7..82e3d9ad2 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -520,6 +520,7 @@ impl SubAgentType { "exec_wait", "exec_interact", "run_tests", + "run_verifiers", "diagnostics", "note", ], diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 29d5fc861..c8c069605 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -261,6 +261,7 @@ fn test_verifier_allowed_tools_include_test_runner_but_no_writes() { #[allow(deprecated)] let tools = SubAgentType::Verifier.allowed_tools(); assert!(tools.contains(&"run_tests")); + assert!(tools.contains(&"run_verifiers")); assert!(tools.contains(&"diagnostics")); assert!(!tools.contains(&"write_file")); assert!(!tools.contains(&"apply_patch")); diff --git a/crates/tui/src/tools/verifier.rs b/crates/tui/src/tools/verifier.rs new file mode 100644 index 000000000..d8dd15b26 --- /dev/null +++ b/crates/tui/src/tools/verifier.rs @@ -0,0 +1,1067 @@ +//! Parallel verifier ensemble tool: `run_verifiers`. +//! +//! This is the agent-facing path for "parallelize the verifier, not the +//! generator": one tool call fans out to independent project checks across +//! common ecosystems and returns a single structured verdict. + +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Instant; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::dependencies::ExternalTool; + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, +}; + +const MAX_GATE_OUTPUT_CHARS: usize = 16_000; +const DEFAULT_MAX_PYTHON_FILES: usize = 200; +const MAX_CUSTOM_GATES: usize = 12; + +/// Tool for running independent verifier gates concurrently. +pub struct RunVerifiersTool; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum VerifierProfile { + Auto, + Rust, + Node, + Python, + Go, +} + +impl VerifierProfile { + fn parse(raw: &str) -> Result { + match raw { + "auto" => Ok(Self::Auto), + "rust" => Ok(Self::Rust), + "node" => Ok(Self::Node), + "python" => Ok(Self::Python), + "go" => Ok(Self::Go), + other => Err(ToolError::invalid_input(format!( + "Unsupported profile '{other}'. Expected one of: auto, rust, node, python, go" + ))), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Rust => "rust", + Self::Node => "node", + Self::Python => "python", + Self::Go => "go", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum VerifierLevel { + Quick, + Full, +} + +impl VerifierLevel { + fn parse(raw: &str) -> Result { + match raw { + "quick" => Ok(Self::Quick), + "full" => Ok(Self::Full), + other => Err(ToolError::invalid_input(format!( + "Unsupported level '{other}'. Expected one of: quick, full" + ))), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Quick => "quick", + Self::Full => "full", + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct RunVerifiersInput { + profile: String, + level: String, + max_python_files: usize, + commands: Vec, +} + +impl Default for RunVerifiersInput { + fn default() -> Self { + Self { + profile: "auto".to_string(), + level: "quick".to_string(), + max_python_files: DEFAULT_MAX_PYTHON_FILES, + commands: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct CustomVerifierInput { + name: String, + program: String, + args: Vec, + cwd: Option, +} + +#[derive(Debug, Clone)] +struct VerifierGate { + name: String, + ecosystem: String, + cwd: PathBuf, + program: Option, + args: Vec, + env: Vec<(String, String)>, + skipped_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct GateResult { + name: String, + ecosystem: String, + status: GateStatus, + command: String, + cwd: String, + exit_code: Option, + duration_ms: u64, + stdout: String, + stderr: String, + stdout_truncated: bool, + stderr_truncated: bool, + skipped_reason: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum GateStatus { + Passed, + Failed, + Skipped, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RunVerifiersOutput { + success: bool, + profile: String, + level: String, + workspace: String, + gate_count: usize, + passed: usize, + failed: usize, + skipped: usize, + summary: String, + gates: Vec, +} + +#[async_trait] +impl ToolSpec for RunVerifiersTool { + fn name(&self) -> &'static str { + "run_verifiers" + } + + fn description(&self) -> &'static str { + "Run independent verifier gates in parallel across detected Rust, Node, Python, and Go projects. Supports explicit custom verifier commands as program+args without requiring Bash." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "profile": { + "type": "string", + "enum": ["auto", "rust", "node", "python", "go"], + "default": "auto", + "description": "Which ecosystem verifier set to run. 'auto' detects all supported project types in the workspace." + }, + "level": { + "type": "string", + "enum": ["quick", "full"], + "default": "quick", + "description": "Quick runs fast syntax/drift/build checks. Full adds heavier test/lint gates where available." + }, + "max_python_files": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": DEFAULT_MAX_PYTHON_FILES, + "description": "Maximum Python files to syntax-parse in the built-in python-syntax gate." + }, + "commands": { + "type": "array", + "description": "Optional explicit verifier gates. Commands run directly as program+args, not through a shell. Use program='bash', args=['-lc', '...'] only when Bash is intentionally part of the verifier.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Short unique gate name." + }, + "program": { + "type": "string", + "description": "Executable to spawn, for example 'uv', 'pytest', 'npm', 'make', 'cmd', 'powershell', or 'bash'." + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Arguments passed directly to the executable." + }, + "cwd": { + "type": "string", + "description": "Optional working directory relative to the workspace." + } + }, + "required": ["name", "program"], + "additionalProperties": false + }, + "default": [] + } + }, + "additionalProperties": false + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ExecutesCode, ToolCapability::Sandboxable] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Required + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let input: RunVerifiersInput = serde_json::from_value(input) + .map_err(|err| ToolError::invalid_input(err.to_string()))?; + let profile = VerifierProfile::parse(input.profile.as_str())?; + let level = VerifierLevel::parse(input.level.as_str())?; + if input.max_python_files == 0 || input.max_python_files > 1000 { + return Err(ToolError::invalid_input( + "max_python_files must be between 1 and 1000", + )); + } + if input.commands.len() > MAX_CUSTOM_GATES { + return Err(ToolError::invalid_input(format!( + "commands may contain at most {MAX_CUSTOM_GATES} custom gates" + ))); + } + + let gates = build_gate_plan( + context, + profile, + level, + input.max_python_files, + &input.commands, + )?; + if gates.is_empty() { + let output = RunVerifiersOutput { + success: false, + profile: profile.as_str().to_string(), + level: level.as_str().to_string(), + workspace: context.workspace.display().to_string(), + gate_count: 0, + passed: 0, + failed: 0, + skipped: 0, + summary: "No verifier gates were detected. Provide custom commands or choose a profile that matches this workspace.".to_string(), + gates: Vec::new(), + }; + return ToolResult::json(&output) + .map_err(|err| ToolError::execution_failed(err.to_string())); + } + + let mut handles = Vec::with_capacity(gates.len()); + for gate in gates { + handles.push(tokio::task::spawn_blocking(move || run_gate(gate))); + } + + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + match handle.await { + Ok(result) => results.push(result), + Err(err) => results.push(GateResult { + name: "internal-join".to_string(), + ecosystem: "internal".to_string(), + status: GateStatus::Failed, + command: "tokio::task::spawn_blocking".to_string(), + cwd: context.workspace.display().to_string(), + exit_code: None, + duration_ms: 0, + stdout: String::new(), + stderr: format!("Verifier task join failed: {err}"), + stdout_truncated: false, + stderr_truncated: false, + skipped_reason: None, + }), + } + } + results.sort_by(|a, b| a.name.cmp(&b.name)); + + let passed = results + .iter() + .filter(|result| result.status == GateStatus::Passed) + .count(); + let failed = results + .iter() + .filter(|result| result.status == GateStatus::Failed) + .count(); + let skipped = results + .iter() + .filter(|result| result.status == GateStatus::Skipped) + .count(); + let success = failed == 0 && skipped == 0; + let summary = if success { + format!("All {passed} verifier gates passed.") + } else { + format!("{passed} passed, {failed} failed, {skipped} skipped.") + }; + + let output = RunVerifiersOutput { + success, + profile: profile.as_str().to_string(), + level: level.as_str().to_string(), + workspace: context.workspace.display().to_string(), + gate_count: results.len(), + passed, + failed, + skipped, + summary, + gates: results, + }; + + ToolResult::json(&output).map_err(|err| ToolError::execution_failed(err.to_string())) + } +} + +fn build_gate_plan( + context: &ToolContext, + profile: VerifierProfile, + level: VerifierLevel, + max_python_files: usize, + custom_commands: &[CustomVerifierInput], +) -> Result, ToolError> { + let workspace = &context.workspace; + let mut gates = Vec::new(); + + if profile == VerifierProfile::Auto && workspace.join(".git").exists() { + gates.push(gate( + "git-whitespace", + "git", + workspace, + "git", + ["diff", "--check"], + )); + } + + if profile_matches(profile, VerifierProfile::Rust) && workspace.join("Cargo.toml").exists() { + add_rust_gates(&mut gates, workspace, level); + } + if profile_matches(profile, VerifierProfile::Node) && workspace.join("package.json").exists() { + add_node_gates(&mut gates, workspace, level); + } + if profile_matches(profile, VerifierProfile::Python) && has_python_project(workspace) { + add_python_gates(&mut gates, workspace, level, max_python_files); + } + if profile_matches(profile, VerifierProfile::Go) && workspace.join("go.mod").exists() { + add_go_gates(&mut gates, workspace, level); + } + + for custom in custom_commands { + gates.push(custom_gate(context, custom)?); + } + + Ok(gates) +} + +fn profile_matches(selected: VerifierProfile, candidate: VerifierProfile) -> bool { + selected == VerifierProfile::Auto || selected == candidate +} + +fn add_rust_gates(gates: &mut Vec, workspace: &Path, level: VerifierLevel) { + let locked = workspace.join("Cargo.lock").exists(); + gates.push(gate( + "rust-fmt", + "rust", + workspace, + "cargo", + ["fmt", "--all", "--", "--check"], + )); + + let metadata_args = if locked { + vec!["metadata", "--locked", "--format-version", "1", "--no-deps"] + } else { + vec!["metadata", "--format-version", "1", "--no-deps"] + }; + gates.push(gate_vec( + "rust-metadata", + "rust", + workspace, + "cargo", + metadata_args, + )); + + let mut check_args = vec!["check", "--workspace", "--all-targets"]; + if locked { + check_args.push("--locked"); + } + gates.push(gate_vec( + "rust-check", + "rust", + workspace, + "cargo", + check_args, + )); + + if level == VerifierLevel::Full { + let mut clippy_args = vec!["clippy", "--workspace", "--all-targets", "--all-features"]; + if locked { + clippy_args.push("--locked"); + } + clippy_args.extend(["--", "-D", "warnings"]); + gates.push(gate_vec( + "rust-clippy", + "rust", + workspace, + "cargo", + clippy_args, + )); + + let mut test_args = vec!["test", "--workspace", "--all-features"]; + if locked { + test_args.push("--locked"); + } + gates.push(gate_vec("rust-test", "rust", workspace, "cargo", test_args)); + } +} + +fn add_node_gates(gates: &mut Vec, workspace: &Path, level: VerifierLevel) { + let scripts = package_json_scripts(workspace); + let Some(scripts) = scripts else { + gates.push(skipped_gate( + "node-package-json", + "node", + workspace, + "package.json is missing or could not be parsed", + )); + return; + }; + let package_manager = detect_node_package_manager(workspace); + for script in ["format:check", "check", "typecheck", "lint"] { + if has_meaningful_script(&scripts, script) { + gates.push(node_script_gate(workspace, &package_manager, script)); + } + } + if level == VerifierLevel::Full && has_meaningful_script(&scripts, "test") { + gates.push(node_script_gate(workspace, &package_manager, "test")); + } +} + +fn add_python_gates( + gates: &mut Vec, + workspace: &Path, + level: VerifierLevel, + max_python_files: usize, +) { + let python_files = collect_python_files(workspace, max_python_files); + match python_files { + PythonFiles::Files(files) if !files.is_empty() => { + gates.push(python_syntax_gate(workspace, &files)); + } + PythonFiles::TooMany { limit, found } => gates.push(skipped_gate( + "python-syntax", + "python", + workspace, + format!( + "found more than {limit} Python files ({found}); raise max_python_files to verify them" + ), + )), + PythonFiles::Files(_) => {} + } + + if level == VerifierLevel::Full && has_pytest_signal(workspace) { + gates.push(python_module_gate( + "python-pytest", + workspace, + ["-m", "pytest"], + )); + } +} + +fn add_go_gates(gates: &mut Vec, workspace: &Path, level: VerifierLevel) { + gates.push(gate("go-test", "go", workspace, "go", ["test", "./..."])); + if level == VerifierLevel::Full { + gates.push(gate("go-vet", "go", workspace, "go", ["vet", "./..."])); + } +} + +fn gate( + name: &str, + ecosystem: &str, + cwd: &Path, + program: &str, + args: [&str; N], +) -> VerifierGate { + gate_vec(name, ecosystem, cwd, program, args) +} + +fn gate_vec(name: &str, ecosystem: &str, cwd: &Path, program: &str, args: I) -> VerifierGate +where + I: IntoIterator, + S: AsRef, +{ + VerifierGate { + name: name.to_string(), + ecosystem: ecosystem.to_string(), + cwd: cwd.to_path_buf(), + program: Some(program.to_string()), + args: args + .into_iter() + .map(|arg| arg.as_ref().to_string()) + .collect(), + env: Vec::new(), + skipped_reason: None, + } +} + +fn skipped_gate( + name: &str, + ecosystem: &str, + cwd: &Path, + reason: impl Into, +) -> VerifierGate { + VerifierGate { + name: name.to_string(), + ecosystem: ecosystem.to_string(), + cwd: cwd.to_path_buf(), + program: None, + args: Vec::new(), + env: Vec::new(), + skipped_reason: Some(reason.into()), + } +} + +fn custom_gate( + context: &ToolContext, + custom: &CustomVerifierInput, +) -> Result { + if custom.name.trim().is_empty() { + return Err(ToolError::invalid_input( + "Custom verifier command is missing 'name'", + )); + } + if custom.program.trim().is_empty() { + return Err(ToolError::invalid_input(format!( + "Custom verifier '{}' is missing 'program'", + custom.name + ))); + } + let cwd = match custom.cwd.as_deref() { + Some(raw) if !raw.trim().is_empty() => context.resolve_path(raw)?, + _ => context.workspace.clone(), + }; + Ok(VerifierGate { + name: custom.name.clone(), + ecosystem: "custom".to_string(), + cwd, + program: Some(custom.program.clone()), + args: custom.args.clone(), + env: Vec::new(), + skipped_reason: None, + }) +} + +fn node_script_gate( + workspace: &Path, + package_manager: &NodePackageManager, + script: &str, +) -> VerifierGate { + let (program, args) = package_manager.command_for_script(script); + gate_vec(&format!("node-{script}"), "node", workspace, program, args) +} + +fn python_syntax_gate(workspace: &Path, files: &[PathBuf]) -> VerifierGate { + let Some((program, mut args)) = python_command_parts() else { + return skipped_gate( + "python-syntax", + "python", + workspace, + "Python interpreter is not installed or not in PATH", + ); + }; + args.push("-c".to_string()); + args.push(PYTHON_SYNTAX_SCRIPT.to_string()); + args.extend(files.iter().map(|path| path.display().to_string())); + let mut gate = gate_vec("python-syntax", "python", workspace, &program, args); + gate.env + .push(("PYTHONDONTWRITEBYTECODE".to_string(), "1".to_string())); + gate +} + +fn python_module_gate( + name: &str, + workspace: &Path, + module_args: [&str; N], +) -> VerifierGate { + let Some((program, mut args)) = python_command_parts() else { + return skipped_gate( + name, + "python", + workspace, + "Python interpreter is not installed or not in PATH", + ); + }; + args.extend(module_args.into_iter().map(str::to_string)); + gate_vec(name, "python", workspace, &program, args) +} + +fn python_command_parts() -> Option<(String, Vec)> { + let spec = crate::dependencies::Python::resolve()?; + Some(crate::dependencies::split_interpreter_spec(&spec)) +} + +const PYTHON_SYNTAX_SCRIPT: &str = r#" +import ast +import pathlib +import sys + +failures = [] +for raw in sys.argv[1:]: + path = pathlib.Path(raw) + try: + source = path.read_text(encoding="utf-8") + ast.parse(source, filename=raw) + except Exception as exc: + failures.append(f"{raw}: {exc.__class__.__name__}: {exc}") + +if failures: + print("\n".join(failures), file=sys.stderr) + sys.exit(1) + +print(f"parsed {len(sys.argv) - 1} Python file(s)") +"#; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NodePackageManager { + Npm, + Pnpm, + Yarn, + Bun, +} + +impl NodePackageManager { + fn command_for_script(self, script: &str) -> (&'static str, Vec) { + match self { + Self::Npm => ("npm", vec!["run".to_string(), script.to_string()]), + Self::Pnpm => ("pnpm", vec!["run".to_string(), script.to_string()]), + Self::Yarn => ("yarn", vec!["run".to_string(), script.to_string()]), + Self::Bun => ("bun", vec!["run".to_string(), script.to_string()]), + } + } +} + +fn detect_node_package_manager(workspace: &Path) -> NodePackageManager { + if workspace.join("pnpm-lock.yaml").exists() { + NodePackageManager::Pnpm + } else if workspace.join("yarn.lock").exists() { + NodePackageManager::Yarn + } else if workspace.join("bun.lock").exists() || workspace.join("bun.lockb").exists() { + NodePackageManager::Bun + } else { + NodePackageManager::Npm + } +} + +fn package_json_scripts(workspace: &Path) -> Option> { + let raw = fs::read_to_string(workspace.join("package.json")).ok()?; + let parsed = serde_json::from_str::(&raw).ok()?; + let scripts = parsed.get("scripts")?.as_object()?; + Some( + scripts + .iter() + .filter_map(|(key, value)| { + value + .as_str() + .map(|script| (key.clone(), script.to_string())) + }) + .collect(), + ) +} + +fn has_meaningful_script(scripts: &HashMap, name: &str) -> bool { + let Some(script) = scripts.get(name).map(|value| value.trim()) else { + return false; + }; + !(script.is_empty() + || name == "test" + && script.contains("Error: no test specified") + && script.contains("exit 1")) +} + +fn has_python_project(workspace: &Path) -> bool { + workspace.join("pyproject.toml").exists() + || workspace.join("setup.py").exists() + || workspace.join("setup.cfg").exists() + || workspace.join("requirements.txt").exists() + || match collect_python_files(workspace, 1) { + PythonFiles::Files(files) => !files.is_empty(), + PythonFiles::TooMany { .. } => true, + } +} + +fn has_pytest_signal(workspace: &Path) -> bool { + if workspace.join("pytest.ini").exists() + || workspace.join("tox.ini").exists() + || workspace.join("tests").is_dir() + { + return true; + } + let pyproject = workspace.join("pyproject.toml"); + fs::read_to_string(pyproject) + .map(|raw| raw.contains("pytest") || raw.contains("[tool.pytest")) + .unwrap_or(false) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PythonFiles { + Files(Vec), + TooMany { limit: usize, found: usize }, +} + +fn collect_python_files(workspace: &Path, limit: usize) -> PythonFiles { + let mut files = BTreeSet::new(); + collect_python_files_inner(workspace, workspace, limit, &mut files); + let found = files.len(); + if found > limit { + PythonFiles::TooMany { limit, found } + } else { + PythonFiles::Files(files.into_iter().collect()) + } +} + +fn collect_python_files_inner( + root: &Path, + dir: &Path, + limit: usize, + files: &mut BTreeSet, +) { + if files.len() > limit { + return; + } + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + if files.len() > limit { + return; + } + let path = entry.path(); + let name = entry.file_name(); + if path.is_dir() { + if should_skip_dir_name(&name.to_string_lossy()) { + continue; + } + collect_python_files_inner(root, &path, limit, files); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("py") + && let Ok(relative) = path.strip_prefix(root) + { + files.insert(relative.to_path_buf()); + } + } +} + +fn should_skip_dir_name(name: &str) -> bool { + matches!( + name, + ".git" + | ".hg" + | ".svn" + | ".venv" + | "venv" + | "env" + | "__pycache__" + | ".mypy_cache" + | ".pytest_cache" + | ".tox" + | "node_modules" + | "target" + | "dist" + | "build" + ) +} + +fn run_gate(gate: VerifierGate) -> GateResult { + let command = render_command(gate.program.as_deref(), &gate.args); + if let Some(reason) = gate.skipped_reason { + return GateResult { + name: gate.name, + ecosystem: gate.ecosystem, + status: GateStatus::Skipped, + command, + cwd: gate.cwd.display().to_string(), + exit_code: None, + duration_ms: 0, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + skipped_reason: Some(reason), + }; + } + + let Some(program) = gate.program else { + return GateResult { + name: gate.name, + ecosystem: gate.ecosystem, + status: GateStatus::Skipped, + command, + cwd: gate.cwd.display().to_string(), + exit_code: None, + duration_ms: 0, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + skipped_reason: Some("verifier has no executable program".to_string()), + }; + }; + + let started = Instant::now(); + let mut cmd = Command::new(&program); + cmd.args(&gate.args) + .current_dir(&gate.cwd) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (key, value) in &gate.env { + cmd.env(key, value); + } + + let output = match cmd.output() { + Ok(output) => output, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return GateResult { + name: gate.name, + ecosystem: gate.ecosystem, + status: GateStatus::Skipped, + command, + cwd: gate.cwd.display().to_string(), + exit_code: None, + duration_ms: started.elapsed().as_millis() as u64, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + skipped_reason: Some(format!("{program} is not installed or not in PATH")), + }; + } + Err(err) => { + return GateResult { + name: gate.name, + ecosystem: gate.ecosystem, + status: GateStatus::Failed, + command, + cwd: gate.cwd.display().to_string(), + exit_code: None, + duration_ms: started.elapsed().as_millis() as u64, + stdout: String::new(), + stderr: format!("Failed to spawn verifier: {err}"), + stdout_truncated: false, + stderr_truncated: false, + skipped_reason: None, + }; + } + }; + + let (stdout, stdout_truncated) = truncate_with_note( + &String::from_utf8_lossy(&output.stdout), + MAX_GATE_OUTPUT_CHARS, + ); + let (stderr, stderr_truncated) = truncate_with_note( + &String::from_utf8_lossy(&output.stderr), + MAX_GATE_OUTPUT_CHARS, + ); + GateResult { + name: gate.name, + ecosystem: gate.ecosystem, + status: if output.status.success() { + GateStatus::Passed + } else { + GateStatus::Failed + }, + command, + cwd: gate.cwd.display().to_string(), + exit_code: output.status.code(), + duration_ms: started.elapsed().as_millis() as u64, + stdout, + stderr, + stdout_truncated, + stderr_truncated, + skipped_reason: None, + } +} + +fn render_command(program: Option<&str>, args: &[String]) -> String { + let mut parts = Vec::new(); + parts.push(program.unwrap_or("").to_string()); + parts.extend(args.iter().cloned()); + parts.join(" ") +} + +fn truncate_with_note(text: &str, max_chars: usize) -> (String, bool) { + if text.chars().count() <= max_chars { + return (text.to_string(), false); + } + let end = char_boundary_index(text, max_chars); + let truncated = &text[..end]; + let omitted_chars = text + .chars() + .count() + .saturating_sub(truncated.chars().count()); + ( + format!( + "{truncated}\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]" + ), + true, + ) +} + +fn char_boundary_index(text: &str, max_chars: usize) -> usize { + if max_chars == 0 { + return 0; + } + for (count, (idx, _)) in text.char_indices().enumerate() { + if count == max_chars { + return idx; + } + } + text.len() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn run_verifiers_requires_user_approval() { + let tool = RunVerifiersTool; + assert_eq!( + tool.approval_requirement(), + ApprovalRequirement::Required, + "run_verifiers executes project code and must require approval" + ); + } + + #[test] + fn auto_profile_detects_multiple_ecosystems_without_bash() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").expect("cargo manifest"); + fs::write( + tmp.path().join("package.json"), + r#"{"scripts":{"lint":"eslint .","test":"echo ok"}}"#, + ) + .expect("package json"); + fs::write(tmp.path().join("main.py"), "print('ok')\n").expect("python file"); + fs::write(tmp.path().join("go.mod"), "module example.com/app\n").expect("go mod"); + + let ctx = ToolContext::new(tmp.path()); + let gates = build_gate_plan( + &ctx, + VerifierProfile::Auto, + VerifierLevel::Quick, + DEFAULT_MAX_PYTHON_FILES, + &[], + ) + .expect("plan"); + let names: BTreeSet<&str> = gates.iter().map(|gate| gate.name.as_str()).collect(); + + assert!(names.contains("rust-fmt")); + assert!(names.contains("node-lint")); + assert!(names.contains("python-syntax")); + assert!(names.contains("go-test")); + assert!( + gates + .iter() + .filter_map(|gate| gate.program.as_deref()) + .all(|program| program != "bash"), + "built-in verifier gates must not require bash" + ); + } + + #[test] + fn custom_commands_can_choose_bash_explicitly() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path()); + let custom = CustomVerifierInput { + name: "shell-check".to_string(), + program: "bash".to_string(), + args: vec!["-lc".to_string(), "echo ok".to_string()], + cwd: None, + }; + + let gate = custom_gate(&ctx, &custom).expect("custom gate"); + + assert_eq!(gate.program.as_deref(), Some("bash")); + assert_eq!(gate.args, vec!["-lc", "echo ok"]); + } + + #[test] + fn node_default_npm_init_test_script_is_not_a_verifier() { + let mut scripts = HashMap::new(); + scripts.insert( + "test".to_string(), + "echo \"Error: no test specified\" && exit 1".to_string(), + ); + + assert!(!has_meaningful_script(&scripts, "test")); + } + + #[tokio::test] + async fn run_verifiers_executes_custom_direct_command() { + if !crate::dependencies::RustC::available() { + return; + } + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path()); + let tool = RunVerifiersTool; + let result = tool + .execute( + json!({ + "profile": "auto", + "commands": [ + { + "name": "rustc-version", + "program": crate::dependencies::RustC::resolve().expect("rustc"), + "args": ["--version"] + } + ] + }), + &ctx, + ) + .await + .expect("execute"); + + let parsed: RunVerifiersOutput = + serde_json::from_str(&result.content).expect("verifier output json"); + assert!(parsed.success, "result: {}", result.content); + assert_eq!(parsed.passed, 1); + assert_eq!(parsed.failed, 0); + assert_eq!(parsed.skipped, 0); + assert!( + parsed.gates[0].stdout.contains("rustc"), + "stdout should include rustc version: {:?}", + parsed.gates[0].stdout + ); + } +} diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index cc36fba1b..4455e3b8c 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -92,6 +92,7 @@ to the model, such as `mcp__`. | `git_diff` | Inspect working-tree or staged diffs. | | `diagnostics` | Workspace, git, sandbox, and toolchain info in one call. | | `run_tests` | `cargo test` with optional args. | +| `run_verifiers` | Run independent verifier gates in parallel across detected Rust, Node, Python, and Go projects, with optional custom `program` + `args` gates for other ecosystems. | ### Task management and durable work From 57c10c78d665ba2ebeb8c78e50f20d3f1a95b4a3 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 1 Jun 2026 16:55:10 -0700 Subject: [PATCH 39/98] fix(tui): compact tool-call UI and context --- crates/tui/src/core/engine/context.rs | 184 ++++++++++++++++++++++++ crates/tui/src/core/engine/tests.rs | 137 ++++++++++++++++++ crates/tui/src/tui/footer_ui.rs | 7 +- crates/tui/src/tui/history.rs | 103 ++++++++++++- crates/tui/src/tui/ui.rs | 11 +- crates/tui/src/tui/ui/tests.rs | 3 +- crates/tui/src/tui/widgets/tool_card.rs | 74 +++++++++- 7 files changed, 504 insertions(+), 15 deletions(-) diff --git a/crates/tui/src/core/engine/context.rs b/crates/tui/src/core/engine/context.rs index 726f1a920..7d3e88323 100644 --- a/crates/tui/src/core/engine/context.rs +++ b/crates/tui/src/core/engine/context.rs @@ -8,6 +8,7 @@ use crate::compaction::estimate_tokens; use crate::error_taxonomy::ErrorCategory; use crate::models::{Message, SystemPrompt, context_window_for_model}; use crate::tools::spec::ToolResult; +use serde_json::Value; /// Max output tokens requested for normal agent turns. Generous on purpose: /// V4 thinking models can produce tens of thousands of reasoning tokens on @@ -126,6 +127,12 @@ fn tool_result_is_noisy(tool_name: &str) -> bool { "exec_shell" | "exec_shell_wait" | "exec_shell_interact" + | "exec_shell_cancel" + | "task_shell_start" + | "task_shell_wait" + | "run_tests" + | "run_verifiers" + | "task_gate_run" | "multi_tool_use.parallel" | "web_search" ) @@ -259,6 +266,179 @@ fn compact_subagent_tool_result_for_context(tool_name: &str, raw: &str) -> Optio Some(out.trim_end().to_string()) } +fn json_text<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) +} + +fn json_number_text(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(|value| { + value + .as_i64() + .map(|n| n.to_string()) + .or_else(|| value.as_u64().map(|n| n.to_string())) + }) + .or_else(|| { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + }) +} + +fn compact_run_tests_result_for_context(raw: &str) -> Option { + let parsed: Value = serde_json::from_str(raw).ok()?; + let success = parsed.get("success")?.as_bool()?; + let exit_code = json_number_text(&parsed, "exit_code").unwrap_or_else(|| "?".to_string()); + let command = json_text(&parsed, "command").unwrap_or("(unknown command)"); + let stdout = json_text(&parsed, "stdout"); + let stderr = json_text(&parsed, "stderr"); + let stream_limit = if success { 500 } else { 1_000 }; + + let mut lines = vec![ + "[run_tests result summarized for context]".to_string(), + format!( + "status: {}, exit_code: {exit_code}", + if success { "passed" } else { "failed" } + ), + format!("command: {}", summarize_text(command, 300)), + ]; + if let Some(stderr) = stderr { + lines.push(format!( + "stderr: {}", + summarize_text_head_tail(stderr, stream_limit) + )); + } + if let Some(stdout) = stdout { + lines.push(format!( + "stdout: {}", + summarize_text_head_tail(stdout, stream_limit) + )); + } + Some(lines.join("\n")) +} + +fn run_verifier_status_rank(status: Option<&str>) -> u8 { + match status.unwrap_or_default() { + "failed" | "timeout" => 0, + "skipped" => 1, + "passed" => 2, + _ => 3, + } +} + +fn compact_run_verifiers_result_for_context(raw: &str) -> Option { + let parsed: Value = serde_json::from_str(raw).ok()?; + let gates = parsed.get("gates")?.as_array()?; + let summary = json_text(&parsed, "summary") + .map(ToString::to_string) + .unwrap_or_else(|| { + let passed = json_number_text(&parsed, "passed").unwrap_or_else(|| "?".to_string()); + let failed = json_number_text(&parsed, "failed").unwrap_or_else(|| "?".to_string()); + let skipped = json_number_text(&parsed, "skipped").unwrap_or_else(|| "?".to_string()); + format!("{passed} passed, {failed} failed, {skipped} skipped") + }); + + let mut ordered: Vec<&Value> = gates.iter().collect(); + ordered.sort_by(|a, b| { + run_verifier_status_rank(json_text(a, "status")) + .cmp(&run_verifier_status_rank(json_text(b, "status"))) + .then_with(|| json_text(a, "name").cmp(&json_text(b, "name"))) + }); + + let mut lines = vec![ + "[run_verifiers result summarized for context]".to_string(), + format!("summary: {summary}"), + ]; + let profile = json_text(&parsed, "profile"); + let level = json_text(&parsed, "level"); + if profile.is_some() || level.is_some() { + lines.push(format!( + "selection: profile={}, level={}", + profile.unwrap_or("?"), + level.unwrap_or("?") + )); + } + + for (idx, gate) in ordered.iter().enumerate() { + if idx >= 12 { + lines.push(format!( + "- ... {} more gate(s) omitted from context summary", + ordered.len().saturating_sub(idx) + )); + break; + } + + let name = json_text(gate, "name").unwrap_or("gate"); + let ecosystem = json_text(gate, "ecosystem").unwrap_or("unknown"); + let status = json_text(gate, "status").unwrap_or("unknown"); + let exit = json_number_text(gate, "exit_code") + .map(|code| format!(" exit={code}")) + .unwrap_or_default(); + lines.push(format!("- {name} ({ecosystem}): {status}{exit}")); + + if status != "passed" { + if let Some(command) = json_text(gate, "command") { + lines.push(format!(" command: {}", summarize_text(command, 240))); + } + if let Some(detail) = json_text(gate, "skipped_reason") + .or_else(|| json_text(gate, "stderr")) + .or_else(|| json_text(gate, "stdout")) + { + lines.push(format!( + " detail: {}", + summarize_text_head_tail(detail, 600) + )); + } + } + } + + Some(lines.join("\n")) +} + +fn compact_task_gate_run_result_for_context(raw: &str) -> Option { + let parsed: Value = serde_json::from_str(raw).ok()?; + let gate = parsed.get("gate")?; + let gate_name = json_text(gate, "gate").unwrap_or("gate"); + let status = json_text(gate, "status").unwrap_or("unknown"); + let command = json_text(gate, "command").unwrap_or("(unknown command)"); + let summary = json_text(gate, "summary") + .or_else(|| json_text(&parsed, "stderr_summary")) + .or_else(|| json_text(&parsed, "stdout_summary")); + let exit = json_number_text(gate, "exit_code") + .map(|code| format!(", exit_code: {code}")) + .unwrap_or_default(); + + let mut lines = vec![ + "[task_gate_run result summarized for context]".to_string(), + format!("gate: {gate_name}, status: {status}{exit}"), + format!("command: {}", summarize_text(command, 300)), + ]; + if let Some(summary) = summary { + lines.push(format!("summary: {}", summarize_text(summary, 800))); + } + if let Some(log_path) = json_text(gate, "log_path") { + lines.push(format!("log_path: {log_path}")); + } + Some(lines.join("\n")) +} + +fn compact_structured_tool_result_for_context(tool_name: &str, raw: &str) -> Option { + match tool_name { + "run_tests" => compact_run_tests_result_for_context(raw), + "run_verifiers" => compact_run_verifiers_result_for_context(raw), + "task_gate_run" => compact_task_gate_run_result_for_context(raw), + _ => None, + } +} + fn tool_result_context_limits_for_model(model: &str) -> ToolResultContextLimits { let is_large_context = context_window_for_model(model).is_some_and(|window| window >= LARGE_CONTEXT_WINDOW_TOKENS); @@ -292,6 +472,10 @@ pub(crate) fn compact_tool_result_for_context( return summary; } + if let Some(summary) = compact_structured_tool_result_for_context(tool_name, raw) { + return summary; + } + let limits = tool_result_context_limits_for_model(model); let raw_chars = raw.chars().count(); let should_compact = raw_chars > limits.hard_limit_chars diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index ecd1d239c..2de6e6460 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1524,6 +1524,143 @@ fn subagent_results_are_summarized_before_parent_context_insertion() { assert!(context.contains("handle_read")); } +#[test] +fn run_verifiers_results_are_structured_before_context_insertion() { + let noisy_failure = "node lint failure detail\n".repeat(300); + let noisy_success = "successful check output\n".repeat(300); + let output = ToolResult::success( + json!({ + "success": false, + "profile": "auto", + "level": "quick", + "workspace": "/repo", + "gate_count": 3, + "passed": 1, + "failed": 1, + "skipped": 1, + "summary": "1 passed, 1 failed, 1 skipped", + "gates": [ + { + "name": "rust-check", + "ecosystem": "rust", + "status": "passed", + "command": "cargo check --workspace --locked", + "cwd": "/repo", + "exit_code": 0, + "duration_ms": 110, + "stdout": noisy_success.clone(), + "stderr": "", + "stdout_truncated": false, + "stderr_truncated": false, + "skipped_reason": null + }, + { + "name": "node-lint", + "ecosystem": "node", + "status": "failed", + "command": "npm run lint", + "cwd": "/repo", + "exit_code": 1, + "duration_ms": 220, + "stdout": "", + "stderr": noisy_failure, + "stdout_truncated": false, + "stderr_truncated": false, + "skipped_reason": null + }, + { + "name": "python-pytest", + "ecosystem": "python", + "status": "skipped", + "command": "", + "cwd": "/repo", + "exit_code": null, + "duration_ms": 0, + "stdout": "", + "stderr": "", + "stdout_truncated": false, + "stderr_truncated": false, + "skipped_reason": "pytest is not installed" + } + ] + }) + .to_string(), + ); + + let context = compact_tool_result_for_context("deepseek-v4-pro", "run_verifiers", &output); + + assert!(context.contains("[run_verifiers result summarized for context]")); + assert!(context.contains("summary: 1 passed, 1 failed, 1 skipped")); + assert!(context.contains("selection: profile=auto, level=quick")); + assert!(context.contains("- node-lint (node): failed exit=1")); + assert!(context.contains("command: npm run lint")); + assert!(context.contains("- python-pytest (python): skipped")); + assert!(context.contains("pytest is not installed")); + assert!(context.contains("- rust-check (rust): passed exit=0")); + assert!(context.len() < output.content.len()); + assert!( + !context.contains(&noisy_success), + "successful gate stdout should not be copied into parent context" + ); +} + +#[test] +fn run_tests_results_are_structured_before_context_insertion() { + let stdout = "running test suite\n".repeat(500); + let stderr = "error[E0425]: cannot find value `missing`\n".repeat(500); + let output = ToolResult::success( + json!({ + "success": false, + "exit_code": 101, + "stdout": stdout, + "stderr": stderr, + "command": "(cd /repo && cargo test --workspace --all-features)" + }) + .to_string(), + ); + + let context = compact_tool_result_for_context("deepseek-v4-pro", "run_tests", &output); + + assert!(context.contains("[run_tests result summarized for context]")); + assert!(context.contains("status: failed, exit_code: 101")); + assert!(context.contains("cargo test --workspace --all-features")); + assert!(context.contains("error[E0425]")); + assert!(context.contains("running test suite")); + assert!(context.len() < output.content.len()); +} + +#[test] +fn task_gate_run_results_are_structured_before_context_insertion() { + let output = ToolResult::success( + json!({ + "gate": { + "id": "gate_abcd1234", + "gate": "clippy", + "command": "cargo clippy -p codewhale-tui --all-targets --all-features --locked -- -D warnings", + "cwd": "/repo", + "exit_code": 1, + "status": "failed", + "classification": "compile_failure", + "duration_ms": 5000, + "summary": "warning promoted to error in verifier.rs", + "log_path": "/repo/.codewhale/runtime/gate.log", + "recorded_at": "2026-06-01T12:00:00Z" + }, + "stdout_summary": "", + "stderr_summary": "warning promoted to error" + }) + .to_string(), + ); + + let context = compact_tool_result_for_context("deepseek-v4-pro", "task_gate_run", &output); + + assert!(context.contains("[task_gate_run result summarized for context]")); + assert!(context.contains("gate: clippy, status: failed, exit_code: 1")); + assert!(context.contains("cargo clippy -p codewhale-tui")); + assert!(context.contains("summary: warning promoted to error")); + assert!(context.contains("log_path: /repo/.codewhale/runtime/gate.log")); +} + #[test] fn refresh_system_prompt_leaves_working_set_out_of_system_prompt() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 455a230a8..02dc8ce55 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -17,6 +17,7 @@ use crate::tui::ui::{ status_color, }; use crate::tui::ui_text::{concise_shell_command_label, truncate_line_to_width}; +use crate::tui::widgets::tool_card::tool_activity_label_for_name; use crate::tui::widgets::{FooterProps, FooterToast, FooterWidget, Renderable}; use crate::tui::workspace_context; @@ -399,7 +400,11 @@ fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatu if matches!(generic.name.as_str(), "agent_open" | "agent_spawn") { return; } - snapshot.record(format!("tool {}", generic.name), generic.status, None); + snapshot.record( + tool_activity_label_for_name(&generic.name), + generic.status, + None, + ); } } } diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index d2c19c483..ca07070e2 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -1279,6 +1279,16 @@ pub struct GenericToolCell { pub is_diff: bool, } +fn should_show_raw_tool_name( + name: &str, + family: crate::tui::widgets::tool_card::ToolFamily, + mode: RenderMode, +) -> bool { + matches!(mode, RenderMode::Transcript) + || matches!(family, crate::tui::widgets::tool_card::ToolFamily::Generic) + || name.starts_with("mcp_") +} + impl GenericToolCell { /// Render the generic tool cell into lines. /// @@ -1329,12 +1339,14 @@ impl GenericToolCell { None, low_motion, )); - lines.extend(render_compact_kv( - "name", - &self.name, - tool_value_style(), - width, - )); + if should_show_raw_tool_name(&self.name, family, mode) { + lines.extend(render_compact_kv( + "name", + &self.name, + tool_value_style(), + width, + )); + } // Prefer per-prompt rows over the generic args summary when the tool // exposes a list of child prompts. One row per child with a `[i]` @@ -1878,6 +1890,18 @@ pub fn summarize_tool_args(input: &Value) -> Option { summarize_inline_value(value, 40, false) )); } + if let Some(value) = obj.get("profile") { + parts.push(format!( + "profile: {}", + summarize_inline_value(value, 40, false) + )); + } + if let Some(value) = obj.get("level") { + parts.push(format!( + "level: {}", + summarize_inline_value(value, 40, false) + )); + } if let Some(value) = obj.get("file_id") { parts.push(format!( "file_id: {}", @@ -4792,6 +4816,73 @@ mod tests { assert!(text.contains("query: foo")); } + #[test] + fn known_generic_tool_hides_raw_name_in_live_mode() { + let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "run_verifiers".to_string(), + status: ToolStatus::Running, + input_summary: Some("profile: auto, level: quick".to_string()), + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })); + + let text = lines_text(&cell.lines(80)); + assert!(text.contains("verify running"), "{text}"); + assert!(text.contains("profile: auto"), "{text}"); + assert!( + !text.contains("name: run_verifiers"), + "live card should not spend a row on internal tool id: {text}" + ); + assert!( + !text.contains("run_verifiers"), + "known tool id should not leak into compact live card: {text}" + ); + } + + #[test] + fn known_generic_tool_keeps_raw_name_in_transcript_mode() { + let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "run_verifiers".to_string(), + status: ToolStatus::Running, + input_summary: Some("profile: auto, level: quick".to_string()), + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })); + + let text = lines_text(&cell.transcript_lines(80)); + assert!(text.contains("verify running"), "{text}"); + assert!( + text.contains("name: run_verifiers"), + "transcript replay should preserve exact tool id: {text}" + ); + } + + #[test] + fn unknown_generic_tool_keeps_raw_name_in_live_mode() { + let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "future_private_tool".to_string(), + status: ToolStatus::Running, + input_summary: Some("query: foo".to_string()), + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })); + + let text = lines_text(&cell.lines(80)); + assert!( + text.contains("name: future_private_tool"), + "unknown tools should remain identifiable: {text}" + ); + } + #[test] fn generic_tool_cell_preserves_multi_line_output_in_transcript() { // Repro for #80: a `git diff --stat`-shaped tool result should keep diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3421e65e8..3492be82c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4407,6 +4407,10 @@ async fn tool_result_content_for_api_message( return String::new(); } + if matches!(name, "run_tests" | "run_verifiers" | "task_gate_run") { + return crate::core::engine::compact_tool_result_for_context(&app.model, name, output); + } + if raw.chars().count() > crate::tool_output_receipts::RAW_TOOL_OUTPUT_RECEIPT_THRESHOLD_CHARS { let messages = live_tool_receipt_messages(app, id, raw, output.success); let artifacts = app.session_artifacts.clone(); @@ -8327,6 +8331,9 @@ fn activity_cell_label(app: &App, cell_index: usize, cell: &HistoryCell) -> Stri HistoryCell::Thinking { .. } => "thinking".to_string(), HistoryCell::Error { .. } => "error".to_string(), HistoryCell::SubAgent(_) => "sub-agent".to_string(), + HistoryCell::Tool(ToolCell::Generic(generic)) => { + crate::tui::widgets::tool_card::tool_activity_label_for_name(&generic.name) + } HistoryCell::Tool(_) => { detail_target_label(app, cell_index).unwrap_or_else(|| "tool activity".to_string()) } @@ -8752,7 +8759,9 @@ pub(crate) fn detail_target_label(app: &App, cell_index: usize) -> Option Some(format!("search {}", search.query)), - HistoryCell::Tool(ToolCell::Generic(generic)) => Some(format!("tool {}", generic.name)), + HistoryCell::Tool(ToolCell::Generic(generic)) => { + Some(crate::tui::widgets::tool_card::tool_activity_label_for_name(&generic.name)) + } HistoryCell::SubAgent(_) => Some("sub-agent".to_string()), _ => None, } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 6ea4de95f..73e160ad2 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1918,7 +1918,8 @@ fn active_tool_status_label_counts_foreground_rlm_work() { let label = active_tool_status_label(&app).expect("status label"); - assert!(label.contains("tool rlm"), "label: {label}"); + assert!(label.contains("rlm"), "label: {label}"); + assert!(!label.contains("tool rlm"), "label: {label}"); assert!(label.contains("1 active"), "label: {label}"); } diff --git a/crates/tui/src/tui/widgets/tool_card.rs b/crates/tui/src/tui/widgets/tool_card.rs index 6020069b1..d525551bb 100644 --- a/crates/tui/src/tui/widgets/tool_card.rs +++ b/crates/tui/src/tui/widgets/tool_card.rs @@ -8,7 +8,7 @@ //! //! This module owns: //! -//! - [`ToolFamily`] — the seven canonical families plus a `Generic` +//! - [`ToolFamily`] — the canonical semantic families plus a `Generic` //! fallback for anything we don't have a family for yet. //! - [`tool_family_for_title`] — maps the legacy `render_tool_header` title //! string (`"Shell"`, `"Patch"`, `"Workspace"`, etc.) to a family. Lets @@ -41,6 +41,8 @@ pub enum ToolFamily { Fanout, /// Recursive language model work. `⋮⋮ rlm`. Rlm, + /// Verification gates, tests, and validators. `✓ verify`. + Verify, /// Reasoning / chain-of-thought. `… think`. Reasoning has its own /// render path (`render_thinking` in `history.rs`); the family is /// declared here for completeness so any future code that reaches for @@ -77,16 +79,46 @@ pub fn tool_family_for_name(name: &str) -> ToolFamily { match name { "read_file" | "list_dir" | "view_image" => ToolFamily::Read, "edit_file" | "apply_patch" | "write_file" => ToolFamily::Patch, - "exec_shell" | "exec_shell_wait" | "exec_shell_interact" => ToolFamily::Run, + "exec_shell" + | "exec_shell_wait" + | "exec_shell_interact" + | "exec_shell_cancel" + | "task_shell_start" + | "task_shell_wait" => ToolFamily::Run, "grep_files" | "file_search" | "web_search" | "fetch_url" => ToolFamily::Find, "agent_open" | "agent_eval" | "agent_close" | "agent_spawn" | "tool_agent" => { ToolFamily::Delegate } "rlm_open" | "rlm_eval" | "rlm_configure" | "rlm_close" | "rlm" => ToolFamily::Rlm, + "run_tests" | "run_verifiers" | "task_gate_run" | "validate_data" => ToolFamily::Verify, _ => ToolFamily::Generic, } } +/// User-facing label for an arbitrary tool name. Known tools collapse to the +/// semantic verb; unknown tools keep their exact name for debugging. +#[must_use] +pub fn tool_display_label_for_name(name: &str) -> String { + let family = tool_family_for_name(name); + if matches!(family, ToolFamily::Generic) { + name.to_string() + } else { + family_label(family).to_string() + } +} + +/// Compact activity/status label for arbitrary tool names. Known built-ins use +/// the semantic verb; unknown tools keep the `tool NAME` form. +#[must_use] +pub fn tool_activity_label_for_name(name: &str) -> String { + let family = tool_family_for_name(name); + if matches!(family, ToolFamily::Generic) { + format!("tool {name}") + } else { + tool_display_label_for_name(name) + } +} + /// Build a compact semantic summary for a tool header from the public tool /// name and the already-sanitized argument summary. #[must_use] @@ -103,6 +135,7 @@ pub fn tool_header_summary_for_name(name: &str, input_summary: Option<&str>) -> ToolFamily::Delegate | ToolFamily::Fanout | ToolFamily::Rlm => { ["prompt", "task", "model"].as_slice() } + ToolFamily::Verify => ["profile", "level", "command", "args", "path"].as_slice(), ToolFamily::Think | ToolFamily::Generic => { ["query", "path", "command", "prompt"].as_slice() } @@ -144,8 +177,9 @@ pub fn family_glyph(family: ToolFamily) -> &'static str { ToolFamily::Delegate => "\u{25D0}", // ◐ ToolFamily::Fanout => "\u{22EE}\u{22EE}", // ⋮⋮ (two cells) ToolFamily::Rlm => "\u{22EE}\u{22EE}", // ⋮⋮ (two cells) - ToolFamily::Think => "\u{2026}", // … - ToolFamily::Generic => "\u{2022}", // • + ToolFamily::Verify => "\u{2713}", + ToolFamily::Think => "\u{2026}", // … + ToolFamily::Generic => "\u{2022}", // • } } @@ -162,6 +196,7 @@ pub fn family_label(family: ToolFamily) -> &'static str { ToolFamily::Delegate => "delegate", ToolFamily::Fanout => "fanout", ToolFamily::Rlm => "rlm", + ToolFamily::Verify => "verify", ToolFamily::Think => "think", ToolFamily::Generic => "tool", } @@ -198,8 +233,9 @@ pub fn rail_glyph(rail: CardRail) -> &'static str { #[cfg(test)] mod tests { use super::{ - CardRail, ToolFamily, family_glyph, family_label, rail_glyph, tool_family_for_name, - tool_family_for_title, tool_header_summary_for_name, + CardRail, ToolFamily, family_glyph, family_label, rail_glyph, tool_activity_label_for_name, + tool_display_label_for_name, tool_family_for_name, tool_family_for_title, + tool_header_summary_for_name, }; #[test] @@ -218,15 +254,35 @@ mod tests { assert_eq!(tool_family_for_name("read_file"), ToolFamily::Read); assert_eq!(tool_family_for_name("apply_patch"), ToolFamily::Patch); assert_eq!(tool_family_for_name("exec_shell"), ToolFamily::Run); + assert_eq!(tool_family_for_name("task_shell_start"), ToolFamily::Run); assert_eq!(tool_family_for_name("grep_files"), ToolFamily::Find); assert_eq!(tool_family_for_name("agent_open"), ToolFamily::Delegate); assert_eq!(tool_family_for_name("rlm_eval"), ToolFamily::Rlm); + assert_eq!(tool_family_for_name("run_verifiers"), ToolFamily::Verify); assert_eq!( tool_family_for_name("totally_new_tool"), ToolFamily::Generic ); } + #[test] + fn tool_display_label_collapses_known_tools_to_user_verbs() { + assert_eq!(tool_display_label_for_name("exec_shell"), "run"); + assert_eq!(tool_display_label_for_name("run_verifiers"), "verify"); + assert_eq!(tool_display_label_for_name("file_search"), "find"); + assert_eq!( + tool_display_label_for_name("future_private_tool"), + "future_private_tool" + ); + + assert_eq!(tool_activity_label_for_name("exec_shell"), "run"); + assert_eq!(tool_activity_label_for_name("run_verifiers"), "verify"); + assert_eq!( + tool_activity_label_for_name("future_private_tool"), + "tool future_private_tool" + ); + } + #[test] fn tool_header_summary_prefers_family_specific_arguments() { assert_eq!( @@ -244,6 +300,11 @@ mod tests { .as_deref(), Some("TODO") ); + assert_eq!( + tool_header_summary_for_name("run_verifiers", Some("profile: auto, level: quick")) + .as_deref(), + Some("auto") + ); assert_eq!( tool_header_summary_for_name("unknown", Some("alpha: beta")).as_deref(), Some("alpha: beta") @@ -261,6 +322,7 @@ mod tests { ToolFamily::Delegate, ToolFamily::Fanout, ToolFamily::Rlm, + ToolFamily::Verify, ToolFamily::Think, ToolFamily::Generic, ] { From d71cba692efd83a5675a3ca36eb8bf05408a75a0 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 07:22:50 +0800 Subject: [PATCH 40/98] test(tui): wait for background shell completion --- crates/tui/src/tools/shell/tests.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index f24923c1d..c6830a90c 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -132,6 +132,20 @@ fn failed_network_shell_result(stdout: &str, stderr: &str) -> ShellResult { } } +fn wait_for_completed_shell(manager: &mut ShellManager, task_id: &str) -> ShellResult { + let deadline = Instant::now() + Duration::from_secs(20); + + loop { + let result = manager + .get_output(task_id, true, 1_000) + .expect("get_output"); + if result.status != ShellStatus::Running || Instant::now() >= deadline { + return result; + } + std::thread::sleep(Duration::from_millis(50)); + } +} + #[test] #[cfg(unix)] fn shell_execution_scrubs_parent_env_and_keeps_explicit_env() { @@ -205,10 +219,7 @@ fn test_background_execution() { .task_id .expect("background execution should return task_id"); - // Wait for completion - let final_result = manager - .get_output(&task_id, true, 5000) - .expect("get_output"); + let final_result = wait_for_completed_shell(&mut manager, &task_id); assert_eq!(final_result.status, ShellStatus::Completed); assert!(final_result.stdout.contains("done")); @@ -799,6 +810,8 @@ async fn test_completed_background_shell_releases_process_handles() { assert!(result.success); let mut manager = shell_manager.lock().expect("shell manager lock"); + let result = wait_for_completed_shell(&mut manager, &task_id); + assert_eq!(result.status, ShellStatus::Completed); let shell = manager.processes.get_mut(&task_id).expect("tracked shell"); shell.poll(); assert_eq!(shell.status, ShellStatus::Completed); From eba019ae4357e25a46e02d00aff2f8642a9773c9 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 1 Jun 2026 17:03:18 -0700 Subject: [PATCH 41/98] test(tui): align activity labels with semantic tools --- crates/tui/src/tui/ui/tests.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 73e160ad2..7025f45bc 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4337,7 +4337,7 @@ fn detail_target_prefers_visible_tool_card() { assert_eq!(detail_target_cell_index(&app), Some(1)); let expected = format!( - "{} Activity: file_search · {} raw", + "{} Activity: find · {} raw", crate::tui::key_shortcuts::activity_shortcut_label(), crate::tui::key_shortcuts::tool_details_shortcut_label() ); @@ -5994,16 +5994,13 @@ fn activity_detail_includes_tool_handle_and_neighbor_context() { assert!(open_activity_detail_pager(&mut app)); let body = pop_pager_body(&mut app); - assert!(body.contains("Activity: read_file"), "{body}"); + assert!(body.contains("Activity: read"), "{body}"); assert!(body.contains("Activity chunk: 2 of 3"), "{body}"); assert!( body.contains("Previous activity: 1 of 3 - thinking"), "{body}" ); - assert!( - body.contains("Next activity: 3 of 3 - tool grep_files"), - "{body}" - ); + assert!(body.contains("Next activity: 3 of 3 - find"), "{body}"); assert!(body.contains("Detail handle: art_call-read"), "{body}"); assert!( body.contains("retrieve_tool_result ref=art_call-read"), @@ -6038,7 +6035,7 @@ fn activity_detail_fallback_prefers_live_activity_context() { let body = pop_pager_body(&mut app); assert!(body.contains("Turn: turn_live_123456789")); - assert!(body.contains("Activity: tool agent_eval")); + assert!(body.contains("Activity: delegate")); assert!(body.contains("Status: running")); assert!(body.contains("agent_id: agent_af58ba3a")); } @@ -6065,7 +6062,7 @@ fn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_du assert!(open_activity_detail_pager(&mut app)); let body = pop_pager_body(&mut app); - assert!(body.contains("Activity: tool read_file")); + assert!(body.contains("Activity: read")); assert!(body.contains("Status: done")); assert!( body.contains("Alt+V for details"), From 7c06cf5981a38a8bff2231e22dee7ac5fa5f93c2 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 07:41:59 +0800 Subject: [PATCH 42/98] fix(tui): guide bug reports toward failure causes --- crates/tui/src/commands/feedback.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/commands/feedback.rs b/crates/tui/src/commands/feedback.rs index 9849c9a20..fc968c73a 100644 --- a/crates/tui/src/commands/feedback.rs +++ b/crates/tui/src/commands/feedback.rs @@ -37,11 +37,15 @@ pub fn feedback(_app: &mut App, arg: Option<&str>) -> CommandResult { } let url = kind.issue_url(); - let message = format!( + let mut message = format!( "Trying to open GitHub {} template in your browser. If that fails, open this URL manually:\n\n{}", kind.label().to_ascii_lowercase(), url, ); + if matches!(kind, FeedbackKind::Bug) { + message.push_str("\n\n"); + message.push_str(bug_report_diagnostics_hint()); + } CommandResult::with_message_and_action( message, @@ -115,6 +119,13 @@ fn feedback_help() -> String { message } +fn bug_report_diagnostics_hint() -> &'static str { + "Before filing, first check whether this looks like a model issue or an environment/tool issue: \ + command exit, network/service, sandbox/approval, missing dependency/path, timeout, or an unclosed turn. \ + Include the CodeWhale version, OS/terminal, the tool name, and redacted timestamps or log handles when available. \ + Do not paste prompts, secrets, raw command output, full local paths, or conversation transcripts." +} + fn parse_feedback_kind(input: &str) -> Option { Some(match input.to_ascii_lowercase().as_str() { "1" | "bug" | "bug-report" | "bug_report" => FeedbackKind::Bug, @@ -204,6 +215,12 @@ mod tests { assert!(message.contains("Trying to open GitHub bug report template")); assert!(message.contains("open this URL manually")); + assert!(message.contains("Before filing, first check whether this looks like")); + assert!(message.contains("network/service")); + assert!(message.contains("sandbox/approval")); + assert!(message.contains("missing dependency/path")); + assert!(message.contains("timeout")); + assert!(message.contains("Do not paste prompts, secrets, raw command output")); assert!(message.contains(url)); assert!(url.contains("template=bug_report.md")); assert!(!url.contains("title=")); From 5a909eea45deb0d54b014b8dd951921a6b27c885 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 06:23:43 +0800 Subject: [PATCH 43/98] docs(providers): clarify local model tool calls --- docs/PROVIDERS.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 840474156..a4ddc01c8 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -206,6 +206,26 @@ the endpoint's ability to accept OpenAI-compatible `tools` payloads. A custom OpenAI-compatible or local endpoint can still reject tool calls even if CodeWhale can send the schema. +### When a Local Model Prints Tool JSON + +CodeWhale only executes tools when the provider returns Chat Completions +`tool_calls` or streamed `delta.tool_calls`. If a local model prints text such +as `{"name":"grep_files","arguments":{...}}` in the assistant message, that is +ordinary model output, not an executable tool request. + +For OpenAI-compatible or local runtimes, check: + +- The endpoint accepts the `tools` array in `/v1/chat/completions` requests. +- The selected model or chat template is configured for function/tool calls. +- The server returns `tool_calls` in the response rather than plain JSON text. +- The compatibility layer does not strip tools before forwarding the request. +- If in doubt, test a small `read_file` or `grep_files` request against a known + tool-calling model before debugging CodeWhale's tool registry. + +Changing `provider`, `base_url`, or `model` can select a route that supports the +OpenAI-compatible payload shape, but CodeWhale cannot convert arbitrary JSON +text into a trusted tool call after the model has emitted it as prose. + DeepSeek compatibility aliases `deepseek-chat` and `deepseek-reasoner` map to `deepseek-v4-flash` capability metadata and are scheduled to retire on 2026-07-24 at 2026-07-24T15:59:00Z. From 5f3cc3c8e8357d7d52a75e16ac3411c86f85c800 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 06:14:32 +0800 Subject: [PATCH 44/98] docs(rebrand): clarify state migration paths --- docs/REBRAND.md | 42 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/docs/REBRAND.md b/docs/REBRAND.md index 1e67d9f9b..56bfbcec5 100644 --- a/docs/REBRAND.md +++ b/docs/REBRAND.md @@ -23,8 +23,10 @@ codewhale doctor codewhale ``` -Your `~/.deepseek/config.toml`, `~/.deepseek/sessions/`, `~/.deepseek/skills/`, -`~/.deepseek/tasks/`, and `~/.deepseek/mcp.json` are untouched. Existing +Your existing `~/.deepseek/config.toml`, `~/.deepseek/sessions/`, +`~/.deepseek/skills/`, `~/.deepseek/tasks/`, and `~/.deepseek/mcp.json` are +not deleted. New CodeWhale installs prefer `~/.codewhale/`, and legacy +`~/.deepseek/` state remains a read fallback while you migrate. Existing `DEEPSEEK_*` environment variables continue to work. ## What got renamed @@ -52,9 +54,10 @@ Anything that targets the DeepSeek provider API stays exactly as it was: aliases `deepseek-chat` and `deepseek-reasoner`. - **Hosts**: `api.deepseek.com` (global) and `api.deepseeki.com` (China fallback). -- **Config directory**: `~/.deepseek/`. Renaming this would invalidate - every existing install's saved API key, sessions, skills, MCP config, - and audit log. +- **Legacy state compatibility**: existing `~/.deepseek/` config, sessions, + skills, tasks, MCP config, memory, and notes remain readable. New writes use + the CodeWhale state root (`~/.codewhale/`) unless you explicitly point a + setting at another path. - **GitHub repository URL**: `https://github.com/Hmbown/CodeWhale`. The old `Hmbown/DeepSeek-TUI` URL redirects there during the transition. - **Homebrew tap and formula** (`Hmbown/homebrew-deepseek-tui`): still uses @@ -118,6 +121,35 @@ A second checksum manifest, `deepseek-artifacts-sha256.txt`, is attached as an alias of `codewhale-artifacts-sha256.txt` so v0.8.40's hardcoded lookup still verifies. +### Sessions, skills, and manual workspaces + +Renaming the binary does not require starting over: + +- **Config**: on first launch, CodeWhale copies `~/.deepseek/config.toml` to + `~/.codewhale/config.toml` if the CodeWhale file does not already exist. + It never overwrites a newer CodeWhale config. You can inspect the active path + with `codewhale doctor`. +- **Sessions and tasks**: managed state is read from `~/.codewhale/...` when + present, with `~/.deepseek/...` used as the legacy fallback when only the old + directory exists. Existing saved sessions still appear in `codewhale sessions` + and the TUI resume picker. +- **Skills**: CodeWhale discovers workspace skills first, then global skills, + including both `~/.codewhale/skills` and legacy `~/.deepseek/skills`. Existing + skill directories with `SKILL.md` do not need to be rewritten. +- **Manual binary installs**: keep the dispatcher and TUI binaries as siblings + on your `PATH`: `codewhale` plus `codewhale-tui`. On Windows, the recommended + user-local location is `%LOCALAPPDATA%\Programs\CodeWhale\bin`. On Unix-like + systems, any user-writable `PATH` directory is fine as long as both binaries + are present. +- **Specified work directories**: running `codewhale` from a project directory, + or launching it with a specific workspace path, does not move project files. + CodeWhale reads `/.codewhale/config.toml` first and falls back to + legacy `/.deepseek/config.toml` when the new path is absent. + +If both `~/.codewhale/...` and `~/.deepseek/...` copies exist, the CodeWhale +path wins. Keep the legacy directory until you have confirmed `codewhale +doctor`, `codewhale sessions`, and your expected skills all show the same state. + ## Why the name change CodeWhale is a shorter, terminal-friendlier handle for the same terminal From 3db7b40458cd9af0febc8375e61ec9856cbddb54 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 06:59:31 +0800 Subject: [PATCH 45/98] docs(rebrand): address migration review notes --- docs/REBRAND.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/REBRAND.md b/docs/REBRAND.md index 56bfbcec5..f3b25a0e5 100644 --- a/docs/REBRAND.md +++ b/docs/REBRAND.md @@ -40,6 +40,13 @@ not deleted. New CodeWhale installs prefer `~/.codewhale/`, and legacy | Release assets | `deepseek-` / `deepseek-tui-` | `codewhale-` / `codewhale-tui-` | | Checksum manifest | `deepseek-artifacts-sha256.txt` | `codewhale-artifacts-sha256.txt` | +## What changed for local state + +New installs write product-owned state under `~/.codewhale/`. Existing +`~/.deepseek/` config, sessions, skills, tasks, MCP config, memory, and notes +remain readable as legacy fallbacks while you migrate. CodeWhale never deletes +the legacy directory automatically. + ## What did NOT change Anything that targets the DeepSeek provider API stays exactly as it was: @@ -54,10 +61,6 @@ Anything that targets the DeepSeek provider API stays exactly as it was: aliases `deepseek-chat` and `deepseek-reasoner`. - **Hosts**: `api.deepseek.com` (global) and `api.deepseeki.com` (China fallback). -- **Legacy state compatibility**: existing `~/.deepseek/` config, sessions, - skills, tasks, MCP config, memory, and notes remain readable. New writes use - the CodeWhale state root (`~/.codewhale/`) unless you explicitly point a - setting at another path. - **GitHub repository URL**: `https://github.com/Hmbown/CodeWhale`. The old `Hmbown/DeepSeek-TUI` URL redirects there during the transition. - **Homebrew tap and formula** (`Hmbown/homebrew-deepseek-tui`): still uses @@ -136,6 +139,10 @@ Renaming the binary does not require starting over: - **Skills**: CodeWhale discovers workspace skills first, then global skills, including both `~/.codewhale/skills` and legacy `~/.deepseek/skills`. Existing skill directories with `SKILL.md` do not need to be rewritten. +- **MCP config**: the default path is `~/.codewhale/mcp.json`. If that file is + absent, CodeWhale still reads legacy `~/.deepseek/mcp.json`. To use a custom + MCP config file, set `mcp_config_path` in `config.toml` or + `DEEPSEEK_MCP_CONFIG`. - **Manual binary installs**: keep the dispatcher and TUI binaries as siblings on your `PATH`: `codewhale` plus `codewhale-tui`. On Windows, the recommended user-local location is `%LOCALAPPDATA%\Programs\CodeWhale\bin`. On Unix-like From 9bd08c2f1c54853adcebbe1b75e41ebc09ff4d4b Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 06:06:35 +0800 Subject: [PATCH 46/98] docs(tui): document statusline footer items --- README.md | 6 ++++++ config.example.toml | 7 +++++++ docs/GUIDE.md | 9 +++++++++ 3 files changed, 22 insertions(+) diff --git a/README.md b/README.md index 3e37aaba5..87cf7f0a0 100644 --- a/README.md +++ b/README.md @@ -490,6 +490,12 @@ Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md). User config: `~/.codewhale/config.toml` (legacy `~/.deepseek/config.toml` fallback). Project overlay: `/.codewhale/config.toml` (legacy `/.deepseek/config.toml`) (denied: `api_key`, `base_url`, `provider`, `mcp_config_path`). [config.example.toml](config.example.toml) has every option. +The TUI footer can be trimmed or reordered with `/statusline`, or by setting +`[tui].status_items` in config. Current footer customization selects from the +built-in chips such as `mode`, `model`, `status`, `git_branch`, `tokens`, and +`cache`; multi-line layouts, custom colors, and external command widgets are +not part of the current statusline surface. + Custom DeepSeek-compatible endpoints usually do not need a new provider. Keep `provider = "deepseek"` and set `[providers.deepseek].base_url` / `model`, or use `provider = "openai"` for generic OpenAI-compatible gateways. Keep diff --git a/config.example.toml b/config.example.toml index d80a8aa44..70e54dd1a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -429,6 +429,13 @@ alternate_screen = "auto" # auto/always use the TUI screen; never uses termina mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms) osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender +# Ordered footer chips shown in the TUI status line. Omit the key to use the +# built-in default; set [] to hide all configurable chips. You can also edit +# this interactively with `/statusline`. +# Supported keys: mode, model, cost, balance, status, coherence, agents, +# reasoning_replay, prefix_stability, cache, context_percent, git_branch, +# last_tool_elapsed, rate_limit, tokens. +# status_items = ["mode", "model", "status", "git_branch", "tokens", "cache"] # notification_condition = "always" # always | never — overrides [notifications].threshold_secs. # "always" = notify on every successful turn (no threshold); # "never" = suppress all turn-completion notifications; diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 8a8407a33..7deb35a67 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -180,6 +180,14 @@ The interactive TUI has a few stable regions: - Status and footer areas: live activity, queued follow-ups, and short command hints. +The footer status line is configurable. Run `/statusline` to choose which +footer chips are visible, or set `[tui].status_items` in `config.toml` for a +stable order. Supported keys currently include `mode`, `model`, `cost`, +`balance`, `status`, `coherence`, `agents`, `reasoning_replay`, +`prefix_stability`, `cache`, `context_percent`, `git_branch`, +`last_tool_elapsed`, `rate_limit`, and `tokens`. Omit `status_items` to keep +the built-in default order; set it to `[]` to hide configurable chips. + The transcript is the audit trail. When CodeWhale reads files, runs commands, or edits code, the action appears there. If a command fails, use the visible failure output as part of your next instruction instead of starting over. @@ -256,6 +264,7 @@ Common commands for first-time users: | `/models` | Fetch or list models from the active endpoint | | `/provider` | Pick the active API provider | | `/config` | Edit runtime and provider settings | +| `/statusline` | Choose which footer status chips are visible | | `/settings` | Inspect persistent UI preferences | | `/compact` | Summarize long context to recover token budget | | `/review` | Ask for a structured review workflow | From ae2000b59aeb740acefc72523d690b12576f1e71 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 1 Jun 2026 17:20:49 -0700 Subject: [PATCH 47/98] docs(tui): align statusline customization limits --- README.md | 8 +++++--- config.example.toml | 5 +++-- docs/GUIDE.md | 13 +++++++------ 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 87cf7f0a0..df229b627 100644 --- a/README.md +++ b/README.md @@ -490,11 +490,13 @@ Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md). User config: `~/.codewhale/config.toml` (legacy `~/.deepseek/config.toml` fallback). Project overlay: `/.codewhale/config.toml` (legacy `/.deepseek/config.toml`) (denied: `api_key`, `base_url`, `provider`, `mcp_config_path`). [config.example.toml](config.example.toml) has every option. -The TUI footer can be trimmed or reordered with `/statusline`, or by setting +The TUI footer can be trimmed with `/statusline`, or by setting `[tui].status_items` in config. Current footer customization selects from the built-in chips such as `mode`, `model`, `status`, `git_branch`, `tokens`, and -`cache`; multi-line layouts, custom colors, and external command widgets are -not part of the current statusline surface. +`cache`; chip order is controlled by the order of keys in `status_items` in +`config.toml`. The interactive picker writes the canonical order. Multi-line +layouts, custom colors, and external command widgets are not part of the +current statusline surface. Custom DeepSeek-compatible endpoints usually do not need a new provider. Keep `provider = "deepseek"` and set `[providers.deepseek].base_url` / `model`, or diff --git a/config.example.toml b/config.example.toml index 70e54dd1a..fecf15e35 100644 --- a/config.example.toml +++ b/config.example.toml @@ -432,9 +432,10 @@ osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTer # Ordered footer chips shown in the TUI status line. Omit the key to use the # built-in default; set [] to hide all configurable chips. You can also edit # this interactively with `/statusline`. -# Supported keys: mode, model, cost, balance, status, coherence, agents, +# Supported keys: mode, model, cost, balance (DeepSeek / DeepSeekCN only), +# status, coherence, agents, # reasoning_replay, prefix_stability, cache, context_percent, git_branch, -# last_tool_elapsed, rate_limit, tokens. +# last_tool_elapsed (placeholder), rate_limit (placeholder), tokens. # status_items = ["mode", "model", "status", "git_branch", "tokens", "cache"] # notification_condition = "always" # always | never — overrides [notifications].threshold_secs. # "always" = notify on every successful turn (no threshold); diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 7deb35a67..c02ece24e 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -181,12 +181,13 @@ The interactive TUI has a few stable regions: hints. The footer status line is configurable. Run `/statusline` to choose which -footer chips are visible, or set `[tui].status_items` in `config.toml` for a -stable order. Supported keys currently include `mode`, `model`, `cost`, -`balance`, `status`, `coherence`, `agents`, `reasoning_replay`, -`prefix_stability`, `cache`, `context_percent`, `git_branch`, -`last_tool_elapsed`, `rate_limit`, and `tokens`. Omit `status_items` to keep -the built-in default order; set it to `[]` to hide configurable chips. +footer chips are visible, or set `[tui].status_items` in `config.toml` to +control both selection and order. Supported keys currently include `mode`, +`model`, `cost`, `balance` (DeepSeek / DeepSeekCN only), `status`, `coherence`, +`agents`, `reasoning_replay`, `prefix_stability`, `cache`, `context_percent`, +`git_branch`, `last_tool_elapsed` (placeholder), `rate_limit` (placeholder), +and `tokens`. Omit `status_items` to keep the built-in default order; set it to +`[]` to hide configurable chips. The transcript is the audit trail. When CodeWhale reads files, runs commands, or edits code, the action appears there. If a command fails, use the visible From fda2141b70bb371e8253c52c83c8a54f74031b0d Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 01:07:18 +0800 Subject: [PATCH 48/98] docs: clarify shell tool mode availability --- docs/MODES.md | 16 ++++++++++++++++ docs/TOOL_SURFACE.md | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/docs/MODES.md b/docs/MODES.md index 1a2763d93..3f7106464 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -22,6 +22,22 @@ Run `/mode` to open the mode picker, or switch directly with `/mode agent`, - **Agent**: multi-step tool use. Shell execution (`exec_shell`, `task_shell_start`, `task_shell_wait`) requires `allow_shell = true` in config; approval prompts gate each call. File writes are allowed without a prompt. - **YOLO**: enables shell + trust mode and auto-approves all tools. Use only in trusted repos. +### Tool availability by mode + +| Tool family | Plan | Agent | YOLO | +|---|---:|---:|---:| +| Read-only file, search, and diagnostic tools | yes | yes | yes | +| File write and patch tools | no | yes | yes | +| Shell tools (`exec_shell`, `task_shell_start`, waits, interact, cancel) | no | yes, when `allow_shell = true` | yes | +| Paid or external-service tools | approval-gated | approval-gated | auto-approved | +| Access outside the workspace root | no | only with trust mode | yes | + +If a shell tool is missing from the model-visible catalog in Agent mode, check +`allow_shell` first. The setting can come from the active config/profile or from +the runtime session. YOLO mode turns shell access on together with trust mode and +auto-approval, which is why shell commands may work there even when the Agent +mode catalog does not list them. + All action-capable modes have access to persistent RLM sessions through `rlm_open`, `rlm_eval`, `rlm_configure`, and `rlm_close`. Inside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child calls pinned to `deepseek-v4-flash`. The model reaches for it when work is too large or repetitive for the parent transcript. The fast `deepseek-v4-flash` / thinking-off path is called Fin in the product diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index 4455e3b8c..f56b5512c 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -40,6 +40,11 @@ chosen over the available shell equivalent. Companion to `crates/tui/src/prompts ### Shell +Shell tools are part of the model-visible tool catalog only when shell access is +enabled for the active session or profile. In Agent mode that usually means +`allow_shell = true`; YOLO enables shell access automatically. Plan mode keeps +shell execution off. + | Tool | Niche | |---|---| | `exec_shell` | Run a shell command. Foreground runs are cancellable, but use them only for bounded commands; timeout kills the process and returns a background-rerun hint. | From 9e9326990dcf0d1f74bbe347b9879adc9e09631e Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 01:20:25 +0800 Subject: [PATCH 49/98] docs: polish mode availability table --- docs/MODES.md | 4 ++-- docs/TOOL_SURFACE.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/MODES.md b/docs/MODES.md index 3f7106464..73941bbee 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -25,7 +25,7 @@ Run `/mode` to open the mode picker, or switch directly with `/mode agent`, ### Tool availability by mode | Tool family | Plan | Agent | YOLO | -|---|---:|---:|---:| +|:---|:---:|:---:|:---:| | Read-only file, search, and diagnostic tools | yes | yes | yes | | File write and patch tools | no | yes | yes | | Shell tools (`exec_shell`, `task_shell_start`, waits, interact, cancel) | no | yes, when `allow_shell = true` | yes | @@ -34,7 +34,7 @@ Run `/mode` to open the mode picker, or switch directly with `/mode agent`, If a shell tool is missing from the model-visible catalog in Agent mode, check `allow_shell` first. The setting can come from the active config/profile or from -the runtime session. YOLO mode turns shell access on together with trust mode and +the runtime session. YOLO turns shell access on together with trust mode and auto-approval, which is why shell commands may work there even when the Agent mode catalog does not list them. diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index f56b5512c..250f44830 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -40,7 +40,7 @@ chosen over the available shell equivalent. Companion to `crates/tui/src/prompts ### Shell -Shell tools are part of the model-visible tool catalog only when shell access is +Shell tools appear in the model-visible tool catalog only when shell access is enabled for the active session or profile. In Agent mode that usually means `allow_shell = true`; YOLO enables shell access automatically. Plan mode keeps shell execution off. From cbd6239f3d59f2b1c16d92080d5e8e9ff3968aee Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 1 Jun 2026 17:23:23 -0700 Subject: [PATCH 50/98] docs: mark agent shell tools approval-gated --- docs/MODES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/MODES.md b/docs/MODES.md index 73941bbee..9aba84af8 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -28,7 +28,7 @@ Run `/mode` to open the mode picker, or switch directly with `/mode agent`, |:---|:---:|:---:|:---:| | Read-only file, search, and diagnostic tools | yes | yes | yes | | File write and patch tools | no | yes | yes | -| Shell tools (`exec_shell`, `task_shell_start`, waits, interact, cancel) | no | yes, when `allow_shell = true` | yes | +| Shell tools (`exec_shell`, `task_shell_start`, waits, interact, cancel) | no | approval-gated, when `allow_shell = true` | yes | | Paid or external-service tools | approval-gated | approval-gated | auto-approved | | Access outside the workspace root | no | only with trust mode | yes | From 908a25d0f6b6da4dc611e72c037fe7d4b836e4ff Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 08:18:35 +0800 Subject: [PATCH 51/98] test(mcp): close stale-session mock responses cleanly --- crates/tui/src/mcp.rs | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index 4cdbdc140..70d531fbe 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -4519,6 +4519,12 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; + async fn write_response(socket: &mut tokio::net::TcpStream, response: &[u8]) { + socket.write_all(response).await.unwrap(); + socket.flush().await.unwrap(); + socket.shutdown().await.unwrap(); + } + let _lock = lock_mcp_loopback_tests().await; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -4580,7 +4586,7 @@ mod tests { let response = format!( "HTTP/1.1 200 OK\r\nMcp-Session-Id: {session}\r\nContent-Length: 0\r\n\r\n" ); - socket.write_all(response.as_bytes()).await.unwrap(); + write_response(&mut socket, response.as_bytes()).await; return; } @@ -4596,12 +4602,11 @@ mod tests { if method == "tools/call" && session_header.as_deref() == Some("sess-old") { stale_seen.store(true, AtomicOrdering::SeqCst); - socket - .write_all( - b"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: 27\r\n\r\n{\"error\":\"session expired\"}", - ) - .await - .unwrap(); + write_response( + &mut socket, + b"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: 27\r\n\r\n{\"error\":\"session expired\"}", + ) + .await; return; } @@ -4626,10 +4631,11 @@ mod tests { serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] }) } _ => { - socket - .write_all(b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n") - .await - .unwrap(); + write_response( + &mut socket, + b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n", + ) + .await; return; } }; @@ -4644,7 +4650,7 @@ mod tests { response_body.len(), response_body ); - socket.write_all(response.as_bytes()).await.unwrap(); + write_response(&mut socket, response.as_bytes()).await; }); } }); From 73cd721665045b1646d2febc9497c93ffa82b1e6 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 06:39:59 +0800 Subject: [PATCH 52/98] feat(tui): add mention browser completions --- config.example.toml | 1 + crates/tui/src/settings.rs | 37 ++++++++ crates/tui/src/tui/app.rs | 7 ++ crates/tui/src/tui/file_mention.rs | 34 ++++++- crates/tui/src/tui/ui/tests.rs | 18 ++++ crates/tui/src/working_set.rs | 147 ++++++++++++++++++++++++++++- docs/CONFIGURATION.md | 4 + 7 files changed, 245 insertions(+), 3 deletions(-) diff --git a/config.example.toml b/config.example.toml index fecf15e35..2b2188ec1 100644 --- a/config.example.toml +++ b/config.example.toml @@ -446,6 +446,7 @@ osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTer # # Override: `locale = "zh-Hans"` for Simplified Chinese regardless of OS locale. # # Also settable at runtime: /config locale zh-Hans # # Note: this only affects TUI labels/chrome — it does NOT change model output language. +# mention_menu_behavior = "fuzzy" # fuzzy | browser; browser lists immediate directory children for @-mentions. # ───────────────────────────────────────────────────────────────────────────────── # Feature Flags diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 8e82757d3..299195543 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -230,6 +230,9 @@ pub struct Settings { /// Maximum workspace depth for `@`-mention completion walks. `0` means /// unlimited depth; use with care in very large repositories. pub mention_walk_depth: usize, + /// `@`-mention completion behavior: fuzzy workspace search or deterministic + /// directory browser. + pub mention_menu_behavior: String, /// Show thinking blocks from the model pub show_thinking: bool, /// Show detailed tool output @@ -337,6 +340,7 @@ impl Default for Settings { paste_burst_detection: true, mention_menu_limit: 128, mention_walk_depth: 6, + mention_menu_behavior: "fuzzy".to_string(), show_thinking: true, show_tool_details: true, locale: "auto".to_string(), @@ -559,6 +563,9 @@ impl Settings { "mention_walk_depth" | "mention_depth" | "completions_walk_depth" => { self.mention_walk_depth = parse_usize_setting("mention_walk_depth", value)?; } + "mention_menu_behavior" | "mention_behavior" | "mention_menu" => { + self.mention_menu_behavior = normalize_mention_menu_behavior(value)?; + } "show_thinking" | "thinking" => { self.show_thinking = parse_bool(value)?; } @@ -756,6 +763,10 @@ impl Settings { )); lines.push(format!(" mention_menu_limit: {}", self.mention_menu_limit)); lines.push(format!(" mention_walk_depth: {}", self.mention_walk_depth)); + lines.push(format!( + " mention_behavior: {}", + self.mention_menu_behavior + )); lines.push(format!(" show_thinking: {}", self.show_thinking)); lines.push(format!(" show_tool_details: {}", self.show_tool_details)); lines.push(format!(" locale: {}", self.locale)); @@ -842,6 +853,10 @@ impl Settings { "mention_walk_depth", "Maximum @-mention workspace walk depth; 0 means unlimited (default 6)", ), + ( + "mention_menu_behavior", + "@-mention completion behavior: fuzzy/browser (default fuzzy)", + ), ("show_thinking", "Show model thinking: on/off"), ("show_tool_details", "Show detailed tool output: on/off"), ( @@ -1024,6 +1039,18 @@ fn parse_percent_setting(key: &str, value: &str) -> Result { Ok(percent) } +fn normalize_mention_menu_behavior(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "fuzzy" | "default" => Ok("fuzzy".to_string()), + "browser" | "browse" | "file-browser" | "file_browser" => Ok("browser".to_string()), + _ => { + anyhow::bail!( + "Failed to update setting: invalid mention_menu_behavior '{value}'. Expected: fuzzy, browser." + ) + } + } +} + fn normalize_mode(value: &str) -> &str { match value.trim().to_ascii_lowercase().as_str() { "edit" => "agent", @@ -1261,6 +1288,7 @@ mod tests { let mut settings = Settings::default(); assert_eq!(settings.mention_menu_limit, 128); assert_eq!(settings.mention_walk_depth, 6); + assert_eq!(settings.mention_menu_behavior, "fuzzy"); settings .set("mention_menu_limit", "256") @@ -1268,14 +1296,23 @@ mod tests { settings .set("mention_walk_depth", "0") .expect("allow unlimited walk depth"); + settings + .set("mention_menu_behavior", "browser") + .expect("set mention menu behavior"); assert_eq!(settings.mention_menu_limit, 256); assert_eq!(settings.mention_walk_depth, 0); + assert_eq!(settings.mention_menu_behavior, "browser"); let err = settings .set("mention_walk_depth", "deep") .expect_err("non-numeric depth should fail"); assert!(err.to_string().contains("invalid mention_walk_depth")); + + let err = settings + .set("mention_menu_behavior", "random") + .expect_err("unknown mention behavior should fail"); + assert!(err.to_string().contains("invalid mention_menu_behavior")); } #[test] diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 8ee44f610..1093396fb 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -885,6 +885,9 @@ pub struct MentionCompletionCache { /// Workspace depth limit used for this completion walk. Included so live /// config changes invalidate cached popup results. pub walk_depth: usize, + /// Completion behavior used for this walk. Included so live config changes + /// invalidate cached popup results. + pub behavior: String, /// Cached completion entries. pub entries: Vec, } @@ -1207,6 +1210,9 @@ pub struct App { /// Maximum workspace depth for `@`-mention completion walks. `0` means /// unlimited depth. pub mention_walk_depth: usize, + /// `@`-mention completion behavior: fuzzy workspace search or deterministic + /// directory browser. + pub mention_menu_behavior: String, pub use_bracketed_paste: bool, pub use_paste_burst_detection: bool, /// Set to `true` the first time a real `Event::Paste` arrives during a @@ -2106,6 +2112,7 @@ impl App { .unwrap_or_else(|| default_composer_arrows_scroll(use_mouse_capture)), mention_menu_limit: settings.mention_menu_limit, mention_walk_depth: settings.mention_walk_depth, + mention_menu_behavior: settings.mention_menu_behavior.clone(), session_title: None, receipt_text: None, receipt_started_at: None, diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index 4e9e2d368..fdb721d27 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -162,6 +162,25 @@ pub fn find_file_mention_completions( entries } +/// Deterministic directory-browser completion entry point. This deliberately +/// skips frecency so the popup remains stable for users navigating deep trees. +pub fn find_file_mention_browser_completions( + workspace: &Workspace, + partial: &str, + limit: usize, +) -> Vec { + let entries = workspace.browser_completions(partial, limit); + tracing::debug!( + target: "codewhale_tui::file_mention", + partial = %partial, + workspace = %workspace.root.display(), + cwd = ?std::env::current_dir().ok(), + match_count = entries.len(), + "file mention browser completion walk", + ); + entries +} + /// Build a `Workspace` for the running app: anchors at `app.workspace` and /// captures the process CWD so the resolver and completion walker honor the /// user's launch directory when it differs from `--workspace`. @@ -202,18 +221,24 @@ pub fn visible_mention_menu_entries(app: &mut App, limit: usize) -> Vec let workspace = app.workspace.clone(); let cwd = std::env::current_dir().ok(); let walk_depth = app.mention_walk_depth; + let behavior = app.mention_menu_behavior.clone(); if let Some(ref cache) = app.composer.mention_completion_cache && cache.workspace == workspace && cache.cwd == cwd && cache.partial == partial && cache.limit == limit && cache.walk_depth == walk_depth + && cache.behavior == behavior { return cache.entries.clone(); } let ws = Workspace::with_cwd_and_depth(workspace.clone(), cwd.clone(), walk_depth); - let entries = find_file_mention_completions(&ws, &partial, limit); + let entries = if behavior == "browser" { + find_file_mention_browser_completions(&ws, &partial, limit) + } else { + find_file_mention_completions(&ws, &partial, limit) + }; app.composer.mention_completion_cache = Some(MentionCompletionCache { workspace, @@ -221,6 +246,7 @@ pub fn visible_mention_menu_entries(app: &mut App, limit: usize) -> Vec partial, limit, walk_depth, + behavior, entries: entries.clone(), }); @@ -268,7 +294,11 @@ pub fn try_autocomplete_file_mention(app: &mut App) -> bool { return false; }; let ws = workspace_for_app(app); - let candidates = find_file_mention_completions(&ws, &partial, FILE_MENTION_COMPLETION_LIMIT); + let candidates = if app.mention_menu_behavior == "browser" { + find_file_mention_browser_completions(&ws, &partial, FILE_MENTION_COMPLETION_LIMIT) + } else { + find_file_mention_completions(&ws, &partial, FILE_MENTION_COMPLETION_LIMIT) + }; if candidates.is_empty() { app.status_message = Some(no_file_mention_matches_status( &partial, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 7025f45bc..ed30ca18b 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4843,6 +4843,24 @@ fn mention_popup_lists_workspace_matches_for_cursor_partial() { assert!(!entries.iter().any(|e| e == "README.md")); } +#[test] +fn mention_popup_browser_mode_lists_immediate_directory_children() { + let tmpdir = TempDir::new().expect("tempdir"); + std::fs::create_dir_all(tmpdir.path().join("src/nested")).unwrap(); + std::fs::write(tmpdir.path().join("src/lib.rs"), "lib").unwrap(); + std::fs::write(tmpdir.path().join("src/nested/deep.rs"), "deep").unwrap(); + std::fs::write(tmpdir.path().join("README.md"), "readme").unwrap(); + + let mut app = create_test_app(); + app.workspace = tmpdir.path().to_path_buf(); + app.mention_menu_behavior = "browser".to_string(); + app.input = "look at @src/".to_string(); + app.cursor_position = app.input.chars().count(); + + let entries = visible_mention_menu_entries(&mut app, 8); + assert_eq!(entries, vec!["src/lib.rs", "src/nested/"]); +} + #[test] fn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() { let tmpdir = TempDir::new().expect("tempdir"); diff --git a/crates/tui/src/working_set.rs b/crates/tui/src/working_set.rs index be5567963..4e4ce95e3 100644 --- a/crates/tui/src/working_set.rs +++ b/crates/tui/src/working_set.rs @@ -17,7 +17,7 @@ use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::ffi::OsStr; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::OnceLock; /// Repo-aware resolver for `@`-mentions and file pickers. @@ -274,6 +274,91 @@ impl Workspace { prefix_hits.truncate(limit); prefix_hits } + + /// Deterministic directory-browser completions for `@` mentions. + /// + /// Unlike [`Workspace::completions`], this mode does not fuzzy-rank across + /// the full workspace. It locks onto the directory part of `partial` and + /// returns only that directory's immediate children in case-insensitive + /// alphabetical order. + #[must_use] + pub fn browser_completions(&self, partial: &str, limit: usize) -> Vec { + if limit == 0 { + return Vec::new(); + } + + let normalized = partial.replace('\\', "/"); + let trimmed = normalized.trim_start_matches('/'); + let (dir_part, name_part) = match trimmed.rsplit_once('/') { + Some((dir, name)) => (dir.trim_end_matches('/'), name), + None => ("", trimmed), + }; + let Some(safe_dir_part) = browser_completion_dir_part(dir_part) else { + return Vec::new(); + }; + let dir = if safe_dir_part.as_os_str().is_empty() { + self.root.clone() + } else { + self.root.join(&safe_dir_part) + }; + if !dir.is_dir() { + return Vec::new(); + } + let display_dir_part = safe_dir_part.to_string_lossy().replace('\\', "/"); + + let show_hidden = name_part.starts_with('.'); + let needle = name_part.to_lowercase(); + let mut entries = Vec::new(); + + let mut builder = WalkBuilder::new(&dir); + builder + .hidden(!show_hidden) + .follow_links(false) + .max_depth(Some(1)); + let _ = builder.add_custom_ignore_filename(".deepseekignore"); + + for entry in builder.build().flatten() { + let path = entry.path(); + if path == dir || path_is_excluded_from_discovery(&self.root, path) { + continue; + } + let Some(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_file() && !file_type.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy(); + if !needle.is_empty() && !name.to_lowercase().starts_with(&needle) { + continue; + } + let mut candidate = if display_dir_part.is_empty() { + name.to_string() + } else { + format!("{display_dir_part}/{name}") + }; + if file_type.is_dir() { + candidate.push('/'); + } + entries.push(candidate); + } + + entries.sort_by_key(|entry| entry.to_lowercase()); + entries.truncate(limit); + entries + } +} + +fn browser_completion_dir_part(dir_part: &str) -> Option { + let mut safe = PathBuf::new(); + for component in Path::new(dir_part).components() { + match component { + Component::CurDir => {} + Component::Normal(part) => safe.push(part), + Component::Prefix(_) | Component::RootDir | Component::ParentDir => return None, + } + } + Some(safe) } /// Default directory depth walked when surfacing file-mention completions. @@ -1508,6 +1593,66 @@ mod tests { ); } + #[test] + fn browser_completions_show_only_immediate_children() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join("src/nested")).unwrap(); + std::fs::write(tmp.path().join("src/lib.rs"), "lib").unwrap(); + std::fs::write(tmp.path().join("src/nested/deep.rs"), "deep").unwrap(); + std::fs::write(tmp.path().join("README.md"), "readme").unwrap(); + + let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None); + + let root_entries = ws.browser_completions("", 16); + assert_eq!(root_entries, vec!["README.md", "src/"]); + + let src_entries = ws.browser_completions("src/", 16); + assert_eq!(src_entries, vec!["src/lib.rs", "src/nested/"]); + assert!( + !src_entries.iter().any(|entry| entry.ends_with("deep.rs")), + "browser mode must not walk past immediate children: {src_entries:?}", + ); + } + + #[test] + fn browser_completions_hide_dot_entries_until_dot_query() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join(".agents")).unwrap(); + std::fs::write(tmp.path().join(".env"), "secret-ish fixture").unwrap(); + std::fs::write(tmp.path().join("app.rs"), "app").unwrap(); + + let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None); + + let default_entries = ws.browser_completions("", 16); + assert_eq!(default_entries, vec!["app.rs"]); + + let dot_entries = ws.browser_completions(".", 16); + assert_eq!(dot_entries, vec![".agents/", ".env"]); + } + + #[test] + fn browser_completions_reject_path_escape_segments() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace"); + let sibling = tmp.path().join("outside"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&sibling).unwrap(); + std::fs::write(workspace.join("inside.rs"), "inside").unwrap(); + std::fs::write(sibling.join("secret.rs"), "outside").unwrap(); + + let ws = Workspace::with_cwd(workspace, None); + + assert_eq!(ws.browser_completions("", 16), vec!["inside.rs"]); + assert!( + ws.browser_completions("../", 16).is_empty(), + "browser mode must not list workspace siblings", + ); + assert!( + ws.browser_completions("../outside", 16).is_empty(), + "browser mode must not complete names from outside the workspace", + ); + } + #[test] fn workspace_completions_surface_explicit_hidden_and_ignored_paths() { let tmp = TempDir::new().unwrap(); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e8463569f..230d077b2 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -530,6 +530,10 @@ Common settings keys: - `mention_walk_depth` (integer, default `6`): maximum workspace depth for `@`-mention completion walks. Set to `0` for unlimited depth in deeply nested workspaces; keep the default in very large repos unless needed. +- `mention_menu_behavior` (`fuzzy`, `browser`; default `fuzzy`): controls how + `@`-mention completions are populated. `fuzzy` searches the workspace and + applies mention frecency. `browser` lists only the immediate children of the + currently typed directory segment in deterministic alphabetical order. - `show_thinking` (on/off) - `show_tool_details` (on/off) - `locale` (`auto`, `en`, `ja`, `zh-Hans`, `pt-BR`; default `auto`): UI chrome From f185d469170b1b1d3149683fa3b609a086469276 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 06:54:27 +0800 Subject: [PATCH 53/98] fix(tui): expose mention behavior in config --- crates/tui/src/commands/config.rs | 5 +++++ crates/tui/src/config_ui.rs | 31 +++++++++++++++++++++++++++++++ crates/tui/src/tui/views/mod.rs | 7 +++++++ 3 files changed, 43 insertions(+) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 24d4e4910..39485c6b2 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -595,6 +595,11 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.composer.mention_completion_cache = None; app.needs_redraw = true; } + "mention_menu_behavior" | "mention_behavior" | "mention_menu" => { + app.mention_menu_behavior = settings.mention_menu_behavior.clone(); + app.composer.mention_completion_cache = None; + app.needs_redraw = true; + } "mention_walk_depth" | "mention_depth" | "completions_walk_depth" => { app.mention_walk_depth = settings.mention_walk_depth; app.composer.mention_completion_cache = None; diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index a7cf27f6d..d5632befe 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -68,6 +68,7 @@ pub struct SettingsSection { pub composer_vim_mode: ComposerVimModeValue, #[schemars(range(min = 0))] pub mention_menu_limit: usize, + pub mention_menu_behavior: MentionMenuBehaviorValue, #[schemars(range(min = 0))] pub mention_walk_depth: usize, pub transcript_spacing: TranscriptSpacingValue, @@ -204,6 +205,13 @@ pub enum ComposerVimModeValue { Vim, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MentionMenuBehaviorValue { + Fuzzy, + Browser, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum TranscriptSpacingValue { @@ -332,6 +340,7 @@ pub fn build_document(app: &App, config: &Config) -> Result { composer_border: settings.composer_border, composer_vim_mode: settings.composer_vim_mode.as_str().into(), mention_menu_limit: settings.mention_menu_limit, + mention_menu_behavior: settings.mention_menu_behavior.as_str().into(), mention_walk_depth: settings.mention_walk_depth, transcript_spacing: settings.transcript_spacing.as_str().into(), status_indicator: settings.status_indicator.as_str().into(), @@ -513,6 +522,10 @@ pub fn apply_document( "mention_menu_limit", &doc.settings.mention_menu_limit.to_string(), ), + ( + "mention_menu_behavior", + doc.settings.mention_menu_behavior.as_setting(), + ), ( "mention_walk_depth", &doc.settings.mention_walk_depth.to_string(), @@ -782,6 +795,24 @@ impl From<&str> for ComposerVimModeValue { } } +impl MentionMenuBehaviorValue { + fn as_setting(self) -> &'static str { + match self { + Self::Fuzzy => "fuzzy", + Self::Browser => "browser", + } + } +} + +impl From<&str> for MentionMenuBehaviorValue { + fn from(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "browser" => Self::Browser, + _ => Self::Fuzzy, + } + } +} + impl TranscriptSpacingValue { fn as_setting(self) -> &'static str { match self { diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 616eef6f4..49e213c62 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -772,6 +772,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Composer, + key: "mention_menu_behavior".to_string(), + value: settings.mention_menu_behavior.clone(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Composer, key: "mention_walk_depth".to_string(), From c81cdabc096679c02004b1cdbb756f9106ca90f0 Mon Sep 17 00:00:00 2001 From: reidliu41 Date: Tue, 2 Jun 2026 08:28:12 +0800 Subject: [PATCH 54/98] feat(tui): add bang shell command shortcut Support `! ` and `!command` in the TUI composer to run shell commands through the existing exec_shell path. The shortcut keeps normal approval, sandbox, policy, transcript, and work-panel handling, while avoiding model context pollution from local-only tool results. Refs #1546 --- README.md | 4 + crates/tui/src/core/engine.rs | 260 +++++++++++++++++++++++++++- crates/tui/src/core/engine/tests.rs | 150 ++++++++++++++++ crates/tui/src/core/ops.rs | 14 ++ crates/tui/src/tui/app.rs | 34 ++++ crates/tui/src/tui/ui.rs | 80 +++++++-- crates/tui/src/tui/ui/tests.rs | 82 +++++++++ docs/KEYBINDINGS.md | 1 + 8 files changed, 607 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index df229b627..deb8d51f1 100644 --- a/README.md +++ b/README.md @@ -402,6 +402,10 @@ codewhale mcp-server # run dispatcher MCP stdio ser codewhale update # check for and apply binary updates ``` +Inside the interactive TUI composer, prefix a line with `!` to run a shell +command through the normal approval, sandbox, and output surfaces, for example +`! cargo test -p codewhale-tui sidebar`. + ### Branching Conversations Saved sessions are intentionally branchable. `codewhale fork ` copies diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 9483315a7..8aebf4ae4 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -68,7 +68,7 @@ use super::capacity_memory::{ }; use super::coherence::{CoherenceSignal, CoherenceState, next_coherence_state}; use super::events::{Event, TurnOutcomeStatus}; -use super::ops::Op; +use super::ops::{Op, USER_SHELL_TOOL_ID_PREFIX}; use super::session::Session; use super::tool_parser; use super::turn::{TurnContext, TurnToolCall, post_turn_snapshot, pre_turn_snapshot}; @@ -634,6 +634,248 @@ impl Engine { (engine, handle) } + async fn handle_run_shell_command( + &mut self, + command: String, + mode: AppMode, + trust_mode: bool, + auto_approve: bool, + approval_mode: crate::tui::approval::ApprovalMode, + ) { + self.reset_cancel_token(); + self.turn_counter = self.turn_counter.saturating_add(1); + self.capacity_controller.mark_turn_start(self.turn_counter); + + let turn_id = format!( + "{}{seq}", + USER_SHELL_TOOL_ID_PREFIX, + seq = self.turn_counter + ); + let tool_id = turn_id.clone(); + let tool_name = "exec_shell".to_string(); + let tool_input = json!({ "command": command, "source": "user" }); + let snapshot_prompt = tool_input["command"] + .as_str() + .unwrap_or_default() + .to_string(); + + self.session.trust_mode = trust_mode; + self.config.trust_mode = trust_mode; + self.session.auto_approve = auto_approve; + self.session.approval_mode = if auto_approve { + crate::tui::approval::ApprovalMode::Auto + } else { + approval_mode + }; + + let _ = self + .tx_event + .send(Event::TurnStarted { + turn_id: turn_id.clone(), + }) + .await; + + if self.config.snapshots_enabled { + let pre_workspace = self.session.workspace.clone(); + let pre_seq = self.turn_counter; + let pre_cap = self.config.snapshots_max_workspace_bytes; + let pre_prompt = snapshot_prompt.clone(); + let _ = tokio::task::spawn_blocking(move || { + pre_turn_snapshot(&pre_workspace, pre_seq, pre_cap, Some(&pre_prompt)) + }) + .await; + } + + let _ = self + .tx_event + .send(Event::ToolCallStarted { + id: tool_id.clone(), + name: tool_name.clone(), + input: tool_input.clone(), + }) + .await; + + let tool_context = self.build_tool_context(mode, auto_approve); + let registry = ToolRegistryBuilder::new() + .with_shell_tools() + .build(tool_context); + + let result = if mode == AppMode::Plan { + Err(ToolError::permission_denied( + "Tool 'exec_shell' is unavailable in Plan mode".to_string(), + )) + } else if !self.config.features.enabled(Feature::ShellTool) { + Err(ToolError::not_available( + "Tool 'exec_shell' is disabled by feature flag".to_string(), + )) + } else if let Some(spec) = registry.get(&tool_name) { + let approval_required = spec.approval_requirement() != ApprovalRequirement::Auto + && !registry.context().auto_approve; + if approval_required { + emit_tool_audit(json!({ + "event": "tool.approval_required", + "tool_id": tool_id.clone(), + "tool_name": tool_name.clone(), + "source": "composer_bang", + })); + let approval_key = + crate::tools::approval_cache::build_approval_key(&tool_name, &tool_input).0; + let approval_grouping_key = + crate::tools::approval_cache::build_approval_grouping_key( + &tool_name, + &tool_input, + ) + .0; + let _ = self + .tx_event + .send(Event::ApprovalRequired { + id: tool_id.clone(), + tool_name: tool_name.clone(), + input: tool_input.clone(), + description: spec.description().to_string(), + approval_key, + approval_grouping_key, + intent_summary: None, + }) + .await; + + match self.await_tool_approval(&tool_id).await { + Ok(ApprovalResult::Approved) => { + emit_tool_audit(json!({ + "event": "tool.approval_decision", + "tool_id": tool_id.clone(), + "tool_name": tool_name.clone(), + "decision": "approved", + "source": "composer_bang", + })); + Self::execute_tool_with_lock( + self.tool_exec_lock.clone(), + spec.supports_parallel(), + false, + self.tx_event.clone(), + tool_name.clone(), + tool_input.clone(), + Some(®istry), + None, + None, + ) + .await + } + Ok(ApprovalResult::Denied) => { + emit_tool_audit(json!({ + "event": "tool.approval_decision", + "tool_id": tool_id.clone(), + "tool_name": tool_name.clone(), + "decision": "denied", + "source": "composer_bang", + })); + Err(ToolError::permission_denied(format!( + "Tool '{tool_name}' denied by user" + ))) + } + Ok(ApprovalResult::RetryWithPolicy(policy)) => { + emit_tool_audit(json!({ + "event": "tool.approval_decision", + "tool_id": tool_id.clone(), + "tool_name": tool_name.clone(), + "decision": "retry_with_policy", + "policy": format!("{policy:?}"), + "source": "composer_bang", + })); + let elevated_context = registry + .context() + .clone() + .with_elevated_sandbox_policy(policy); + Self::execute_tool_with_lock( + self.tool_exec_lock.clone(), + spec.supports_parallel(), + false, + self.tx_event.clone(), + tool_name.clone(), + tool_input.clone(), + Some(®istry), + None, + Some(elevated_context), + ) + .await + } + Err(err) => Err(err), + } + } else { + Self::execute_tool_with_lock( + self.tool_exec_lock.clone(), + spec.supports_parallel(), + false, + self.tx_event.clone(), + tool_name.clone(), + tool_input.clone(), + Some(®istry), + None, + None, + ) + .await + } + } else { + Err(ToolError::not_available( + "tool 'exec_shell' is not registered".to_string(), + )) + }; + + let mut result = result; + if let Ok(tool_result) = result.as_mut() + && let Some(path) = crate::tools::truncate::apply_spillover_with_artifact( + tool_result, + &tool_id, + &tool_name, + &self.session.id, + ) + { + emit_tool_audit(json!({ + "event": "tool.spillover", + "tool_id": tool_id.clone(), + "tool_name": tool_name.clone(), + "path": path.display().to_string(), + "source": "composer_bang", + })); + } + + let status = if result.is_err() { + TurnOutcomeStatus::Failed + } else { + TurnOutcomeStatus::Completed + }; + let error = result.as_ref().err().map(ToString::to_string); + + let _ = self + .tx_event + .send(Event::ToolCallComplete { + id: tool_id, + name: tool_name, + result, + }) + .await; + + let _ = self + .tx_event + .send(Event::TurnComplete { + usage: Usage::default(), + status, + error, + tool_catalog: None, + base_url: None, + }) + .await; + + if self.config.snapshots_enabled { + let post_workspace = self.session.workspace.clone(); + let post_seq = self.turn_counter; + let post_cap = self.config.snapshots_max_workspace_bytes; + crate::utils::spawn_blocking_supervised("post-shell-turn-snapshot", move || { + post_turn_snapshot(&post_workspace, post_seq, post_cap, Some(&snapshot_prompt)); + }); + } + } + /// Run the engine event loop #[allow(clippy::too_many_lines)] pub async fn run(mut self) { @@ -675,6 +917,22 @@ impl Engine { ) .await; } + Op::RunShellCommand { + command, + mode, + trust_mode, + auto_approve, + approval_mode, + } => { + self.handle_run_shell_command( + command, + mode, + trust_mode, + auto_approve, + approval_mode, + ) + .await; + } Op::CancelRequest => { self.cancel_token.cancel(); self.reset_cancel_token(); diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 2de6e6460..6470efa78 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -978,6 +978,156 @@ fn deferred_tool_preflight_guides_checklist_update_list_replacement() { assert!(result.content.contains("Use checklist_write")); } +#[tokio::test] +async fn run_shell_command_op_requests_approval_and_executes_shell() { + let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default()); + let handle_for_approval = handle.clone(); + + let task = tokio::spawn(async move { + engine + .handle_run_shell_command( + "echo bang-ok".to_string(), + AppMode::Agent, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + ) + .await; + }); + + let mut saw_started = false; + let mut saw_approval = false; + let mut saw_complete = false; + let mut saw_turn_complete = false; + let mut rx = handle.rx_event.write().await; + while let Some(event) = rx.recv().await { + match event { + Event::TurnStarted { turn_id } => { + assert!(turn_id.starts_with(USER_SHELL_TOOL_ID_PREFIX)); + } + Event::ToolCallStarted { id, name, input } => { + saw_started = true; + assert!(id.starts_with(USER_SHELL_TOOL_ID_PREFIX)); + assert_eq!(name, "exec_shell"); + assert_eq!(input["command"], json!("echo bang-ok")); + assert_eq!(input["source"], json!("user")); + } + Event::ApprovalRequired { id, tool_name, .. } => { + saw_approval = true; + assert!(id.starts_with(USER_SHELL_TOOL_ID_PREFIX)); + assert_eq!(tool_name, "exec_shell"); + handle_for_approval + .approve_tool_call(id) + .await + .expect("approve shell"); + } + Event::ToolCallComplete { id, name, result } => { + saw_complete = true; + assert!(id.starts_with(USER_SHELL_TOOL_ID_PREFIX)); + assert_eq!(name, "exec_shell"); + let result = result.expect("shell result"); + assert!(result.success, "{result:?}"); + assert!(result.content.contains("bang-ok"), "{result:?}"); + } + Event::TurnComplete { status, .. } => { + saw_turn_complete = true; + assert_eq!(status, TurnOutcomeStatus::Completed); + break; + } + _ => {} + } + } + drop(rx); + task.await.expect("shell op task"); + + assert!(saw_started); + assert!(saw_approval); + assert!(saw_complete); + assert!(saw_turn_complete); +} + +#[tokio::test] +async fn run_shell_command_op_skips_approval_when_auto_approved() { + let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default()); + + engine + .handle_run_shell_command( + "echo bang-yolo".to_string(), + AppMode::Yolo, + true, + true, + crate::tui::approval::ApprovalMode::Auto, + ) + .await; + + let mut saw_complete = false; + let mut rx = handle.rx_event.write().await; + while let Some(event) = rx.recv().await { + match event { + Event::ApprovalRequired { .. } => { + panic!("auto-approved shell shortcut should not request approval"); + } + Event::ToolCallComplete { result, .. } => { + saw_complete = true; + let result = result.expect("shell result"); + assert!(result.success, "{result:?}"); + assert!(result.content.contains("bang-yolo"), "{result:?}"); + } + Event::TurnComplete { status, .. } => { + assert_eq!(status, TurnOutcomeStatus::Completed); + break; + } + _ => {} + } + } + + assert!(saw_complete); +} + +#[tokio::test] +async fn run_shell_command_op_preserves_plan_mode_shell_block() { + let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default()); + + engine + .handle_run_shell_command( + "echo blocked".to_string(), + AppMode::Plan, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + ) + .await; + + let mut saw_complete = false; + let mut saw_turn_complete = false; + let mut rx = handle.rx_event.write().await; + while let Some(event) = rx.recv().await { + match event { + Event::ApprovalRequired { .. } => { + panic!("Plan mode shell should be blocked before approval"); + } + Event::ToolCallComplete { name, result, .. } => { + saw_complete = true; + assert_eq!(name, "exec_shell"); + let err = result.expect_err("plan shell should fail"); + assert!( + err.to_string().contains("unavailable in Plan mode"), + "{err}" + ); + } + Event::TurnComplete { status, .. } => { + saw_turn_complete = true; + assert_eq!(status, TurnOutcomeStatus::Failed); + break; + } + _ => {} + } + } + + assert!(saw_complete); + assert!(saw_turn_complete); +} + #[test] fn deferred_tool_preflight_skips_already_active_tools() { let mut tool = api_tool("deferred_tool"); diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index ab61659e4..4260cf0c8 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -9,6 +9,9 @@ use crate::tui::app::AppMode; use crate::tui::approval::ApprovalMode; use std::path::PathBuf; +/// Prefix used for tool-call ids created by local composer shell shortcuts. +pub const USER_SHELL_TOOL_ID_PREFIX: &str = "user_shell_"; + /// Operations that can be submitted to the engine. #[derive(Debug, Clone)] pub enum Op { @@ -40,6 +43,17 @@ pub enum Op { hook_executor: Option>, }, + /// Execute a user-submitted composer shell command (`! `) without + /// sending a model turn. This still routes through `exec_shell`, approval, + /// sandbox, and command-safety handling. + RunShellCommand { + command: String, + mode: AppMode, + trust_mode: bool, + auto_approve: bool, + approval_mode: ApprovalMode, + }, + /// Cancel the current request #[allow(dead_code)] CancelRequest, diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 1093396fb..54d94062d 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -97,6 +97,17 @@ pub(crate) fn looks_like_slash_command_input(input: &str) -> bool { !command.contains('/') } +pub(crate) fn shell_command_from_bang_input(input: &str) -> Result, &'static str> { + let Some(rest) = input.trim_start().strip_prefix('!') else { + return Ok(None); + }; + let command = rest.trim(); + if command.is_empty() { + return Err("Usage: ! "); + } + Ok(Some(command)) +} + fn initial_onboarding_state( skip_onboarding: bool, was_onboarded: bool, @@ -5126,6 +5137,29 @@ mod tests { )); } + #[test] + fn bang_shell_prefix_parses_compact_and_spaced_forms() { + assert_eq!(shell_command_from_bang_input("!pwd"), Ok(Some("pwd"))); + assert_eq!(shell_command_from_bang_input("! pwd"), Ok(Some("pwd"))); + assert_eq!( + shell_command_from_bang_input(" ! cargo test -p codewhale-tui sidebar"), + Ok(Some("cargo test -p codewhale-tui sidebar")) + ); + assert_eq!(shell_command_from_bang_input("normal message"), Ok(None)); + } + + #[test] + fn bang_shell_prefix_rejects_empty_command() { + assert_eq!( + shell_command_from_bang_input("!"), + Err("Usage: ! ") + ); + assert_eq!( + shell_command_from_bang_input("! "), + Err("Usage: ! ") + ); + } + #[test] fn submit_input_records_absolute_slash_path_as_message_history() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3492be82c..b8157015b 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -48,7 +48,7 @@ use crate::config::{ use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent}; use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; -use crate::core::ops::Op; +use crate::core::ops::{Op, USER_SHELL_TOOL_ID_PREFIX}; use crate::hooks::{HookEvent, HookExecutor}; use crate::llm_client::LlmClient; use crate::models::{ @@ -115,7 +115,7 @@ use super::key_actions; use super::app::{ App, AppAction, AppMode, OnboardingState, QueuedMessage, ReasoningEffort, SidebarFocus, StatusToastLevel, SubmitDisposition, TaskPanelEntry, TuiOptions, - looks_like_slash_command_input, + looks_like_slash_command_input, shell_command_from_bang_input, }; use super::approval::{ ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision, @@ -1423,21 +1423,27 @@ async fn run_event_loop( if name == "update_plan" { app.plan_tool_used_in_turn = true; } - let tool_content = match &result { - Ok(output) => sanitize_stream_chunk( - &tool_result_content_for_api_message(app, &id, &name, output).await, - ), - Err(err) => sanitize_stream_chunk(&format!("Error: {err}")), - }; - app.api_messages.push(Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: id.clone(), - content: tool_content, - is_error: None, - content_blocks: None, - }], - }); + if is_model_visible_tool_call(&id) { + let tool_content = match &result { + Ok(output) => sanitize_stream_chunk( + &tool_result_content_for_api_message(app, &id, &name, output) + .await, + ), + Err(err) => sanitize_stream_chunk(&format!("Error: {err}")), + }; + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: id.clone(), + content: tool_content, + is_error: None, + content_blocks: None, + }], + }); + } else { + app.pending_tool_uses + .retain(|(tool_id, _, _)| tool_id != &id); + } handle_tool_call_complete(app, &id, &name, &result); // Immediately refresh the task panel sidebar when a @@ -3514,6 +3520,9 @@ async fn run_event_loop( && !key.modifiers.contains(KeyModifiers::ALT) => { if let Some(input) = app.submit_input() { + if handle_bang_shell_input(app, &engine_handle, &input).await? { + continue; + } if looks_like_slash_command_input(&input) { if execute_command_input( terminal, @@ -3563,6 +3572,9 @@ async fn run_event_loop( // #382: Ctrl+Enter forces a steer into the current turn. KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => { if let Some(input) = app.submit_input() { + if handle_bang_shell_input(app, &engine_handle, &input).await? { + continue; + } if looks_like_slash_command_input(&input) { if execute_command_input( terminal, @@ -3639,6 +3651,9 @@ async fn run_event_loop( handle_memory_quick_add(app, &input, config); continue; } + if handle_bang_shell_input(app, &engine_handle, &input).await? { + continue; + } if looks_like_slash_command_input(&input) { if execute_command_input( terminal, @@ -4767,6 +4782,37 @@ async fn apply_mode_update(app: &mut App, engine_handle: &EngineHandle, mode: Ap } } +async fn handle_bang_shell_input( + app: &mut App, + engine_handle: &EngineHandle, + input: &str, +) -> Result { + let command = match shell_command_from_bang_input(input) { + Ok(Some(command)) => command, + Ok(None) => return Ok(false), + Err(message) => { + app.status_message = Some(format!("Error: {message}")); + return Ok(true); + } + }; + + engine_handle + .send(Op::RunShellCommand { + command: command.to_string(), + mode: app.mode, + trust_mode: app.trust_mode, + auto_approve: app.mode == AppMode::Yolo, + approval_mode: app.approval_mode, + }) + .await?; + app.status_message = Some(format!("Shell command submitted: {command}")); + Ok(true) +} + +fn is_model_visible_tool_call(id: &str) -> bool { + !id.starts_with(USER_SHELL_TOOL_ID_PREFIX) +} + async fn apply_model_and_compaction_update( engine_handle: &EngineHandle, compaction: crate::compaction::CompactionConfig, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index ed30ca18b..3d7bf7f5f 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2930,6 +2930,88 @@ fn event_poll_timeout_has_nonzero_floor() { ); } +#[tokio::test] +async fn bang_shell_input_dispatches_shell_op_instead_of_model_message() { + let mut app = create_test_app(); + app.mode = AppMode::Agent; + app.trust_mode = false; + + let mut engine = mock_engine_handle(); + + let handled = handle_bang_shell_input(&mut app, &engine.handle, "! pwd") + .await + .expect("bang shell handler"); + + assert!(handled); + assert_eq!( + app.status_message.as_deref(), + Some("Shell command submitted: pwd") + ); + + let op = engine.rx_op.recv().await.expect("engine op"); + match op { + Op::RunShellCommand { + command, + mode, + trust_mode, + auto_approve, + approval_mode, + } => { + assert_eq!(command, "pwd"); + assert_eq!(mode, AppMode::Agent); + assert!(!trust_mode); + assert!(!auto_approve); + assert_eq!(approval_mode, ApprovalMode::Suggest); + } + other => panic!("expected RunShellCommand, got {other:?}"), + } +} + +#[tokio::test] +async fn bang_shell_input_dispatches_even_while_turn_is_loading() { + let mut app = create_test_app(); + app.mode = AppMode::Agent; + app.is_loading = true; + + let mut engine = mock_engine_handle(); + + let handled = handle_bang_shell_input(&mut app, &engine.handle, "! echo steer-safe") + .await + .expect("bang shell handler"); + + assert!(handled); + let op = engine.rx_op.recv().await.expect("engine op"); + match op { + Op::RunShellCommand { command, mode, .. } => { + assert_eq!(command, "echo steer-safe"); + assert_eq!(mode, AppMode::Agent); + } + other => panic!("expected RunShellCommand, got {other:?}"), + } +} + +#[tokio::test] +async fn empty_bang_shell_input_is_consumed_with_usage_error() { + let mut app = create_test_app(); + let engine = mock_engine_handle(); + + let handled = handle_bang_shell_input(&mut app, &engine.handle, "! ") + .await + .expect("bang shell handler"); + + assert!(handled); + assert_eq!( + app.status_message.as_deref(), + Some("Error: Usage: ! ") + ); +} + +#[test] +fn local_bang_shell_tool_ids_are_not_model_visible() { + assert!(!is_model_visible_tool_call("user_shell_1")); + assert!(is_model_visible_tool_call("toolu_01abc")); +} + fn complete_release_json(tag: &str) -> serde_json::Value { let assets = REQUIRED_RELEASE_ASSETS .iter() diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index 3e7892ed3..6782e4eef 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -45,6 +45,7 @@ Editing the message you're about to send. | `Alt-R` | Search prompt history (Alt-R to exit) | | `Tab` | Slash-command / `@`-mention completion (popup-aware) | | `Ctrl-O` | Open external editor for the composer draft when it has focus | +| `! command` | Run a shell command through normal approval, sandbox, and output surfaces | ### `@` mentions From 5becfda03b3cac525a80e9f686b3b5bc5c7a6b4f Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Mon, 1 Jun 2026 19:41:35 +0800 Subject: [PATCH 55/98] fix(tui): use effective model window in context inspector --- crates/tui/src/tui/context_inspector.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/context_inspector.rs b/crates/tui/src/tui/context_inspector.rs index f141a7f13..52d4d9fb3 100644 --- a/crates/tui/src/tui/context_inspector.rs +++ b/crates/tui/src/tui/context_inspector.rs @@ -133,7 +133,8 @@ pub fn build_context_inspector_text(app: &App) -> String { } fn context_usage(app: &App) -> (usize, u32, f64) { - let max = context_window_for_model(&app.model).unwrap_or(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS); + let max = context_window_for_model(app.effective_model_for_budget()) + .unwrap_or(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS); let estimated = estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref()); let total_chars = estimate_message_chars(&app.api_messages); @@ -495,6 +496,18 @@ mod tests { assert!(text.contains("Context: critical"), "{text}"); } + #[test] + fn inspector_uses_effective_auto_model_context_window() { + let mut app = test_app(); + app.model = "auto".to_string(); + app.auto_model = true; + app.last_effective_model = Some("deepseek-v4-pro".to_string()); + + let text = build_context_inspector_text(&app); + assert!(text.contains("Model: auto"), "{text}"); + assert!(text.contains("/1000000 tokens"), "{text}"); + } + #[test] fn inspector_no_system_prompt_shows_section() { let app = test_app(); From c148b00e89f89e75bdfadd8e2276b3ac93f77a55 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 09:36:14 +0800 Subject: [PATCH 56/98] fix(npm): prefer binary version output --- npm/codewhale/scripts/run.js | 48 +++++++++++++++++++------------ npm/codewhale/test/run.test.js | 52 +++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 19 deletions(-) diff --git a/npm/codewhale/scripts/run.js b/npm/codewhale/scripts/run.js index 94e3b7e68..e9478374f 100644 --- a/npm/codewhale/scripts/run.js +++ b/npm/codewhale/scripts/run.js @@ -7,31 +7,43 @@ function isVersionFlag(args = process.argv.slice(2)) { return args.includes("--version") || args.includes("-V"); } -function handleVersionFallback(binaryName) { - if (isVersionFlag()) { - const binVersion = - pkg.codewhaleBinaryVersion || pkg.deepseekBinaryVersion || pkg.version; - console.log(`${binaryName} (npm wrapper) v${pkg.version}`); - console.log(`binary version: v${binVersion}`); - console.log(`repo: ${pkg.repository?.url || "N/A"}`); - process.exit(0); - } +function printVersionFallback(binaryName) { + const binVersion = + pkg.codewhaleBinaryVersion || pkg.deepseekBinaryVersion || pkg.version; + console.log(`${binaryName} (npm wrapper) v${pkg.version}`); + console.log(`binary version: v${binVersion}`); + console.log(`repo: ${pkg.repository?.url || "N/A"}`); } -async function run(binaryName) { - // Intercept --version before attempting binary download/launch - handleVersionFallback(binaryName); +async function run(binaryName, options = {}) { + const args = options.args || process.argv.slice(2); + const resolveBinaryPath = options.getBinaryPath || getBinaryPath; + const spawn = options.spawnSync || spawnSync; + const exit = options.exit || process.exit; + const versionFlag = isVersionFlag(args); + + let binaryPath; + try { + binaryPath = await resolveBinaryPath(binaryName); + } catch (error) { + if (versionFlag) { + printVersionFallback(binaryName); + return exit(0); + } + throw error; + } - const binaryPath = await getBinaryPath(binaryName); - const result = spawnSync(binaryPath, process.argv.slice(2), { + const result = spawn(binaryPath, args, { stdio: "inherit", }); if (result.error) { - // If binary fails and user asked for --version, show npm version instead - handleVersionFallback(binaryName); + if (versionFlag) { + printVersionFallback(binaryName); + return exit(0); + } throw result.error; } - process.exit(result.status ?? 1); + return exit(result.status ?? 1); } async function runCodeWhale() { @@ -46,7 +58,7 @@ module.exports = { run, runCodeWhale, runCodeWhaleTui, - _internal: { isVersionFlag }, + _internal: { isVersionFlag, printVersionFallback }, }; if (require.main === module) { diff --git a/npm/codewhale/test/run.test.js b/npm/codewhale/test/run.test.js index 3b471f1e6..5bc03a79c 100644 --- a/npm/codewhale/test/run.test.js +++ b/npm/codewhale/test/run.test.js @@ -1,7 +1,7 @@ const assert = require("node:assert/strict"); const test = require("node:test"); -const { _internal } = require("../scripts/run"); +const { run, _internal } = require("../scripts/run"); test("version fallback handles only version flags", () => { assert.equal(_internal.isVersionFlag(["--version"]), true); @@ -9,3 +9,53 @@ test("version fallback handles only version flags", () => { assert.equal(_internal.isVersionFlag(["-v"]), false); assert.equal(_internal.isVersionFlag(["--verbose"]), false); }); + +test("version flags prefer the installed binary over package metadata", async () => { + let spawned = false; + const exits = []; + + await run("codewhale", { + args: ["--version"], + getBinaryPath: async () => "/tmp/codewhale-test-binary", + spawnSync: (binary, args, options) => { + spawned = true; + assert.equal(binary, "/tmp/codewhale-test-binary"); + assert.deepEqual(args, ["--version"]); + assert.deepEqual(options, { stdio: "inherit" }); + return { status: 0 }; + }, + exit: (status) => { + exits.push(status); + }, + }); + + assert.equal(spawned, true); + assert.deepEqual(exits, [0]); +}); + +test("version flags fall back to package metadata when the binary is unavailable", async () => { + const originalLog = console.log; + const lines = []; + const exits = []; + console.log = (line) => lines.push(line); + try { + await run("codewhale", { + args: ["--version"], + getBinaryPath: async () => { + throw new Error("download unavailable"); + }, + spawnSync: () => { + throw new Error("spawn should not run without a binary"); + }, + exit: (status) => { + exits.push(status); + }, + }); + } finally { + console.log = originalLog; + } + + assert.deepEqual(exits, [0]); + assert.match(lines.join("\n"), /codewhale \(npm wrapper\) v/); + assert.match(lines.join("\n"), /binary version: v/); +}); From 650d1a619513e68ccb9a04286ee0676070d0f854 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 03:24:22 +0800 Subject: [PATCH 57/98] fix(subagent): guard truncated tool calls --- crates/tui/src/tools/subagent/mod.rs | 43 ++++++++++++++++++++++++-- crates/tui/src/tools/subagent/tests.rs | 36 +++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 82e3d9ad2..a4debdf44 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -28,7 +28,7 @@ use crate::client::DeepSeekClient; use crate::config::MAX_SUBAGENTS; use crate::core::events::Event; use crate::llm_client::LlmClient; -use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Tool}; +use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Tool}; use crate::tools::handle::VarHandle; use crate::tools::plan::{PlanState, SharedPlanState}; use crate::tools::registry::{ToolRegistry, ToolRegistryBuilder}; @@ -69,6 +69,10 @@ fn release_resident_leases_for(agent_id: &str) { /// the `SubAgentManager`. const DEFAULT_MAX_STEPS: u32 = u32::MAX; const TOOL_TIMEOUT: Duration = Duration::from_secs(30); +// Non-streaming sub-agents need enough response budget to carry large tool-call +// arguments, especially write_file content. The API bills generated tokens, not +// the requested ceiling. +const SUBAGENT_RESPONSE_MAX_TOKENS: u32 = 16_384; /// Per-step LLM API call timeout. Each `create_message` request must complete /// within this window or the step is treated as timed out. Prevents a single /// stuck API call from blocking the sub-agent indefinitely. @@ -3647,6 +3651,24 @@ fn subagent_failed_sentinel(agent_id: &str, _err: &str) -> String { format!("{payload}") } +fn response_was_truncated(response: &MessageResponse) -> bool { + response.stop_reason.as_deref() == Some("length") +} + +fn truncated_response_tool_results(tool_uses: &[(String, String, Value)]) -> Vec { + tool_uses + .iter() + .map(|(tool_id, tool_name, _)| ContentBlock::ToolResult { + tool_use_id: tool_id.clone(), + content: format!( + "Error: the model response was truncated by max_tokens before the tool call arguments for '{tool_name}' could be fully generated. Split large content into smaller writes and retry." + ), + is_error: Some(true), + content_blocks: None, + }) + .collect() +} + #[allow(clippy::too_many_arguments)] async fn insert_subagent_full_transcript_handle( runtime: &SubAgentRuntime, @@ -3815,7 +3837,7 @@ async fn run_subagent( let request = MessageRequest { model: runtime.model.clone(), messages: messages.clone(), - max_tokens: 4096, + max_tokens: SUBAGENT_RESPONSE_MAX_TOKENS, system: Some(request_system.clone()), tools: Some(tools.clone()), tool_choice: Some(json!({ "type": "auto" })), @@ -3929,6 +3951,23 @@ async fn run_subagent( continue; } + if response_was_truncated(&response) { + emit_agent_progress( + runtime.event_tx.as_ref(), + runtime.mailbox.as_ref(), + &agent_id, + format!( + "step {steps}/{max_steps}: response truncated, returning {} tool error(s)", + tool_uses.len() + ), + ); + messages.push(Message { + role: "user".to_string(), + content: truncated_response_tool_results(&tool_uses), + }); + continue; + } + emit_agent_progress( runtime.event_tx.as_ref(), runtime.mailbox.as_ref(), diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index c8c069605..826166307 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -1500,6 +1500,42 @@ async fn auto_approved_parent_runs_required_tools_in_subagent() { .expect("auto-approved parent should allow writes"); } +#[test] +fn subagent_request_budget_allows_large_write_file_arguments() { + assert_eq!( + SUBAGENT_RESPONSE_MAX_TOKENS, 16_384, + "non-streaming sub-agent tool calls need enough output budget for large write_file arguments" + ); +} + +#[test] +fn truncated_subagent_tool_calls_return_model_visible_errors() { + let tool_uses = vec![( + "toolu_write".to_string(), + "write_file".to_string(), + json!({"path": "report.md", "content": "partial"}), + )]; + + let results = truncated_response_tool_results(&tool_uses); + + assert_eq!(results.len(), 1); + match &results[0] { + ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + .. + } => { + assert_eq!(tool_use_id, "toolu_write"); + assert_eq!(is_error, &Some(true)); + assert!(content.contains("truncated by max_tokens")); + assert!(content.contains("write_file")); + assert!(content.contains("smaller writes")); + } + other => panic!("expected tool error result, got {other:?}"), + } +} + #[test] fn child_cancellation_cascades_from_parent() { let parent = stub_runtime(); From 537afcf07e35b3c818cff0687520db09089f1c1b Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 09:17:41 +0800 Subject: [PATCH 58/98] fix(subagent): cap truncated response retries --- crates/tui/src/tools/subagent/mod.rs | 70 +++++++++++++++++++------- crates/tui/src/tools/subagent/tests.rs | 33 ++++++++++++ 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index a4debdf44..3ab494b52 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -73,6 +73,7 @@ const TOOL_TIMEOUT: Duration = Duration::from_secs(30); // arguments, especially write_file content. The API bills generated tokens, not // the requested ceiling. const SUBAGENT_RESPONSE_MAX_TOKENS: u32 = 16_384; +const MAX_CONSECUTIVE_TRUNCATED_SUBAGENT_RESPONSES: u32 = 5; /// Per-step LLM API call timeout. Each `create_message` request must complete /// within this window or the step is treated as timed out. Prevents a single /// stuck API call from blocking the sub-agent indefinitely. @@ -3669,6 +3670,28 @@ fn truncated_response_tool_results(tool_uses: &[(String, String, Value)]) -> Vec .collect() } +fn truncated_response_text_retry_message() -> Vec { + vec![ContentBlock::Text { + text: "Error: the model response was truncated by max_tokens. No complete tool call was available, so the partial response was not accepted as the sub-agent result. Retry with a shorter response or split the work into smaller steps.".to_string(), + cache_control: None, + }] +} + +fn record_truncated_subagent_response(consecutive: &mut u32) -> Result<()> { + *consecutive = consecutive.saturating_add(1); + if *consecutive > MAX_CONSECUTIVE_TRUNCATED_SUBAGENT_RESPONSES { + return Err(anyhow!( + "Sub-agent response was truncated by max_tokens {count} consecutive times; stopping to avoid an unbounded retry loop.", + count = *consecutive + )); + } + Ok(()) +} + +fn reset_truncated_subagent_responses(consecutive: &mut u32) { + *consecutive = 0; +} + #[allow(clippy::too_many_arguments)] async fn insert_subagent_full_transcript_handle( runtime: &SubAgentRuntime, @@ -3753,6 +3776,7 @@ async fn run_subagent( let mut steps = 0; let mut final_result: Option = None; let mut pending_inputs: VecDeque = VecDeque::new(); + let mut consecutive_truncated_responses = 0; for _step in 0..max_steps { // Cooperative cancellation: bail if this session's token was cancelled @@ -3932,6 +3956,35 @@ async fn run_subagent( content: response.content.clone(), }); + if response_was_truncated(&response) { + final_result = None; + record_truncated_subagent_response(&mut consecutive_truncated_responses)?; + let progress = if tool_uses.is_empty() { + "response truncated, returning retry instruction".to_string() + } else { + format!( + "response truncated, returning {} tool error(s)", + tool_uses.len() + ) + }; + emit_agent_progress( + runtime.event_tx.as_ref(), + runtime.mailbox.as_ref(), + &agent_id, + format!("step {steps}/{max_steps}: {progress}"), + ); + messages.push(Message { + role: "user".to_string(), + content: if tool_uses.is_empty() { + truncated_response_text_retry_message() + } else { + truncated_response_tool_results(&tool_uses) + }, + }); + continue; + } + reset_truncated_subagent_responses(&mut consecutive_truncated_responses); + if tool_uses.is_empty() { while let Ok(input) = input_rx.try_recv() { if input.interrupt { @@ -3951,23 +4004,6 @@ async fn run_subagent( continue; } - if response_was_truncated(&response) { - emit_agent_progress( - runtime.event_tx.as_ref(), - runtime.mailbox.as_ref(), - &agent_id, - format!( - "step {steps}/{max_steps}: response truncated, returning {} tool error(s)", - tool_uses.len() - ), - ); - messages.push(Message { - role: "user".to_string(), - content: truncated_response_tool_results(&tool_uses), - }); - continue; - } - emit_agent_progress( runtime.event_tx.as_ref(), runtime.mailbox.as_ref(), diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 826166307..a2039a46c 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -1536,6 +1536,39 @@ fn truncated_subagent_tool_calls_return_model_visible_errors() { } } +#[test] +fn truncated_subagent_text_response_returns_model_visible_error() { + let results = truncated_response_text_retry_message(); + + assert_eq!(results.len(), 1); + match &results[0] { + ContentBlock::Text { text, .. } => { + assert!(text.contains("truncated by max_tokens")); + assert!(text.contains("No complete tool call was available")); + assert!(text.contains("Retry with a shorter response")); + } + other => panic!("expected text retry message, got {other:?}"), + } +} + +#[test] +fn consecutive_truncated_subagent_responses_are_capped() { + let mut consecutive = 0; + + for _ in 0..MAX_CONSECUTIVE_TRUNCATED_SUBAGENT_RESPONSES { + record_truncated_subagent_response(&mut consecutive).expect("within truncation cap"); + } + + let err = record_truncated_subagent_response(&mut consecutive) + .expect_err("one more truncation should stop the sub-agent"); + assert!(err.to_string().contains("truncated by max_tokens")); + assert!(err.to_string().contains("consecutive")); + + reset_truncated_subagent_responses(&mut consecutive); + record_truncated_subagent_response(&mut consecutive).expect("reset should allow recovery"); + assert_eq!(consecutive, 1); +} + #[test] fn child_cancellation_cascades_from_parent() { let parent = stub_runtime(); From 6144d64914fa5f7d94fcac085d92ede598df56cd Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 08:46:20 +0800 Subject: [PATCH 59/98] fix(config): report legacy config migration --- crates/config/src/lib.rs | 104 +++++++++++++++++++++++++++++++++++++-- crates/tui/src/main.rs | 8 ++- 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 870f3c674..57ea97810 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -2026,18 +2026,34 @@ pub fn default_config_path() -> Result { Ok(primary) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigMigration { + pub legacy_path: PathBuf, + pub primary_path: PathBuf, +} + +impl ConfigMigration { + pub fn user_notice(&self) -> String { + format!( + "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.", + self.legacy_path.display(), + self.primary_path.display() + ) + } +} + /// v0.8.44: one-time migration from `~/.deepseek/config.toml` to /// `~/.codewhale/config.toml`. Called on first launch after the config /// is loaded; copies the legacy file if the primary doesn't exist yet. /// Never overwrites an existing primary config. -pub fn migrate_config_if_needed() -> Result<()> { +pub fn migrate_config_if_needed() -> Result> { let primary = codewhale_home()?.join(CONFIG_FILE_NAME); if primary.exists() { - return Ok(()); + return Ok(None); } let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME); if !legacy.exists() { - return Ok(()); + return Ok(None); } // Copy the config to the new home. if let Some(parent) = primary.parent() { @@ -2050,7 +2066,10 @@ pub fn migrate_config_if_needed() -> Result<()> { legacy.display(), primary.display() ); - Ok(()) + Ok(Some(ConfigMigration { + legacy_path: legacy, + primary_path: primary, + })) } fn parse_bool(raw: &str) -> Result { @@ -3295,6 +3314,83 @@ unix_socket_path = "/tmp/cw-hooks.sock" ); } + #[test] + fn migrate_config_reports_copied_legacy_path() { + let _lock = env_lock(); + struct HomeEnvGuard { + home: Option, + userprofile: Option, + codewhale_home: Option, + } + + impl Drop for HomeEnvGuard { + fn drop(&mut self) { + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + match self.home.take() { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + match self.userprofile.take() { + Some(value) => env::set_var("USERPROFILE", value), + None => env::remove_var("USERPROFILE"), + } + match self.codewhale_home.take() { + Some(value) => env::set_var("CODEWHALE_HOME", value), + None => env::remove_var("CODEWHALE_HOME"), + } + } + } + } + + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let home = std::env::temp_dir().join(format!( + "codewhale-config-migration-{}-{unique}", + std::process::id() + )); + let legacy_dir = home.join(LEGACY_APP_DIR); + let primary_dir = home.join(CODEWHALE_APP_DIR); + fs::create_dir_all(&legacy_dir).expect("legacy dir"); + fs::write( + legacy_dir.join(CONFIG_FILE_NAME), + "provider = \"deepseek\"\n", + ) + .expect("legacy config"); + + let _env = HomeEnvGuard { + home: env::var_os("HOME"), + userprofile: env::var_os("USERPROFILE"), + codewhale_home: env::var_os("CODEWHALE_HOME"), + }; + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + env::set_var("HOME", &home); + env::set_var("USERPROFILE", &home); + env::remove_var("CODEWHALE_HOME"); + } + + let migration = migrate_config_if_needed() + .expect("migration") + .expect("legacy config should be copied"); + + assert_eq!(migration.legacy_path, legacy_dir.join(CONFIG_FILE_NAME)); + assert_eq!(migration.primary_path, primary_dir.join(CONFIG_FILE_NAME)); + let notice = migration.user_notice(); + assert!(notice.contains(&legacy_dir.join(CONFIG_FILE_NAME).display().to_string())); + assert!(notice.contains(&primary_dir.join(CONFIG_FILE_NAME).display().to_string())); + assert!(notice.contains(".codewhale path for future edits")); + assert!(notice.contains(".deepseek file remains only as a compatibility fallback")); + assert_eq!( + fs::read_to_string(primary_dir.join(CONFIG_FILE_NAME)).expect("primary config"), + "provider = \"deepseek\"\n" + ); + + let _ = fs::remove_dir_all(home); + } + #[test] fn normalize_config_file_path_rejects_traversal() { let err = normalize_config_file_path(PathBuf::from("../config.toml")) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 376514f8a..f6934d037 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -5068,8 +5068,12 @@ async fn run_interactive( // v0.8.44: migrate config from ~/.deepseek/ to ~/.codewhale/ on first // launch. Non-fatal — existing installs keep working either way. - if let Err(err) = codewhale_config::migrate_config_if_needed() { - logging::warn(format!("Config migration skipped: {err}")); + match codewhale_config::migrate_config_if_needed() { + Ok(Some(migration)) => { + eprintln!("{}", migration.user_notice()); + } + Ok(None) => {} + Err(err) => logging::warn(format!("Config migration skipped: {err}")), } let model = config.default_model(); From eff4e99a2cfa65807472d9d85dbe9460edb2993e Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 09:01:36 +0800 Subject: [PATCH 60/98] test(config): stabilize migration home on windows --- crates/config/src/lib.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 57ea97810..9e6523026 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -3320,6 +3320,10 @@ unix_socket_path = "/tmp/cw-hooks.sock" struct HomeEnvGuard { home: Option, userprofile: Option, + #[cfg(windows)] + homedrive: Option, + #[cfg(windows)] + homepath: Option, codewhale_home: Option, } @@ -3335,6 +3339,16 @@ unix_socket_path = "/tmp/cw-hooks.sock" Some(value) => env::set_var("USERPROFILE", value), None => env::remove_var("USERPROFILE"), } + #[cfg(windows)] + match self.homedrive.take() { + Some(value) => env::set_var("HOMEDRIVE", value), + None => env::remove_var("HOMEDRIVE"), + } + #[cfg(windows)] + match self.homepath.take() { + Some(value) => env::set_var("HOMEPATH", value), + None => env::remove_var("HOMEPATH"), + } match self.codewhale_home.take() { Some(value) => env::set_var("CODEWHALE_HOME", value), None => env::remove_var("CODEWHALE_HOME"), @@ -3363,12 +3377,24 @@ unix_socket_path = "/tmp/cw-hooks.sock" let _env = HomeEnvGuard { home: env::var_os("HOME"), userprofile: env::var_os("USERPROFILE"), + #[cfg(windows)] + homedrive: env::var_os("HOMEDRIVE"), + #[cfg(windows)] + homepath: env::var_os("HOMEPATH"), codewhale_home: env::var_os("CODEWHALE_HOME"), }; // Safety: test-only environment mutation is serialized by env_lock(). unsafe { env::set_var("HOME", &home); env::set_var("USERPROFILE", &home); + #[cfg(windows)] + { + let mut components = home.components(); + if let Some(std::path::Component::Prefix(prefix)) = components.next() { + env::set_var("HOMEDRIVE", prefix.as_os_str()); + env::set_var("HOMEPATH", components.as_path()); + } + } env::remove_var("CODEWHALE_HOME"); } From 0842b3f52848eff484bd063195da0ae5ee5ac201 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 09:23:00 +0800 Subject: [PATCH 61/98] test(config): use real legacy home on windows --- crates/config/src/lib.rs | 70 +++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 9e6523026..68082ad4b 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -3320,10 +3320,6 @@ unix_socket_path = "/tmp/cw-hooks.sock" struct HomeEnvGuard { home: Option, userprofile: Option, - #[cfg(windows)] - homedrive: Option, - #[cfg(windows)] - homepath: Option, codewhale_home: Option, } @@ -3339,16 +3335,6 @@ unix_socket_path = "/tmp/cw-hooks.sock" Some(value) => env::set_var("USERPROFILE", value), None => env::remove_var("USERPROFILE"), } - #[cfg(windows)] - match self.homedrive.take() { - Some(value) => env::set_var("HOMEDRIVE", value), - None => env::remove_var("HOMEDRIVE"), - } - #[cfg(windows)] - match self.homepath.take() { - Some(value) => env::set_var("HOMEPATH", value), - None => env::remove_var("HOMEPATH"), - } match self.codewhale_home.take() { Some(value) => env::set_var("CODEWHALE_HOME", value), None => env::remove_var("CODEWHALE_HOME"), @@ -3357,6 +3343,34 @@ unix_socket_path = "/tmp/cw-hooks.sock" } } + struct LegacyConfigGuard { + path: PathBuf, + original: Option>, + } + + impl LegacyConfigGuard { + fn install(path: PathBuf, contents: &[u8]) -> Self { + let original = fs::read(&path).ok(); + fs::create_dir_all(path.parent().expect("legacy config parent")) + .expect("legacy dir"); + fs::write(&path, contents).expect("legacy config"); + Self { path, original } + } + } + + impl Drop for LegacyConfigGuard { + fn drop(&mut self) { + if let Some(original) = self.original.take() { + let _ = fs::write(&self.path, original); + } else { + let _ = fs::remove_file(&self.path); + if let Some(parent) = self.path.parent() { + let _ = fs::remove_dir(parent); + } + } + } + } + let unique = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock") @@ -3365,44 +3379,32 @@ unix_socket_path = "/tmp/cw-hooks.sock" "codewhale-config-migration-{}-{unique}", std::process::id() )); + #[cfg(windows)] + let legacy_dir = legacy_deepseek_home().expect("legacy home"); + #[cfg(not(windows))] let legacy_dir = home.join(LEGACY_APP_DIR); let primary_dir = home.join(CODEWHALE_APP_DIR); - fs::create_dir_all(&legacy_dir).expect("legacy dir"); - fs::write( - legacy_dir.join(CONFIG_FILE_NAME), - "provider = \"deepseek\"\n", - ) - .expect("legacy config"); + let legacy_config = legacy_dir.join(CONFIG_FILE_NAME); + let _legacy = + LegacyConfigGuard::install(legacy_config.clone(), b"provider = \"deepseek\"\n"); let _env = HomeEnvGuard { home: env::var_os("HOME"), userprofile: env::var_os("USERPROFILE"), - #[cfg(windows)] - homedrive: env::var_os("HOMEDRIVE"), - #[cfg(windows)] - homepath: env::var_os("HOMEPATH"), codewhale_home: env::var_os("CODEWHALE_HOME"), }; // Safety: test-only environment mutation is serialized by env_lock(). unsafe { env::set_var("HOME", &home); env::set_var("USERPROFILE", &home); - #[cfg(windows)] - { - let mut components = home.components(); - if let Some(std::path::Component::Prefix(prefix)) = components.next() { - env::set_var("HOMEDRIVE", prefix.as_os_str()); - env::set_var("HOMEPATH", components.as_path()); - } - } - env::remove_var("CODEWHALE_HOME"); + env::set_var("CODEWHALE_HOME", &primary_dir); } let migration = migrate_config_if_needed() .expect("migration") .expect("legacy config should be copied"); - assert_eq!(migration.legacy_path, legacy_dir.join(CONFIG_FILE_NAME)); + assert_eq!(migration.legacy_path, legacy_config); assert_eq!(migration.primary_path, primary_dir.join(CONFIG_FILE_NAME)); let notice = migration.user_notice(); assert!(notice.contains(&legacy_dir.join(CONFIG_FILE_NAME).display().to_string())); From 556e0b46fbefa735974b58719a11dd7d923426e8 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Tue, 2 Jun 2026 01:14:31 +0800 Subject: [PATCH 62/98] fix(tui): use theme colors in sidebar panels instead of hardcoded palette constants The task, work, agents, and context sidebar panels were using hardcoded palette::DEEPSEEK_SKY, palette::TEXT_MUTED, palette::STATUS_WARNING etc. constants (Whale dark theme colors) that never change when the user switches themes. This caused the sidebar content to remain in Whale dark colors even after switching to Claude, Catppuccin, Dracula, or other community themes. Root cause: - task_panel_lines() used palette::DEEPSEEK_SKY, TEXT_MUTED, STATUS_* etc. - work_panel_lines() and helpers used palette::TEXT_MUTED, STATUS_* etc. - subagent_panel_lines() used palette::DEEPSEEK_SKY, TEXT_DIM, STATUS_* etc. - render_context_panel() used palette::DEEPSEEK_SKY, TEXT_MUTED, TEXT_DIM - tool_status_marker() returned hardcoded palette::STATUS_* colors - agent_status_marker() returned hardcoded palette::STATUS_* colors Fix: - All sidebar panel functions now accept &UiTheme and use theme fields - task_panel_lines: uses app.ui_theme directly - work_panel_lines: passes ui_theme to all helpers - subagent_panel_lines: accepts theme parameter - render_context_panel: uses app.ui_theme - tool_status_marker/agent_status_marker: accept theme parameter - All palette::DEEPSEEK_SKY -> theme.accent_primary - All palette::TEXT_MUTED -> theme.text_muted - All palette::TEXT_DIM -> theme.text_dim - All palette::STATUS_WARNING -> theme.warning - All palette::STATUS_SUCCESS -> theme.success - All palette::STATUS_ERROR -> theme.error_fg This ensures sidebar panels immediately reflect the active theme when switching, without requiring a conversation turn to trigger a refresh. Also creates a theme modification guide for future contributors. --- crates/tui/src/tui/sidebar.rs | 211 ++++++++++++++++++++++------------ 1 file changed, 136 insertions(+), 75 deletions(-) diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index fbf5e9851..89316271a 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -286,20 +286,21 @@ fn work_panel_lines( content_width: usize, max_rows: usize, palette_mode: palette::PaletteMode, + ui_theme: &palette::UiTheme, ) -> Vec> { let theme = Theme::for_palette_mode(palette_mode); let mut lines: Vec> = Vec::with_capacity(max_rows.max(4)); - push_work_goal_lines(summary, content_width, max_rows, &mut lines); + push_work_goal_lines(summary, content_width, max_rows, &mut lines, ui_theme); if summary.state_updating && lines.len() < max_rows { lines.push(Line::from(Span::styled( "Work state updating...", - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(ui_theme.text_muted), ))); } - push_work_checklist_lines(summary, content_width, max_rows, &mut lines); + push_work_checklist_lines(summary, content_width, max_rows, &mut lines, ui_theme); push_work_strategy_lines(summary, content_width, max_rows, &mut lines, &theme); if summary.cycle_count > 0 && lines.len() < max_rows { @@ -309,14 +310,14 @@ fn work_panel_lines( summary.cycle_count, summary.cycle_count.saturating_add(1) ), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(ui_theme.text_muted), ))); } if lines.is_empty() { lines.push(Line::from(Span::styled( work_panel_empty_hint(content_width), - Style::default().fg(palette::TEXT_MUTED).italic(), + Style::default().fg(ui_theme.text_muted).italic(), ))); } @@ -328,6 +329,7 @@ fn push_work_goal_lines( content_width: usize, max_rows: usize, lines: &mut Vec>, + theme: &palette::UiTheme, ) { let Some(objective) = summary.goal_objective.as_deref() else { return; @@ -339,11 +341,11 @@ fn push_work_goal_lines( let icon = if summary.goal_completed { "✓" } else { "◆" }; let status_style = if summary.goal_completed { Style::default() - .fg(palette::STATUS_SUCCESS) + .fg(theme.success) .add_modifier(ratatui::style::Modifier::BOLD) } else { Style::default() - .fg(palette::STATUS_WARNING) + .fg(theme.warning) .add_modifier(ratatui::style::Modifier::BOLD) }; @@ -368,7 +370,7 @@ fn push_work_goal_lines( }; lines.push(Line::from(Span::styled( truncate_line_to_width(&elapsed_str, content_width), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); } @@ -393,7 +395,7 @@ fn push_work_goal_lines( &format!("tokens: {}/{} {}", summary.tokens_used, budget, bar), content_width, ), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); } } @@ -403,6 +405,7 @@ fn push_work_checklist_lines( content_width: usize, max_rows: usize, lines: &mut Vec>, + theme: &palette::UiTheme, ) { if summary.checklist_items.is_empty() || lines.len() >= max_rows { return; @@ -417,11 +420,11 @@ fn push_work_checklist_lines( lines.push(Line::from(vec![ Span::styled( format!("{}%", summary.checklist_completion_pct), - Style::default().fg(palette::STATUS_SUCCESS).bold(), + Style::default().fg(theme.success).bold(), ), Span::styled( format!(" complete ({completed}/{total})"), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ), ])); @@ -442,9 +445,9 @@ fn push_work_checklist_lines( .min(summary.checklist_items.len()); for item in summary.checklist_items[start..end].iter() { let (prefix, color) = match item.status { - TodoStatus::Pending => ("[ ]", palette::TEXT_MUTED), - TodoStatus::InProgress => ("[~]", palette::STATUS_WARNING), - TodoStatus::Completed => ("[✓]", palette::STATUS_SUCCESS), + TodoStatus::Pending => ("[ ]", theme.text_muted), + TodoStatus::InProgress => ("[~]", theme.warning), + TodoStatus::Completed => ("[✓]", theme.success), }; let text = format!("{prefix} #{} {}", item.id, item.content); lines.push(Line::from(Span::styled( @@ -464,7 +467,7 @@ fn push_work_checklist_lines( }; lines.push(Line::from(Span::styled( label, - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); } } @@ -574,6 +577,7 @@ fn render_sidebar_work(f: &mut Frame, area: Rect, app: &mut App) { content_width.max(1), usable_rows, app.ui_theme.mode, + &app.ui_theme, ); let full_texts: Vec = lines.iter().map(|l| spans_to_text(&l.spans)).collect(); @@ -602,6 +606,7 @@ struct SidebarToolRow { } fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec> { + let theme = &app.ui_theme; let mut lines: Vec> = Vec::with_capacity(max_rows.max(4)); if let Some(turn_id) = app.runtime_turn_id.as_ref() { @@ -619,14 +624,14 @@ fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec Vec palette::TEXT_MUTED, - "running" => palette::STATUS_WARNING, - "completed" => palette::STATUS_SUCCESS, - "failed" => palette::STATUS_ERROR, - "canceled" => palette::TEXT_DIM, - _ => palette::TEXT_MUTED, + "queued" => theme.text_muted, + "running" => theme.warning, + "completed" => theme.success, + "failed" => theme.error_fg, + "canceled" => theme.text_dim, + _ => theme.text_muted, }; let duration = task .duration_ms @@ -672,7 +677,7 @@ fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec Vec /jobs cancel-all", content_width.max(1)), Style::default() - .fg(palette::TEXT_MUTED) + .fg(theme.text_muted) .add_modifier(ratatui::style::Modifier::ITALIC), ))); } @@ -693,8 +698,8 @@ fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec Vec Vec>, label: &str, color: ratatui::style::Color) { +fn push_sidebar_label_theme(lines: &mut Vec>, label: &str, theme: &palette::UiTheme) { lines.push(Line::from(Span::styled( label.to_string(), - Style::default().fg(color).bold(), + Style::default().fg(theme.accent_primary).bold(), ))); } @@ -847,12 +852,13 @@ fn push_tool_rows( rows: &[SidebarToolRow], content_width: usize, max_rows: usize, + theme: &palette::UiTheme, ) { for row in rows { if lines.len() >= max_rows { break; } - let (marker, color) = tool_status_marker(row.status); + let (marker, color) = tool_status_marker(row.status, theme); let label = if let Some(duration_ms) = row.duration_ms { format!("{marker} {} {}", row.name, format_duration_ms(duration_ms)) } else { @@ -868,7 +874,7 @@ fn push_tool_rows( " {}", truncate_line_to_width(&row.summary, content_width.saturating_sub(2).max(1)) ), - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(theme.text_dim), ))); } } @@ -1428,11 +1434,14 @@ fn first_nonempty_line(text: &str) -> &str { .unwrap_or("") } -fn tool_status_marker(status: ToolStatus) -> (&'static str, ratatui::style::Color) { +fn tool_status_marker( + status: ToolStatus, + theme: &palette::UiTheme, +) -> (&'static str, ratatui::style::Color) { match status { - ToolStatus::Running => ("[~]", palette::STATUS_WARNING), - ToolStatus::Success => ("[✓]", palette::STATUS_SUCCESS), - ToolStatus::Failed => ("[!]", palette::STATUS_ERROR), + ToolStatus::Running => ("[~]", theme.warning), + ToolStatus::Success => ("[✓]", theme.success), + ToolStatus::Failed => ("[!]", theme.error_fg), } } @@ -1493,7 +1502,13 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &mut App) { role_counts, }; let rows = sidebar_agent_rows(app); - let lines = subagent_panel_lines(&summary, &rows, content_width, usable_rows.max(1)); + let lines = subagent_panel_lines( + &summary, + &rows, + content_width, + usable_rows.max(1), + &app.ui_theme, + ); render_sidebar_section(f, area, "Agents", lines, Vec::new(), app); } @@ -1608,6 +1623,7 @@ pub fn subagent_panel_lines( rows: &[SidebarAgentRow], content_width: usize, max_rows: usize, + theme: &palette::UiTheme, ) -> Vec> { let mut lines: Vec> = Vec::with_capacity(max_rows.max(4)); @@ -1619,7 +1635,7 @@ pub fn subagent_panel_lines( { lines.push(Line::from(Span::styled( "No agents", - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); return lines; } @@ -1637,17 +1653,14 @@ pub fn subagent_panel_lines( vec![ Span::styled( format!("{live_running} running"), - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - ), - Span::styled( - format!(" / {total}"), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.accent_primary).bold(), ), + Span::styled(format!(" / {total}"), Style::default().fg(theme.text_muted)), ] } else { vec![Span::styled( format!("{done} done"), - Style::default().fg(palette::STATUS_SUCCESS), + Style::default().fg(theme.success), )] }; lines.push(Line::from(header)); @@ -1661,7 +1674,7 @@ pub fn subagent_panel_lines( let role_line = mix.join(" \u{00B7} "); lines.push(Line::from(Span::styled( truncate_line_to_width(&role_line, content_width.max(1)), - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(theme.text_dim), ))); } @@ -1669,7 +1682,7 @@ pub fn subagent_panel_lines( if lines.len() >= max_rows { break; } - let (marker, color) = agent_status_marker(row.status.as_str()); + let (marker, color) = agent_status_marker(row.status.as_str(), theme); let label = format!("{marker} {} {}", row.role, row.name); lines.push(Line::from(Span::styled( truncate_line_to_width(&label, content_width.max(1)), @@ -1706,16 +1719,16 @@ pub fn subagent_panel_lines( content_width.saturating_sub(2).max(1) ) ), - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(theme.text_dim), ))); } if summary.foreground_rlm_running { lines.push(Line::from(vec![ - Span::styled("RLM", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled("RLM", Style::default().fg(theme.accent_primary).bold()), Span::styled( " foreground work active", - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(theme.text_dim), ), ])); } @@ -1723,13 +1736,16 @@ pub fn subagent_panel_lines( lines } -fn agent_status_marker(status: &str) -> (&'static str, ratatui::style::Color) { +fn agent_status_marker( + status: &str, + theme: &palette::UiTheme, +) -> (&'static str, ratatui::style::Color) { match status { - "running" => ("[~]", palette::STATUS_WARNING), - "done" => ("[✓]", palette::STATUS_SUCCESS), - "failed" => ("[!]", palette::STATUS_ERROR), - "canceled" | "interrupted" => ("[-]", palette::TEXT_MUTED), - _ => ("[ ]", palette::TEXT_MUTED), + "running" => ("[~]", theme.warning), + "done" => ("[✓]", theme.success), + "failed" => ("[!]", theme.error_fg), + "canceled" | "interrupted" => ("[-]", theme.text_muted), + _ => ("[ ]", theme.text_muted), } } @@ -1744,6 +1760,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { return; } + let theme = &app.ui_theme; let content_width = area.width.saturating_sub(4) as usize; let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); @@ -1757,11 +1774,11 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { lines.push(Line::from(vec![ Span::styled( truncate_line_to_width(&ws_name, content_width.max(1)), - Style::default().fg(palette::DEEPSEEK_SKY).bold(), + Style::default().fg(theme.accent_primary).bold(), ), Span::styled( format!(" {}", app.workspace_context.as_deref().unwrap_or("")), - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(theme.text_dim), ), ])); @@ -1788,7 +1805,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { window, truncate_line_to_width(&bar, content_width.saturating_sub(32).max(8)) ), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); // ── Session cost ───────────────────────────────────────────── @@ -1811,7 +1828,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { }; lines.push(Line::from(Span::styled( cost_line, - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); // ── MCP servers ────────────────────────────────────────────── @@ -1826,7 +1843,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { "mcp: {} server(s){}", app.mcp_configured_count, restart_hint ), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); } @@ -1834,7 +1851,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { let lsp_label = if app.lsp_enabled { "on" } else { "off" }; lines.push(Line::from(Span::styled( format!("lsp: {lsp_label}"), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); // ── Cycles ─────────────────────────────────────────────────── @@ -1845,7 +1862,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { app.cycle_count, app.cycle_briefings.len() ), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); } @@ -1865,7 +1882,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { .unwrap_or_else(|_| "—".to_string()); lines.push(Line::from(Span::styled( format!("memory: {} ({})", app.memory_path.display(), size_hint), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); } @@ -1959,6 +1976,7 @@ mod tests { work_panel_lines, }; use crate::config::Config; + use crate::palette; use crate::palette::PaletteMode; use crate::tools::plan::StepStatus; use crate::tools::todo::TodoStatus; @@ -2155,7 +2173,13 @@ mod tests { ..SidebarWorkSummary::default() }; - let text = lines_to_text(&work_panel_lines(&summary, 80, 16, PaletteMode::Dark)); + let text = lines_to_text(&work_panel_lines( + &summary, + 80, + 16, + PaletteMode::Dark, + &palette::UI_THEME, + )); assert!( text[0].starts_with("33% complete (1/3)"), @@ -2191,7 +2215,13 @@ mod tests { ..SidebarWorkSummary::default() }; - let text = lines_to_text(&work_panel_lines(&summary, 80, 6, PaletteMode::Dark)); + let text = lines_to_text(&work_panel_lines( + &summary, + 80, + 6, + PaletteMode::Dark, + &palette::UI_THEME, + )); assert!( text.iter() @@ -2212,6 +2242,7 @@ mod tests { 80, 16, PaletteMode::Dark, + &palette::UI_THEME, )); assert!( !empty_text.iter().any(|line| line.contains("Strategy")), @@ -2222,7 +2253,13 @@ mod tests { strategy_explanation: Some("High-level sequencing".to_string()), ..SidebarWorkSummary::default() }; - let text = lines_to_text(&work_panel_lines(&summary, 80, 16, PaletteMode::Dark)); + let text = lines_to_text(&work_panel_lines( + &summary, + 80, + 16, + PaletteMode::Dark, + &palette::UI_THEME, + )); assert!( text.iter().any(|line| line == "Strategy metadata"), "non-empty plan should show strategy label: {text:?}" @@ -2703,7 +2740,7 @@ mod tests { #[test] fn navigator_empty_state_says_no_agents() { let summary = SidebarSubagentSummary::default(); - let lines = subagent_panel_lines(&summary, &[], 32, 8); + let lines = subagent_panel_lines(&summary, &[], 32, 8, &palette::UI_THEME); let text = lines_to_text(&lines); assert_eq!(text, vec!["No agents".to_string()]); } @@ -2743,7 +2780,13 @@ mod tests { duration_ms: Some(21_000), }, ]; - let text = lines_to_text(&subagent_panel_lines(&summary, &rows, 64, 12)); + let text = lines_to_text(&subagent_panel_lines( + &summary, + &rows, + 64, + 12, + &palette::UI_THEME, + )); assert!(text[0].contains("2 running"), "header: {:?}", text[0]); assert!(text[0].contains("/ 3"), "total in header: {:?}", text[0]); assert!( @@ -2774,7 +2817,13 @@ mod tests { role_counts: std::collections::BTreeMap::new(), }; - let text = lines_to_text(&subagent_panel_lines(&summary, &[], 64, 8)); + let text = lines_to_text(&subagent_panel_lines( + &summary, + &[], + 64, + 8, + &palette::UI_THEME, + )); assert!(text[0].contains("1 running"), "header: {:?}", text[0]); assert!(text[0].contains("/ 6"), "fanout total: {:?}", text[0]); @@ -2793,7 +2842,13 @@ mod tests { foreground_rlm_running: false, role_counts, }; - let text = lines_to_text(&subagent_panel_lines(&summary, &[], 32, 8)); + let text = lines_to_text(&subagent_panel_lines( + &summary, + &[], + 32, + 8, + &palette::UI_THEME, + )); assert!(text[0].contains("1 done"), "settled header: {:?}", text[0]); } @@ -2813,7 +2868,7 @@ mod tests { foreground_rlm_running: false, role_counts, }; - let lines = subagent_panel_lines(&summary, &[], 16, 8); + let lines = subagent_panel_lines(&summary, &[], 16, 8, &palette::UI_THEME); let role_line: &str = lines[1] .spans .first() @@ -2831,7 +2886,13 @@ mod tests { foreground_rlm_running: true, ..SidebarSubagentSummary::default() }; - let text = lines_to_text(&subagent_panel_lines(&summary, &[], 64, 8)); + let text = lines_to_text(&subagent_panel_lines( + &summary, + &[], + 64, + 8, + &palette::UI_THEME, + )); assert!(!text[0].contains("No agents"), "header: {text:?}"); assert!( From b1cc344d21c0634278e1fb7660be377789e5090b Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Tue, 2 Jun 2026 01:38:00 +0800 Subject: [PATCH 63/98] fix(tui): force full repaint on theme switch to prevent stale sidebar colors When switching themes, ratatui's incremental diff engine may miss color-only changes in sidebar cells that were rendered with theme-resolved UiTheme fields rather than palette constants routed through the backend remap layer. This manifests as the sidebar retaining the previous theme's colors until a window resize or conversation turn triggers a full repaint. Add a force_next_full_repaint flag on App that is set whenever a theme or background_color ConfigUpdated event is processed. The main render loop merges this into the existing force_terminal_repaint mechanism, which clears the terminal and redraws every cell. --- crates/tui/src/tui/app.rs | 6 ++++++ crates/tui/src/tui/ui.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 54d94062d..8c517c6b5 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1504,6 +1504,11 @@ pub struct App { pub session_started_at: chrono::DateTime, /// Whether the UI needs to be redrawn. pub needs_redraw: bool, + /// When true, the next draw will be a full repaint (terminal clear + + /// all cells redrawn) instead of a ratatui incremental diff. Used by + /// theme switches where the diff engine may miss color-only changes + /// in sidebar cells that were previously rendered with palette constants. + pub force_next_full_repaint: bool, /// When the current thinking block started (for duration tracking). pub thinking_started_at: Option, /// Whether context compaction is currently in progress. @@ -2094,6 +2099,7 @@ impl App { decision_card: None, session_started_at: chrono::Utc::now(), needs_redraw: true, + force_next_full_repaint: false, thinking_started_at: None, is_compacting: false, is_purging: false, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b8157015b..76e7cc87f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2383,6 +2383,12 @@ async fn run_event_loop( } else { None }; + // Merge the per-app full-repaint hint (set by theme switches) + // into the loop-level flag before the draw decision. + if app.force_next_full_repaint { + force_terminal_repaint = true; + app.force_next_full_repaint = false; + } if app.needs_redraw && draw_wait.is_none() { let was_full_repaint = force_terminal_repaint; draw_app_frame_inner(terminal, app, force_terminal_repaint)?; @@ -6807,6 +6813,19 @@ async fn handle_view_events( persist, } => { let result = commands::set_config_value(app, &key, &value, persist); + // Theme / background changes require a full terminal repaint + // because ratatui's incremental diff may miss color-only + // changes in cells that were rendered with theme-resolved + // colors (sidebar panels) rather than palette constants that + // go through the backend remap layer. A full repaint + // (terminal clear + all cells redrawn) guarantees every cell + // picks up the new theme immediately. + if matches!( + key.as_str(), + "theme" | "ui_theme" | "background_color" | "background" | "bg" + ) { + app.force_next_full_repaint = true; + } // Only surface the "key = value" confirmation when the // change is being persisted. Live-preview events // (`persist: false`, e.g. arrow keys in the theme picker) From baba81cfb9febc7a4ece932c68a70bf987abbc80 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 09:58:22 +0800 Subject: [PATCH 64/98] fix(tui): show session timestamps in listings --- README.md | 7 +++++- crates/tui/src/session_manager.rs | 28 +++++++++++++++++++++- crates/tui/src/tui/session_picker.rs | 36 +++++++++++++++++++++++++++- docs/GUIDE.md | 5 ++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index deb8d51f1..39713d19e 100644 --- a/README.md +++ b/README.md @@ -388,7 +388,7 @@ codewhale doctor --json # machine-readable diagnostics codewhale setup --status # read-only setup status codewhale setup --tools --plugins # scaffold tool/plugin dirs codewhale models # list live API models -codewhale sessions # list saved sessions +codewhale sessions # list saved sessions with timestamps codewhale resume --last # resume the most recent session in this workspace codewhale resume # resume a specific session by UUID codewhale fork # fork a saved session into a sibling path @@ -414,6 +414,11 @@ id in metadata, and opens that fork so you can explore an alternate direction without polluting the original path. The session picker and `codewhale sessions` mark forked sessions with their parent id. +`codewhale sessions` lists saved sessions across workspaces and includes the +last-updated timestamp. `codewhale resume --last` and `codewhale --continue` +choose the latest session for the current workspace; pass an explicit session id +when resuming work from another directory. + Inside the TUI, Esc-Esc backtrack can rewind the active transcript to a prior user prompt and put that prompt back in the composer for editing. `/restore` and `revert_turn` are separate workspace rollback tools: they restore files diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 9feb84ee9..cb0282258 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -957,6 +957,7 @@ fn truncate_title(s: &str, max_len: usize) -> String { /// Format a session for display in a picker pub fn format_session_line(meta: &SessionMetadata) -> String { let age = format_age(&meta.updated_at); + let updated = format_session_updated_at(&meta.updated_at, &age); let truncated_title = truncate_title(extract_title(&meta.title), 40); let fork_label = meta .parent_session_id @@ -970,10 +971,14 @@ pub fn format_session_line(meta: &SessionMetadata) -> String { truncated_title, meta.message_count, fork_label, - age + updated ) } +pub(crate) fn format_session_updated_at(dt: &DateTime, age: &str) -> String { + format!("{} ({age})", dt.format("%Y-%m-%d %H:%M UTC")) +} + /// Format a datetime as relative age fn format_age(dt: &DateTime) -> String { let now = Utc::now(); @@ -1480,6 +1485,27 @@ mod tests { assert_eq!(format_age(&day_ago), "3d ago"); } + #[test] + fn format_session_line_includes_absolute_updated_timestamp() { + let mut session = create_saved_session( + &[make_test_message("user", "Find Friday work")], + "test-model", + Path::new("/tmp/project"), + 100, + None, + ); + session.metadata.updated_at = DateTime::parse_from_rfc3339("2026-06-01T12:34:00Z") + .expect("timestamp") + .with_timezone(&Utc); + + let line = format_session_line(&session.metadata); + + assert!( + line.contains("2026-06-01 12:34 UTC"), + "session list should include an absolute timestamp, got {line:?}" + ); + } + #[test] fn test_update_session() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index 1cfbad951..2543644ac 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -692,7 +692,8 @@ fn build_list_lines( } fn format_session_line(session: &SessionMetadata) -> String { - let updated = format_relative_time(&session.updated_at); + let age = format_relative_time(&session.updated_at); + let updated = crate::session_manager::format_session_updated_at(&session.updated_at, &age); let raw_title = extract_title(&session.title); let title = if raw_title == "Session" { truncate(crate::session_manager::truncate_id(&session.id), 32) @@ -1111,6 +1112,39 @@ mod tests { assert!(span.style.add_modifier.contains(Modifier::BOLD)); } + #[test] + fn build_list_lines_includes_absolute_updated_timestamp() { + let mut session = test_session(1, "last friday thread"); + session.updated_at = DateTime::parse_from_rfc3339("2026-06-01T12:34:00Z") + .expect("timestamp") + .with_timezone(&Utc); + let lines = build_list_lines( + &[session], + 0, + 120, + 0, + 5, + false, + "", + "recent", + false, + false, + "", + None, + ); + + let rendered = lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::>() + .join("\n"); + assert!( + rendered.contains("2026-06-01 12:34 UTC"), + "session picker should include an absolute timestamp, got {rendered:?}" + ); + } + #[test] fn build_list_lines_marks_fork_lineage() { let mut forked = test_session(1, "forked path"); diff --git a/docs/GUIDE.md b/docs/GUIDE.md index c02ece24e..d09d414b3 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -508,6 +508,11 @@ CodeWhale saves sessions. Use the session picker or resume/continue CLI paths documented in the README and modes guide. For a risky experiment, fork the session before changing direction. +The `/sessions` picker starts scoped to the current workspace so resumes stay +attached to the project you opened. Press `a` in the picker to show sessions +from every workspace, or run `codewhale sessions` to list all saved sessions +with last-updated timestamps before resuming a specific id. + ### What should I do when the model gets confused? Stop and restate the goal, constraints, and current evidence. If the transcript From a41a3825c51332bd8bb66dfc8d14b9bf12f78829 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 10:07:52 +0800 Subject: [PATCH 65/98] docs(runtime): outline receipt export boundary --- docs/RECEIPTS.md | 139 ++++++++++++++++++++++++++++++++++++++++++++ docs/RUNTIME_API.md | 7 +++ 2 files changed, 146 insertions(+) create mode 100644 docs/RECEIPTS.md diff --git a/docs/RECEIPTS.md b/docs/RECEIPTS.md new file mode 100644 index 000000000..91e031b17 --- /dev/null +++ b/docs/RECEIPTS.md @@ -0,0 +1,139 @@ +# Runtime Receipts + +This document sketches a future read-only receipt export for completed runtime +turns. It is a protocol note, not an implemented endpoint. + +The goal is to let a local supervisor audit one completed turn without +screen-scraping the terminal transcript. A receipt should summarize the durable +runtime records that CodeWhale already owns: thread metadata, turn status, turn +items, event sequence lineage, usage when available, approval decisions, and +side-effect boundaries. + +## Non-Goals + +A receipt is not a safety certification, provider compatibility certification, +or hosted attestation. It must not call providers, execute tools, write memory, +write project files, mutate runtime state, or expose API keys. + +Receipts should not export raw chain-of-thought or private reasoning by default. +When reasoning custody is represented, use stable item ids, counts, hashes, or +explicit `unavailable` fields rather than raw hidden content. + +## Candidate Surfaces + +Potential local-only surfaces: + +```text +codewhale receipt export --thread --turn --format json +GET /v1/threads/{thread_id}/turns/{turn_id}/receipt +``` + +Both surfaces should share the existing runtime API auth boundary. They should +only read persisted runtime records and append-only events. + +## Current Data Sources + +The current runtime store already persists the core inputs a receipt builder +would need: + +- `ThreadRecord`: model, workspace, mode, shell/trust/auto-approve flags, + title, task linkage, and latest turn metadata. +- `TurnRecord`: turn status, input summary, timestamps, duration, usage, error, + steer count, and item ids. +- `TurnItemRecord`: item kind, lifecycle status, summary, optional detail, + metadata, artifact refs, and item timestamps. +- `RuntimeEventRecord`: thread id, turn id, item id, event name, JSON payload, + timestamp, and monotonic `seq` values per runtime store. + +Not every receipt field can be filled from those records today. If a provider or +store does not persist a value, the receipt should say `available: false` or +`unavailable`, not infer it from UI text. + +## Draft Schema Shape + +```json +{ + "schema_id": "codewhale.conformance-receipt/v0", + "thread": { + "id": "thr_...", + "model": "deepseek-v4-pro", + "mode": "agent", + "auto_approve": false, + "trust_mode": false, + "allow_shell": false + }, + "turn": { + "id": "turn_...", + "status": "completed", + "started_at": "2026-06-02T01:00:00Z", + "ended_at": "2026-06-02T01:00:12Z", + "duration_ms": 12000 + }, + "reasoning_custody": { + "raw_reasoning_exported": false, + "available": false, + "reason": "reasoning blocks are not persisted as receipt-ready records" + }, + "tool_lineage": { + "tool_call_count": 1, + "tool_result_count": 1, + "unmatched_tool_call_ids": [], + "unmatched_tool_result_ids": [] + }, + "usage_evidence": { + "available": true, + "usage": { + "prompt_tokens": 123, + "completion_tokens": 45 + }, + "provider_cache_breakdown_available": false + }, + "source_event_lineage": { + "first_seq": 10, + "last_seq": 42, + "event_count": 33, + "missing_event_ranges": [] + }, + "side_effect_boundary": { + "approval_required_count": 1, + "approval_allowed_count": 0, + "approval_denied_count": 1, + "command_execution_count": 0, + "file_change_count": 0, + "sandbox_denied_count": 0 + }, + "claim_ceiling": [ + "local_receipt_only", + "not_safety_certification", + "not_provider_compatibility_certification" + ] +} +``` + +## Builder Rules + +A receipt builder should be deterministic and conservative: + +1. Load the thread and turn by id, then reject mismatched `thread_id` values. +2. Load only item ids referenced by the turn. +3. Read event records for the thread and filter by `turn_id`. +4. Preserve event sequence boundaries with `first_seq`, `last_seq`, and any + detected gaps. +5. Count approval, command, file, sandbox, and tool events from typed records or + known event names only. +6. Mark unavailable evidence explicitly instead of deriving it from free-form + summaries. +7. Emit no raw tool output beyond existing item summaries unless a later schema + adds a separate redaction policy. + +## Incremental Implementation Path + +The safest implementation path is: + +1. Land this protocol note and settle field names/non-goals. +2. Add protocol structs and JSON snapshot fixtures for completed, failed, and + approval-denied turns. +3. Add a pure builder over `ThreadRecord`, `TurnRecord`, `TurnItemRecord`, and + `RuntimeEventRecord`. +4. Expose the local runtime API endpoint. +5. Add the CLI export command and optional validation mode. diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 9ebfada8c..a3582c82c 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -22,6 +22,10 @@ macOS workbench (or any local supervisor) The engine runs as a local-only process. All APIs bind to `localhost` by default. No hosted relay, no provider-token custody, no secret leakage. +For a proposed read-only audit export over completed turns, see +[`docs/RECEIPTS.md`](RECEIPTS.md). That document is a protocol note; the receipt +CLI/API surfaces are not implemented yet. + ## ACP stdio adapter: `codewhale serve --acp` `codewhale serve --acp` speaks JSON-RPC 2.0 over newline-delimited stdio for @@ -215,6 +219,9 @@ accept an empty string to clear a previously-set value. Added in v0.8.10 (#562): **Events** (SSE replay + live stream) - `GET /v1/threads/{id}/events?since_seq=` +**Receipts** (future read-only audit export) +- Proposed only: `GET /v1/threads/{thread_id}/turns/{turn_id}/receipt` + **Compatibility stream** (one-shot, backwards-compatible) - `POST /v1/stream` From c92f3c350b8ea171b220049e67fed4b69459cba8 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Mon, 1 Jun 2026 19:19:53 +0800 Subject: [PATCH 66/98] feat(tui): expose current model in turn metadata --- crates/tui/src/core/engine.rs | 5 ++++- crates/tui/src/core/engine/tests.rs | 3 +++ docs/MODES.md | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 8aebf4ae4..62523d339 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1206,7 +1206,10 @@ impl Engine { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); - let mut lines = vec![format!("Current local date: {today}")]; + let mut lines = vec![ + format!("Current local date: {today}"), + format!("Current model: {routed_model}"), + ]; if auto_model { lines.push(format!("Auto model route: {routed_model}")); } diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 6470efa78..bf40ca53a 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1877,6 +1877,7 @@ fn working_set_reaches_model_as_turn_metadata() { fn turn_metadata_includes_current_local_date_without_working_set() { let tmp = tempdir().expect("tempdir"); let config = EngineConfig { + model: "deepseek-v4-flash".to_string(), workspace: tmp.path().to_path_buf(), ..Default::default() }; @@ -1896,6 +1897,7 @@ fn turn_metadata_includes_current_local_date_without_working_set() { let today = chrono::Local::now().format("%Y-%m-%d").to_string(); assert!(text.starts_with("\n")); assert!(text.contains(&format!("Current local date: {today}"))); + assert!(text.contains("Current model: deepseek-v4-flash")); } #[test] @@ -1919,6 +1921,7 @@ fn turn_metadata_includes_auto_model_route() { panic!("expected text metadata block"); }; + assert!(text.contains("Current model: deepseek-v4-pro")); assert!(text.contains("Auto model route: deepseek-v4-pro")); assert!(text.contains("Auto reasoning effort: max")); assert!(!text.contains("debug this regression")); diff --git a/docs/MODES.md b/docs/MODES.md index 9aba84af8..3064084c5 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -9,6 +9,10 @@ Model selection is separate. `--model auto` and `/model auto` route each turn to a concrete model and thinking level; they are not TUI modes and are not part of the `Tab` cycle. +Each user turn includes a small `` block with the current local date +and the concrete model sent to the provider. When `--model auto` is active, the +same block also records that the model was auto-routed. + ## TUI Modes Press `Tab` to complete composer menus, queue a draft as a next-turn follow-up From c2c36cca1188898309f5958dc8ce5fa35908062f Mon Sep 17 00:00:00 2001 From: songzhenrui Date: Mon, 25 May 2026 10:36:19 +0800 Subject: [PATCH 67/98] feat: add NSIS installer and classroom admin checklist Closes #1983 - Add scripts/installer/codewhale.nsi: NSIS installer that installs both codewhale.exe and codewhale-tui.exe to %LOCALAPPDATA%\Programs\CodeWhale\bin, adds to current-user PATH, and includes an uninstaller that cleans PATH - Add docs/CLASSROOM_INSTALL.md: step-by-step checklist for IT admins deploying CodeWhale in labs/classrooms, covering silent install, manual fallback, API key provisioning, imaging notes, and troubleshooting - Update docs/INSTALL.md: add Windows NSIS Installer section referencing the new installer and classroom checklist --- docs/CLASSROOM_INSTALL.md | 178 ++++++++++++++++++++++++++++ docs/INSTALL.md | 37 ++++++ scripts/installer/codewhale.nsi | 202 ++++++++++++++++++++++++++++++++ 3 files changed, 417 insertions(+) create mode 100644 docs/CLASSROOM_INSTALL.md create mode 100644 scripts/installer/codewhale.nsi diff --git a/docs/CLASSROOM_INSTALL.md b/docs/CLASSROOM_INSTALL.md new file mode 100644 index 000000000..7ea5b7798 --- /dev/null +++ b/docs/CLASSROOM_INSTALL.md @@ -0,0 +1,178 @@ +# CodeWhale Classroom / Lab Install Checklist + +A step-by-step checklist for IT admins deploying CodeWhale on lab or classroom +machines running Windows. + +> **Audience**: IT staff, teaching assistants, lab managers. +> **Prereq**: Each target machine runs Windows 10 (1809+) or Windows 11. + +--- + +## Pre-install checklist (run once per machine) + +| # | Task | Done? | +|---|------|-------| +| 1 | Confirm Windows version: `winver` → 10 build 17763+ or 11 | ☐ | +| 2 | Ensure the user account is a **standard user** (not a local admin). The installer does not require elevation. | ☐ | +| 3 | Verify outbound HTTPS (port 443) is open to `api.openai.com` (or whichever LLM provider the course uses). | ☐ | +| 4 | Obtain the installer: download `CodeWhaleSetup.exe` from the [latest release](https://github.com/Hmbown/CodeWhale/releases/latest) or from your department mirror. | ☐ | +| 5 | (Optional) Verify SHA-256 hash matches the published manifest. | ☐ | + +--- + +## Installation + +### Option A — Silent install (recommended for imaging / SCCM / Intune) + +```powershell +# Run as admin or via deployment tool +CodeWhaleSetup.exe /S +``` + +The silent installer: +- Installs to `%LOCALAPPDATA%\Programs\CodeWhale\bin` +- Adds the bin directory to the **current user** PATH +- Registers in Windows "Apps & Features" for uninstall + +### Option B — Interactive install + +1. Double-click `CodeWhaleSetup.exe`. +2. Accept the license. +3. Choose the install directory (default is fine for most setups). +4. Click **Install**. + +### Option C — Manual fallback (no installer) + +If the NSIS installer is blocked by group policy, install manually: + +```powershell +# 1. Create directory +$binDir = "$env:LOCALAPPDATA\Programs\CodeWhale\bin" +New-Item -ItemType Directory -Force -Path $binDir + +# 2. Download binaries (adjust URL to your mirror or release tag) +$tag = "v0.9.0" # replace with desired version +Invoke-WebRequest -Uri "https://github.com/Hmbown/CodeWhale/releases/download/$tag/codewhale-x64.exe" -OutFile "$binDir\codewhale.exe" +Invoke-WebRequest -Uri "https://github.com/Hmbown/CodeWhale/releases/download/$tag/codewhale-tui-x64.exe" -OutFile "$binDir\codewhale-tui.exe" + +# 3. Add to user PATH (persistent) +$currentPath = [Environment]::GetEnvironmentVariable("Path", "User") +if ($currentPath -notlike "*$binDir*") { + [Environment]::SetEnvironmentVariable("Path", "$currentPath;$binDir", "User") +} + +# 4. Refresh current session PATH +$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") +``` + +--- + +## Post-install verification + +Run these on **each machine** (or spot-check a sample): + +| # | Command | Expected output | Done? | +|---|---------|-----------------|-------| +| 1 | `codewhale --version` | Prints version string | ☐ | +| 2 | `codewhale doctor` | All checks pass | ☐ | +| 3 | `codewhale-tui --version` | Prints version string | ☐ | + +If `codewhale` is not found, the user may need to open a **new** terminal window for PATH changes to take effect. + +--- + +## API key provisioning + +Each student needs an API key. Options: + +| Method | Pros | Cons | +|--------|------|------| +| **Per-student key** | Individual usage tracking | More key management | +| **Shared lab key** | Simple to deploy | Harder to audit; rate limits shared | + +### Deploying a shared key via environment variable + +```powershell +# Set for current user (persists across reboots) +[Environment]::SetEnvironmentVariable("OPENAI_API_KEY", "sk-...", "User") +``` + +Or create a `config.toml` in `%APPDATA%\codewhale\`: + +```toml +[provider] +api_key = "sk-..." +base_url = "https://api.openai.com/v1" +``` + +### Deploying per-student keys with Intune / GPO + +Use a Group Policy Preference or Intune PowerShell script to set the +`OPENAI_API_KEY` environment variable per user. The variable name depends on +your LLM provider — see [CONFIGURATION.md](CONFIGURATION.md). + +--- + +## Uninstall + +### Silent uninstall + +```powershell +& "$env:LOCALAPPDATA\Programs\CodeWhale\Uninstall.exe" /S +``` + +### Manual uninstall (if installer was not used) + +```powershell +$binDir = "$env:LOCALAPPDATA\Programs\CodeWhale\bin" +Remove-Item -Recurse -Force (Split-Path $binDir) + +# Remove from PATH +$currentPath = [Environment]::GetEnvironmentVariable("Path", "User") +$newPath = ($currentPath -split ";" | Where-Object { $_ -ne $binDir }) -join ";" +[Environment]::SetEnvironmentVariable("Path", $newPath, "User") +``` + +--- + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `codewhale` not found after install | Open a **new** terminal. If still missing, check PATH: `echo $env:Path` | +| `MISSING_COMPANION_BINARY` | Ensure both `codewhale.exe` and `codewhale-tui.exe` are in the same directory | +| `TLS handshake` errors | Check proxy settings or use the CNB mirror (see [INSTALL.md](INSTALL.md)) | +| Antivirus quarantines binaries | Add the install directory to AV exclusions | +| `codewhale doctor` fails API check | Verify `OPENAI_API_KEY` is set or `config.toml` exists | + +--- + +## Imaging / Golden Image Notes + +If building a golden image (WIM/FFU): + +1. Install CodeWhale using Option A (silent) or Option C (manual). +2. Do **not** set API keys in the image — these are per-user/per-student. +3. The install directory (`%LOCALAPPDATA%\Programs\CodeWhale\bin`) is per-user, + so it will be present for the user who installed it. For other users on the + same machine, run the installer again or use Option C. +4. Alternatively, install to a shared location like `C:\Tools\CodeWhale\bin` + and add it to the **machine** PATH: + ```powershell + [Environment]::SetEnvironmentVariable("Path", "$env:Path;C:\Tools\CodeWhale\bin", "Machine") + ``` + +--- + +## Quick Reference: All file paths + +| Item | Default location | +|------|-----------------| +| Binaries | `%LOCALAPPDATA%\Programs\CodeWhale\bin\` | +| User config | `%APPDATA%\codewhale\config.toml` | +| Uninstaller | `%LOCALAPPDATA%\Programs\CodeWhale\Uninstall.exe` | +| PATH entry | `HKCU\Environment\Path` (current user) | + +--- + +*Last updated: 2026-05-25* diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 44aa6a547..30ab955fa 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -299,6 +299,43 @@ Scoop manifests are maintained outside this repository's release workflow and can lag GitHub/npm/Cargo releases. Use npm or manual GitHub release downloads when you need the newest version immediately. +### Windows NSIS Installer + +A standalone NSIS-based installer is available for Windows users who prefer a +traditional double-click setup (no npm, no Scoop, no Cargo required). + +**Download** `CodeWhaleSetup.exe` from the +[Releases page](https://github.com/Hmbown/CodeWhale/releases/latest). + +**Install** by double-clicking the setup executable. The installer: + +- Installs `codewhale.exe` and `codewhale-tui.exe` side-by-side into + `%LOCALAPPDATA%\Programs\CodeWhale\bin` +- Adds the install directory to the **current user** `PATH` +- Registers in Windows **Apps & Features** for easy uninstall + +**Silent install** (for IT admins, SCCM, Intune): + +```powershell +CodeWhaleSetup.exe /S +``` + +**Build the installer yourself** (requires [NSIS](https://nsis.sourceforge.io)): + +```powershell +cd scripts\installer +# Place codewhale.exe and codewhale-tui.exe here, then: +makensis /DVERSION=0.9.0 codewhale.nsi +``` + +**Manual fallback** — if the installer is blocked by group policy, see the +[CLASSROOM_INSTALL.md](CLASSROOM_INSTALL.md) guide for step-by-step PowerShell +commands. + +> **Deploying to a classroom or lab?** See the full +> [Classroom Install Checklist](CLASSROOM_INSTALL.md) for silent install, +> API key provisioning, imaging notes, and troubleshooting. + --- ## 7. Build from source diff --git a/scripts/installer/codewhale.nsi b/scripts/installer/codewhale.nsi new file mode 100644 index 000000000..a88477867 --- /dev/null +++ b/scripts/installer/codewhale.nsi @@ -0,0 +1,202 @@ +; codewhale.nsi — NSIS installer for CodeWhale (Windows) +; +; Requirements (see https://github.com/Hmbown/CodeWhale/issues/1983): +; - Install codewhale.exe and codewhale-tui.exe side-by-side +; - Default to %LOCALAPPDATA%\Programs\CodeWhale\bin +; - Add install dir to current-user PATH +; - Uninstaller removes the PATH entry +; +; Usage: +; 1. Place both .exe files next to this script: +; codewhale.exe +; codewhale-tui.exe +; 2. Build: +; makensis codewhale.nsi +; 3. Output: CodeWhaleSetup.exe (in current directory) +; +; You can override version at build time: +; makensis /DVERSION=1.2.3 codewhale.nsi + +;-------------------------------- +; Includes +;-------------------------------- +!include "MUI2.nsh" +!include "FileFunc.nsh" +!include "StrFunc.nsh" + +${StrStr} + +;-------------------------------- +; General +;-------------------------------- +!ifndef VERSION + !define VERSION "0.0.0" +!endif + +!define PRODUCT_NAME "CodeWhale" +!define PRODUCT_PUBLISHER "Hmbown" +!define PRODUCT_WEB_SITE "https://github.com/Hmbown/CodeWhale" + +Name "${PRODUCT_NAME} ${VERSION}" +OutFile "CodeWhaleSetup.exe" +InstallDir "$LOCALAPPDATA\Programs\CodeWhale" +RequestExecutionLevel user +BrandingText "${PRODUCT_NAME} Installer" + +;-------------------------------- +; Interface Settings +;-------------------------------- +!define MUI_ABORTWARNING +!define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install.ico" +!define MUI_UNICON "${NSISDIR}\Contrib\Graphics\Icons\modern-uninstall.ico" + +;-------------------------------- +; Pages +;-------------------------------- +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_LICENSE "..\..\LICENSE" +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + +;-------------------------------- +; Languages +;-------------------------------- +!insertmacro MUI_LANGUAGE "English" +!insertmacro MUI_LANGUAGE "SimpChinese" + +;-------------------------------- +; Installer Sections +;-------------------------------- +Section "Install" SecInstall + SetOutPath "$INSTDIR\bin" + + ; Copy binaries + File "codewhale.exe" + File "codewhale-tui.exe" + + ; Write uninstaller + WriteUninstaller "$INSTDIR\Uninstall.exe" + + ; Add to current-user PATH + ; Read existing PATH, append if not already present + ReadRegStr $0 HKCU "Environment" "Path" + ${StrStr} $1 $0 "$INSTDIR\bin" + StrCmp $1 "" 0 path_already_set + ; Not found — append + StrCmp $0 "" empty_path + WriteRegExpandStr HKCU "Environment" "Path" "$0;$INSTDIR\bin" + Goto path_done + empty_path: + WriteRegExpandStr HKCU "Environment" "Path" "$INSTDIR\bin" + path_done: + ; Notify the system about the environment change + SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000 + path_already_set: + + ; Store install directory for uninstaller + WriteRegStr HKCU "Software\${PRODUCT_NAME}" "InstallDir" "$INSTDIR" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayName" "${PRODUCT_NAME}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\"" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "QuietUninstallString" "$\"$INSTDIR\Uninstall.exe$\" /S" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "Publisher" "${PRODUCT_PUBLISHER}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "URLInfoAbout" "${PRODUCT_WEB_SITE}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayVersion" "${VERSION}" + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "NoModify" 1 + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "NoRepair" 1 + + ; Calculate and store installed size + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "EstimatedSize" "$0" +SectionEnd + +;-------------------------------- +; Uninstaller Section +;-------------------------------- +Section "Uninstall" + ; Remove binaries + Delete "$INSTDIR\bin\codewhale.exe" + Delete "$INSTDIR\bin\codewhale-tui.exe" + Delete "$INSTDIR\Uninstall.exe" + RMDir "$INSTDIR\bin" + RMDir "$INSTDIR" + + ; Remove from current-user PATH + ReadRegStr $0 HKCU "Environment" "Path" + ${StrStr} $1 $0 "$INSTDIR\bin" + StrCmp $1 "" path_clean_done + ; Remove the entry + ; Build new PATH without the install dir + ; This handles: "...\path;$INSTDIR\bin" and "$INSTDIR\bin;...\path" and standalone + Push "$0" + Push "$INSTDIR\bin" + Call un.RemoveFromPath + Pop $0 + WriteRegExpandStr HKCU "Environment" "Path" "$0" + SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000 + path_clean_done: + + ; Remove registry keys + DeleteRegKey HKCU "Software\${PRODUCT_NAME}" + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" +SectionEnd + +;-------------------------------- +; Helper: Remove a directory from PATH +; Input: PATH string (on stack), directory to remove (on stack) +; Output: cleaned PATH (on stack) +;-------------------------------- +Function un.RemoveFromPath + Exch $R0 ; directory to remove + Exch + Exch $R1 ; original PATH + Push $R2 + Push $R3 + Push $R4 + + StrCpy $R2 "" + StrCpy $R3 "" + + loop: + ${StrStr} $R4 $R1 $R0 + StrCmp $R4 "" done + ; Found — get substring before match + StrLen $R4 $R4 + StrLen $R3 $R1 + IntOp $R3 $R3 - $R4 + StrCpy $R2 $R1 $R3 + ; Get substring after match + dir length + StrLen $R3 $R0 + IntOp $R4 $R4 - $R3 + StrCpy $R3 $R1 "" $R4 + ; Strip leading semicolon from remainder + StrCpy $R4 $R3 1 + StrCmp $R4 ";" 0 +2 + StrCpy $R3 $R3 "" 1 + ; Strip trailing semicolon from prefix + StrLen $R4 $R2 + IntOp $R4 $R4 - 1 + StrCpy $R4 $R2 1 $R4 + StrCmp $R4 ";" 0 +2 + StrCpy $R2 $R2 $R4 + ; Concatenate + StrCmp $R2 "" 0 +2 + StrCpy $R2 $R3 + Goto done + StrCmp $R3 "" 0 +2 + StrCpy $R1 $R2 + Goto done + StrCpy $R1 "$R2;$R3" + Goto done + + done: + Pop $R4 + Pop $R3 + Pop $R2 + Pop $R0 + Exch $R1 +FunctionEnd From e6de6f47d5eab110751cd12d4f390a54c3cad411 Mon Sep 17 00:00:00 2001 From: songzhenrui Date: Mon, 25 May 2026 12:24:40 +0800 Subject: [PATCH 68/98] fix: address Gemini code review feedback - Define UnStrStr macro for uninstaller string functions - Use un.StrStr instead of StrStr in uninstaller context - Rewrite un.RemoveFromPath with correct offset calculations and semicolon handling to prevent PATH corruption - Use dynamic version fetch from GitHub API in CLASSROOM_INSTALL.md --- docs/CLASSROOM_INSTALL.md | 2 +- scripts/installer/codewhale.nsi | 80 ++++++++++++++++----------------- 2 files changed, 40 insertions(+), 42 deletions(-) diff --git a/docs/CLASSROOM_INSTALL.md b/docs/CLASSROOM_INSTALL.md index 7ea5b7798..07a7d14ea 100644 --- a/docs/CLASSROOM_INSTALL.md +++ b/docs/CLASSROOM_INSTALL.md @@ -51,7 +51,7 @@ $binDir = "$env:LOCALAPPDATA\Programs\CodeWhale\bin" New-Item -ItemType Directory -Force -Path $binDir # 2. Download binaries (adjust URL to your mirror or release tag) -$tag = "v0.9.0" # replace with desired version +$tag = (Invoke-RestMethod -Uri "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest").tag_name Invoke-WebRequest -Uri "https://github.com/Hmbown/CodeWhale/releases/download/$tag/codewhale-x64.exe" -OutFile "$binDir\codewhale.exe" Invoke-WebRequest -Uri "https://github.com/Hmbown/CodeWhale/releases/download/$tag/codewhale-tui-x64.exe" -OutFile "$binDir\codewhale-tui.exe" diff --git a/scripts/installer/codewhale.nsi b/scripts/installer/codewhale.nsi index a88477867..29e94f093 100644 --- a/scripts/installer/codewhale.nsi +++ b/scripts/installer/codewhale.nsi @@ -25,6 +25,7 @@ !include "StrFunc.nsh" ${StrStr} +${UnStrStr} ;-------------------------------- ; General @@ -127,11 +128,9 @@ Section "Uninstall" ; Remove from current-user PATH ReadRegStr $0 HKCU "Environment" "Path" - ${StrStr} $1 $0 "$INSTDIR\bin" + ${un.StrStr} $1 $0 "$INSTDIR\bin" StrCmp $1 "" path_clean_done ; Remove the entry - ; Build new PATH without the install dir - ; This handles: "...\path;$INSTDIR\bin" and "$INSTDIR\bin;...\path" and standalone Push "$0" Push "$INSTDIR\bin" Call un.RemoveFromPath @@ -146,7 +145,7 @@ Section "Uninstall" SectionEnd ;-------------------------------- -; Helper: Remove a directory from PATH +; Helper: Remove a directory from PATH (uninstaller version) ; Input: PATH string (on stack), directory to remove (on stack) ; Output: cleaned PATH (on stack) ;-------------------------------- @@ -154,44 +153,43 @@ Function un.RemoveFromPath Exch $R0 ; directory to remove Exch Exch $R1 ; original PATH - Push $R2 - Push $R3 - Push $R4 - - StrCpy $R2 "" - StrCpy $R3 "" - - loop: - ${StrStr} $R4 $R1 $R0 - StrCmp $R4 "" done - ; Found — get substring before match - StrLen $R4 $R4 - StrLen $R3 $R1 - IntOp $R3 $R3 - $R4 - StrCpy $R2 $R1 $R3 - ; Get substring after match + dir length - StrLen $R3 $R0 - IntOp $R4 $R4 - $R3 - StrCpy $R3 $R1 "" $R4 - ; Strip leading semicolon from remainder - StrCpy $R4 $R3 1 - StrCmp $R4 ";" 0 +2 - StrCpy $R3 $R3 "" 1 - ; Strip trailing semicolon from prefix - StrLen $R4 $R2 - IntOp $R4 $R4 - 1 - StrCpy $R4 $R2 1 $R4 - StrCmp $R4 ";" 0 +2 - StrCpy $R2 $R2 $R4 - ; Concatenate - StrCmp $R2 "" 0 +2 - StrCpy $R2 $R3 - Goto done - StrCmp $R3 "" 0 +2 - StrCpy $R1 $R2 - Goto done - StrCpy $R1 "$R2;$R3" + Push $R2 ; prefix + Push $R3 ; suffix + Push $R4 ; match result + + ${un.StrStr} $R4 $R1 $R0 + StrCmp $R4 "" done + + ; Calculate prefix + StrLen $R2 $R1 + StrLen $R3 $R4 + IntOp $R3 $R2 - $R3 ; Match offset + StrCpy $R2 $R1 $R3 ; Prefix string + + ; Calculate suffix + StrLen $R4 $R0 + IntOp $R4 $R3 + $R4 ; Suffix offset = Match offset + Dir length + StrCpy $R3 $R1 "" $R4 ; Suffix string + + ; Clean up semicolons + StrCpy $R4 $R3 1 + StrCmp $R4 ";" 0 +2 + StrCpy $R3 $R3 "" 1 ; Strip leading semicolon from suffix + + StrLen $R4 $R2 + IntOp $R4 $R4 - 1 + StrCpy $R0 $R2 1 $R4 + StrCmp $R0 ";" 0 +2 + StrCpy $R2 $R2 $R4 ; Strip trailing semicolon from prefix + + ; Concatenate + StrCmp $R2 "" 0 +3 + StrCpy $R1 $R3 + Goto done + StrCmp $R3 "" 0 +3 + StrCpy $R1 $R2 Goto done + StrCpy $R1 "$R2;$R3" done: Pop $R4 From 63b7c189b83fa1c2b822d1540f496a33873ece01 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 19:34:25 -0700 Subject: [PATCH 69/98] fix(release): ship NSIS installer artifact --- .github/workflows/release.yml | 50 ++++++++++++- docs/CLASSROOM_INSTALL.md | 34 ++++++--- docs/INSTALL.md | 16 ++++- scripts/installer/codewhale.nsi | 123 ++++++++++++++++++++------------ 4 files changed, 162 insertions(+), 61 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50bf8707c..bc9aa4161 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -382,6 +382,48 @@ jobs: path: bundles/* if-no-files-found: error + windows-installer: + needs: [build, resolve] + if: ${{ !cancelled() && needs.build.result == 'success' }} + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve.outputs.source_ref }} + - uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: 'codewhale*-windows-x64.exe' + - name: Install NSIS + shell: pwsh + run: choco install nsis -y --no-progress + - name: Build NSIS installer + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $version = "${{ needs.resolve.outputs.tag }}".TrimStart("v") + Copy-Item "artifacts\codewhale-windows-x64.exe\codewhale-windows-x64.exe" "scripts\installer\codewhale.exe" + Copy-Item "artifacts\codewhale-tui-windows-x64.exe\codewhale-tui-windows-x64.exe" "scripts\installer\codewhale-tui.exe" + $makensis = "${env:ProgramFiles(x86)}\NSIS\makensis.exe" + if (!(Test-Path $makensis)) { + $makensis = "${env:ProgramFiles}\NSIS\makensis.exe" + } + if (!(Test-Path $makensis)) { + throw "makensis.exe not found after NSIS install" + } + Push-Location scripts\installer + & $makensis "/DVERSION=$version" "codewhale.nsi" + Pop-Location + if (!(Test-Path "scripts\installer\CodeWhaleSetup.exe")) { + throw "CodeWhaleSetup.exe was not produced" + } + - name: Upload installer artifact + uses: actions/upload-artifact@v4 + with: + name: CodeWhaleSetup.exe + path: scripts/installer/CodeWhaleSetup.exe + if-no-files-found: error + docker: needs: [build, resolve] if: ${{ !cancelled() && needs.build.result == 'success' }} @@ -451,8 +493,8 @@ jobs: cache-to: type=gha,mode=max release: - needs: [build, bundle, docker, resolve] - if: ${{ !cancelled() && needs.build.result == 'success' && needs.bundle.result == 'success' && needs.docker.result == 'success' }} + needs: [build, bundle, windows-installer, docker, resolve] + if: ${{ !cancelled() && needs.build.result == 'success' && needs.bundle.result == 'success' && needs.windows-installer.result == 'success' && needs.docker.result == 'success' }} runs-on: ubuntu-latest permissions: contents: write @@ -552,6 +594,7 @@ jobs: | Linux RISC-V | `codewhale-linux-riscv64.tar.gz` | `install.sh` | | macOS x64 | `codewhale-macos-x64.tar.gz` | `install.sh` | | macOS ARM | `codewhale-macos-arm64.tar.gz` | `install.sh` | + | Windows x64 (installer) | `CodeWhaleSetup.exe` | NSIS setup | | Windows x64 | `codewhale-windows-x64.zip` | `install.bat` | | Windows x64 (portable) | `codewhale-windows-x64-portable.zip` | — | @@ -563,11 +606,12 @@ jobs: ``` **Windows:** + - For the installer path, run `CodeWhaleSetup.exe`; it installs both binaries under `%LOCALAPPDATA%\Programs\CodeWhale\bin` and adds that directory to the current-user PATH. - Extract `codewhale-windows-x64.zip` - Run `install.bat` (copies to `%USERPROFILE%\bin`) - Add `%USERPROFILE%\bin` to your PATH - The **portable** Windows archive skips the install script — extract and run from any directory. + The **portable** Windows archive skips the install script — extract and run from any directory. The NSIS installer is currently unsigned and may trigger Windows SmartScreen until a signing certificate is wired into the release pipeline. Individual binaries are also attached below for scripting and the npm wrapper. Legacy `deepseek-*` and `deepseek-tui-*` assets are compatibility-only deprecation shims for v0.8.x so that existing `deepseek update` invocations on v0.8.40 keep working; they forward to the canonical binaries. The legacy npm package `deepseek-tui` is deprecated and is not republished. diff --git a/docs/CLASSROOM_INSTALL.md b/docs/CLASSROOM_INSTALL.md index 07a7d14ea..319dd99b1 100644 --- a/docs/CLASSROOM_INSTALL.md +++ b/docs/CLASSROOM_INSTALL.md @@ -15,8 +15,9 @@ machines running Windows. | 1 | Confirm Windows version: `winver` → 10 build 17763+ or 11 | ☐ | | 2 | Ensure the user account is a **standard user** (not a local admin). The installer does not require elevation. | ☐ | | 3 | Verify outbound HTTPS (port 443) is open to `api.openai.com` (or whichever LLM provider the course uses). | ☐ | -| 4 | Obtain the installer: download `CodeWhaleSetup.exe` from the [latest release](https://github.com/Hmbown/CodeWhale/releases/latest) or from your department mirror. | ☐ | -| 5 | (Optional) Verify SHA-256 hash matches the published manifest. | ☐ | +| 4 | Obtain the installer: download `CodeWhaleSetup.exe` from a v0.8.50+ [release](https://github.com/Hmbown/CodeWhale/releases/latest) or from your department mirror. | ☐ | +| 5 | Verify SHA-256 hash against `codewhale-artifacts-sha256.txt` before deploying. | ☐ | +| 6 | Note that the public installer is currently unsigned and may trigger Windows SmartScreen unless your organization signs it before deployment. | ☐ | --- @@ -25,7 +26,7 @@ machines running Windows. ### Option A — Silent install (recommended for imaging / SCCM / Intune) ```powershell -# Run as admin or via deployment tool +# Run as the target user or via a per-user deployment tool CodeWhaleSetup.exe /S ``` @@ -52,13 +53,15 @@ New-Item -ItemType Directory -Force -Path $binDir # 2. Download binaries (adjust URL to your mirror or release tag) $tag = (Invoke-RestMethod -Uri "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest").tag_name -Invoke-WebRequest -Uri "https://github.com/Hmbown/CodeWhale/releases/download/$tag/codewhale-x64.exe" -OutFile "$binDir\codewhale.exe" -Invoke-WebRequest -Uri "https://github.com/Hmbown/CodeWhale/releases/download/$tag/codewhale-tui-x64.exe" -OutFile "$binDir\codewhale-tui.exe" +Invoke-WebRequest -Uri "https://github.com/Hmbown/CodeWhale/releases/download/$tag/codewhale-windows-x64.exe" -OutFile "$binDir\codewhale.exe" +Invoke-WebRequest -Uri "https://github.com/Hmbown/CodeWhale/releases/download/$tag/codewhale-tui-windows-x64.exe" -OutFile "$binDir\codewhale-tui.exe" # 3. Add to user PATH (persistent) $currentPath = [Environment]::GetEnvironmentVariable("Path", "User") -if ($currentPath -notlike "*$binDir*") { - [Environment]::SetEnvironmentVariable("Path", "$currentPath;$binDir", "User") +$pathParts = @($currentPath -split ";" | Where-Object { $_ }) +if ($pathParts -notcontains $binDir) { + $newPath = (@($pathParts) + $binDir) -join ";" + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") } # 4. Refresh current session PATH @@ -79,6 +82,19 @@ Run these on **each machine** (or spot-check a sample): If `codewhale` is not found, the user may need to open a **new** terminal window for PATH changes to take effect. +## Lab validation checklist + +Run this once on a clean lab machine, and again on a machine that already has a +previous CodeWhale install: + +| # | Scenario | Expected result | Done? | +|---|----------|-----------------|-------| +| 1 | Install with no existing CodeWhale PATH entry | Adds exactly `%LOCALAPPDATA%\Programs\CodeWhale\bin` | ☐ | +| 2 | Install twice | PATH is not duplicated | ☐ | +| 3 | Install with a neighboring PATH entry such as `C:\Tools\CodeWhale\bin-extra` | Neighboring entry is preserved | ☐ | +| 4 | Upgrade by installing a newer `CodeWhaleSetup.exe` over an older one | Apps & Features version and both `--version` outputs match the new build | ☐ | +| 5 | Silent uninstall with `Uninstall.exe /S` | Files, uninstall registry entry, and only the exact installer PATH entry are removed | ☐ | + --- ## API key provisioning @@ -129,7 +145,7 @@ Remove-Item -Recurse -Force (Split-Path $binDir) # Remove from PATH $currentPath = [Environment]::GetEnvironmentVariable("Path", "User") -$newPath = ($currentPath -split ";" | Where-Object { $_ -ne $binDir }) -join ";" +$newPath = ($currentPath -split ";" | Where-Object { $_ -and ($_ -ne $binDir) }) -join ";" [Environment]::SetEnvironmentVariable("Path", $newPath, "User") ``` @@ -175,4 +191,4 @@ If building a golden image (WIM/FFU): --- -*Last updated: 2026-05-25* +*Last updated: 2026-06-02* diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 30ab955fa..6afab7549 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -301,8 +301,9 @@ when you need the newest version immediately. ### Windows NSIS Installer -A standalone NSIS-based installer is available for Windows users who prefer a -traditional double-click setup (no npm, no Scoop, no Cargo required). +A standalone NSIS-based installer is available starting with v0.8.50 for +Windows users who prefer a traditional double-click setup (no npm, no Scoop, no +Cargo required). **Download** `CodeWhaleSetup.exe` from the [Releases page](https://github.com/Hmbown/CodeWhale/releases/latest). @@ -320,12 +321,21 @@ traditional double-click setup (no npm, no Scoop, no Cargo required). CodeWhaleSetup.exe /S ``` +The installer is per-user and does not request elevation. Run silent installs in +the target user's context, or use a deployment tool that can run the installer +for each user profile that needs CodeWhale. + +The release-built installer is currently unsigned and may trigger Windows +SmartScreen. Verify the SHA-256 checksum from `codewhale-artifacts-sha256.txt` +before deploying, and sign the installer in your internal deployment pipeline if +your environment requires signed application packages. + **Build the installer yourself** (requires [NSIS](https://nsis.sourceforge.io)): ```powershell cd scripts\installer # Place codewhale.exe and codewhale-tui.exe here, then: -makensis /DVERSION=0.9.0 codewhale.nsi +makensis /DVERSION= codewhale.nsi ``` **Manual fallback** — if the installer is blocked by group policy, see the diff --git a/scripts/installer/codewhale.nsi b/scripts/installer/codewhale.nsi index 29e94f093..47cd025e2 100644 --- a/scripts/installer/codewhale.nsi +++ b/scripts/installer/codewhale.nsi @@ -11,11 +11,8 @@ ; codewhale.exe ; codewhale-tui.exe ; 2. Build: -; makensis codewhale.nsi +; makensis /DVERSION=1.2.3 codewhale.nsi ; 3. Output: CodeWhaleSetup.exe (in current directory) -; -; You can override version at build time: -; makensis /DVERSION=1.2.3 codewhale.nsi ;-------------------------------- ; Includes @@ -83,11 +80,12 @@ Section "Install" SecInstall WriteUninstaller "$INSTDIR\Uninstall.exe" ; Add to current-user PATH - ; Read existing PATH, append if not already present + ; Read existing PATH, append only when the exact entry is absent. ReadRegStr $0 HKCU "Environment" "Path" - ${StrStr} $1 $0 "$INSTDIR\bin" + StrCpy $2 ";$0;" + StrCpy $3 ";$INSTDIR\bin;" + ${StrStr} $1 $2 $3 StrCmp $1 "" 0 path_already_set - ; Not found — append StrCmp $0 "" empty_path WriteRegExpandStr HKCU "Environment" "Path" "$0;$INSTDIR\bin" Goto path_done @@ -128,9 +126,10 @@ Section "Uninstall" ; Remove from current-user PATH ReadRegStr $0 HKCU "Environment" "Path" - ${un.StrStr} $1 $0 "$INSTDIR\bin" + StrCpy $2 ";$0;" + StrCpy $3 ";$INSTDIR\bin;" + ${UnStrStr} $1 $2 $3 StrCmp $1 "" path_clean_done - ; Remove the entry Push "$0" Push "$INSTDIR\bin" Call un.RemoveFromPath @@ -145,7 +144,7 @@ Section "Uninstall" SectionEnd ;-------------------------------- -; Helper: Remove a directory from PATH (uninstaller version) +; Helper: Remove exact directory entries from PATH (uninstaller version) ; Input: PATH string (on stack), directory to remove (on stack) ; Output: cleaned PATH (on stack) ;-------------------------------- @@ -153,48 +152,80 @@ Function un.RemoveFromPath Exch $R0 ; directory to remove Exch Exch $R1 ; original PATH - Push $R2 ; prefix - Push $R3 ; suffix + Push $R2 ; padded path + Push $R3 ; padded needle Push $R4 ; match result - - ${un.StrStr} $R4 $R1 $R0 - StrCmp $R4 "" done - - ; Calculate prefix - StrLen $R2 $R1 - StrLen $R3 $R4 - IntOp $R3 $R2 - $R3 ; Match offset - StrCpy $R2 $R1 $R3 ; Prefix string - - ; Calculate suffix - StrLen $R4 $R0 - IntOp $R4 $R3 + $R4 ; Suffix offset = Match offset + Dir length - StrCpy $R3 $R1 "" $R4 ; Suffix string - - ; Clean up semicolons - StrCpy $R4 $R3 1 - StrCmp $R4 ";" 0 +2 - StrCpy $R3 $R3 "" 1 ; Strip leading semicolon from suffix - - StrLen $R4 $R2 - IntOp $R4 $R4 - 1 - StrCpy $R0 $R2 1 $R4 - StrCmp $R0 ";" 0 +2 - StrCpy $R2 $R2 $R4 ; Strip trailing semicolon from prefix - - ; Concatenate - StrCmp $R2 "" 0 +3 - StrCpy $R1 $R3 - Goto done - StrCmp $R3 "" 0 +3 - StrCpy $R1 $R2 - Goto done - StrCpy $R1 "$R2;$R3" + Push $R5 ; prefix + Push $R6 ; suffix + Push $R7 ; offset/length + + loop: + StrCmp $R1 "" done + StrCpy $R2 ";$R1;" + StrCpy $R3 ";$R0;" + ${UnStrStr} $R4 $R2 $R3 + StrCmp $R4 "" done + + ; Prefix before the exact `;dir;` match in the padded PATH. + StrLen $R5 $R2 + StrLen $R6 $R4 + IntOp $R6 $R5 - $R6 + StrCpy $R5 $R2 $R6 + + ; Suffix after the exact `;dir;` match in the padded PATH. + StrLen $R7 $R3 + IntOp $R7 $R6 + $R7 + StrCpy $R6 $R2 "" $R7 + + Push $R5 + Call un.TrimPathEdgeSemicolons + Pop $R5 + Push $R6 + Call un.TrimPathEdgeSemicolons + Pop $R6 + + StrCmp $R5 "" 0 +3 + StrCpy $R1 $R6 + Goto loop + StrCmp $R6 "" 0 +3 + StrCpy $R1 $R5 + Goto loop + StrCpy $R1 "$R5;$R6" + Goto loop done: + Pop $R7 + Pop $R6 + Pop $R5 Pop $R4 Pop $R3 Pop $R2 Pop $R0 Exch $R1 FunctionEnd + +Function un.TrimPathEdgeSemicolons + Exch $R9 + Push $R8 + + trim_leading: + StrCpy $R8 $R9 1 + StrCmp $R8 ";" 0 trim_trailing + StrCpy $R9 $R9 "" 1 + Goto trim_leading + + trim_trailing: + StrLen $R8 $R9 + IntCmp $R8 0 trim_done + IntOp $R8 $R8 - 1 + StrCpy $R8 $R9 1 $R8 + StrCmp $R8 ";" 0 trim_done + StrLen $R8 $R9 + IntOp $R8 $R8 - 1 + StrCpy $R9 $R9 $R8 + Goto trim_trailing + + trim_done: + Pop $R8 + Exch $R9 +FunctionEnd From 41edcd5c4f9486c00e0b86a211175284cd8a5a42 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 19:39:45 -0700 Subject: [PATCH 70/98] chore(release): bump local version to 0.8.50 --- CHANGELOG.md | 40 +++++++++++++++++++++++++++++++++++- Cargo.lock | 30 +++++++++++++-------------- Cargo.toml | 2 +- crates/agent/Cargo.toml | 2 +- crates/app-server/Cargo.toml | 18 ++++++++-------- crates/cli/Cargo.toml | 16 +++++++-------- crates/config/Cargo.toml | 4 ++-- crates/core/Cargo.toml | 16 +++++++-------- crates/execpolicy/Cargo.toml | 2 +- crates/hooks/Cargo.toml | 2 +- crates/tools/Cargo.toml | 2 +- crates/tui/CHANGELOG.md | 40 +++++++++++++++++++++++++++++++++++- crates/tui/Cargo.toml | 10 ++++----- npm/codewhale/package.json | 4 ++-- 14 files changed, 132 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eda34d18..b27fd7c37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.50] - 2026-06-02 + +### Added + +- Added a Windows NSIS installer release artifact and classroom/lab deployment + checklist, harvested from #2045 for #1987. The release workflow now builds + `CodeWhaleSetup.exe` from the canonical Windows binaries, and the installer + adds/removes only the exact current-user PATH entry. +- Added deterministic session timestamps in session listings, receipt-export + boundary docs, and current-model turn metadata for routed/auto sessions. + +### Changed + +- Hardened theme repainting and sidebar color use so theme switches do not + leave stale Whale-dark panel colors behind. +- Made legacy config migration visible when CodeWhale copies old DeepSeek-era + config into the CodeWhale config path. + +### Fixed + +- Fixed `/context` to use the effective routed model for context-window + budgeting, so DeepSeek V4 routes report the 1M-token window and legacy + DeepSeek routes keep the 128K fallback. +- Fixed npm wrapper version output so `--version` prefers the installed binary + version instead of stale package metadata when both are available. +- Fixed truncated subagent tool calls and repeated truncated subagent responses + so they return model-visible errors instead of silently failing. + +### Community + +Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, +#2562, #2563, #2564), and **@HUQIANTAO** (#2527) for the work harvested into +this release pass. Thanks also to issue reporters and verification helpers +including **@New2Niu** (#2561), **@buko** (#2533, #2369), **@wywsoor** +(#2494), **@ctxyao** (#2556), and **@Dr3259** (#2380) for reports and +acceptance details that shaped these fixes. + ## [0.8.49] - 2026-06-01 ### Added @@ -5162,7 +5199,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...HEAD +[0.8.50]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...v0.8.50 [0.8.49]: https://github.com/Hmbown/CodeWhale/compare/v0.8.48...v0.8.49 [0.8.48]: https://github.com/Hmbown/CodeWhale/compare/v0.8.47...v0.8.48 [0.8.47]: https://github.com/Hmbown/CodeWhale/compare/v0.8.46...v0.8.47 diff --git a/Cargo.lock b/Cargo.lock index 50d4b04e7..4f3e1854d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,7 +803,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" [[package]] name = "codewhale-agent" -version = "0.8.49" +version = "0.8.50" dependencies = [ "codewhale-config", "serde", @@ -811,7 +811,7 @@ dependencies = [ [[package]] name = "codewhale-app-server" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "axum", @@ -836,7 +836,7 @@ dependencies = [ [[package]] name = "codewhale-cli" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "chrono", @@ -863,7 +863,7 @@ dependencies = [ [[package]] name = "codewhale-config" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "codewhale-execpolicy", @@ -877,7 +877,7 @@ dependencies = [ [[package]] name = "codewhale-core" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "chrono", @@ -895,7 +895,7 @@ dependencies = [ [[package]] name = "codewhale-execpolicy" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "codewhale-protocol", @@ -904,7 +904,7 @@ dependencies = [ [[package]] name = "codewhale-hooks" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "async-trait", @@ -918,7 +918,7 @@ dependencies = [ [[package]] name = "codewhale-mcp" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "serde", @@ -927,7 +927,7 @@ dependencies = [ [[package]] name = "codewhale-protocol" -version = "0.8.49" +version = "0.8.50" dependencies = [ "serde", "serde_json", @@ -935,7 +935,7 @@ dependencies = [ [[package]] name = "codewhale-release" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "reqwest", @@ -946,7 +946,7 @@ dependencies = [ [[package]] name = "codewhale-secrets" -version = "0.8.49" +version = "0.8.50" dependencies = [ "dirs", "keyring", @@ -959,7 +959,7 @@ dependencies = [ [[package]] name = "codewhale-state" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "chrono", @@ -971,7 +971,7 @@ dependencies = [ [[package]] name = "codewhale-tools" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "async-trait", @@ -985,7 +985,7 @@ dependencies = [ [[package]] name = "codewhale-tui" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "arboard", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "codewhale-tui-core" -version = "0.8.49" +version = "0.8.50" [[package]] name = "colorchoice" diff --git a/Cargo.toml b/Cargo.toml index e38976d27..614400a84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.49" +version = "0.8.50" edition = "2024" # Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the # codebase relies on extensively. Cargo enforces this so users on older diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index f6d615a44..a42937072 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -codewhale-config = { path = "../config", version = "0.8.49" } +codewhale-config = { path = "../config", version = "0.8.50" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 0dc37ffd6..9ec76487a 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.49" } -codewhale-config = { path = "../config", version = "0.8.49" } -codewhale-core = { path = "../core", version = "0.8.49" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.49" } -codewhale-hooks = { path = "../hooks", version = "0.8.49" } -codewhale-mcp = { path = "../mcp", version = "0.8.49" } -codewhale-protocol = { path = "../protocol", version = "0.8.49" } -codewhale-state = { path = "../state", version = "0.8.49" } -codewhale-tools = { path = "../tools", version = "0.8.49" } +codewhale-agent = { path = "../agent", version = "0.8.50" } +codewhale-config = { path = "../config", version = "0.8.50" } +codewhale-core = { path = "../core", version = "0.8.50" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.50" } +codewhale-hooks = { path = "../hooks", version = "0.8.50" } +codewhale-mcp = { path = "../mcp", version = "0.8.50" } +codewhale-protocol = { path = "../protocol", version = "0.8.50" } +codewhale-state = { path = "../state", version = "0.8.50" } +codewhale-tools = { path = "../tools", version = "0.8.50" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ad634349d..59d7bd00b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -25,14 +25,14 @@ path = "src/bin/deepseek_legacy_shim.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.49" } -codewhale-app-server = { path = "../app-server", version = "0.8.49" } -codewhale-config = { path = "../config", version = "0.8.49" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.49" } -codewhale-mcp = { path = "../mcp", version = "0.8.49" } -codewhale-release = { path = "../release", version = "0.8.49" } -codewhale-secrets = { path = "../secrets", version = "0.8.49" } -codewhale-state = { path = "../state", version = "0.8.49" } +codewhale-agent = { path = "../agent", version = "0.8.50" } +codewhale-app-server = { path = "../app-server", version = "0.8.50" } +codewhale-config = { path = "../config", version = "0.8.50" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.50" } +codewhale-mcp = { path = "../mcp", version = "0.8.50" } +codewhale-release = { path = "../release", version = "0.8.50" } +codewhale-secrets = { path = "../secrets", version = "0.8.50" } +codewhale-state = { path = "../state", version = "0.8.50" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 726c06304..71191068e 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,8 +8,8 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.49" } -codewhale-secrets = { path = "../secrets", version = "0.8.49" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.50" } +codewhale-secrets = { path = "../secrets", version = "0.8.50" } dirs.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 4c4526cac..43011a6ba 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.49" } -codewhale-config = { path = "../config", version = "0.8.49" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.49" } -codewhale-hooks = { path = "../hooks", version = "0.8.49" } -codewhale-mcp = { path = "../mcp", version = "0.8.49" } -codewhale-protocol = { path = "../protocol", version = "0.8.49" } -codewhale-state = { path = "../state", version = "0.8.49" } -codewhale-tools = { path = "../tools", version = "0.8.49" } +codewhale-agent = { path = "../agent", version = "0.8.50" } +codewhale-config = { path = "../config", version = "0.8.50" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.50" } +codewhale-hooks = { path = "../hooks", version = "0.8.50" } +codewhale-mcp = { path = "../mcp", version = "0.8.50" } +codewhale-protocol = { path = "../protocol", version = "0.8.50" } +codewhale-state = { path = "../state", version = "0.8.50" } +codewhale-tools = { path = "../tools", version = "0.8.50" } serde_json.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 789b0cab2..4214f6865 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.49" } +codewhale-protocol = { path = "../protocol", version = "0.8.50" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 66e293be9..c1460ab03 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.49" } +codewhale-protocol = { path = "../protocol", version = "0.8.50" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index cc9f1d837..ca14cd65a 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.49" } +codewhale-protocol = { path = "../protocol", version = "0.8.50" } serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 1eda34d18..b27fd7c37 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.50] - 2026-06-02 + +### Added + +- Added a Windows NSIS installer release artifact and classroom/lab deployment + checklist, harvested from #2045 for #1987. The release workflow now builds + `CodeWhaleSetup.exe` from the canonical Windows binaries, and the installer + adds/removes only the exact current-user PATH entry. +- Added deterministic session timestamps in session listings, receipt-export + boundary docs, and current-model turn metadata for routed/auto sessions. + +### Changed + +- Hardened theme repainting and sidebar color use so theme switches do not + leave stale Whale-dark panel colors behind. +- Made legacy config migration visible when CodeWhale copies old DeepSeek-era + config into the CodeWhale config path. + +### Fixed + +- Fixed `/context` to use the effective routed model for context-window + budgeting, so DeepSeek V4 routes report the 1M-token window and legacy + DeepSeek routes keep the 128K fallback. +- Fixed npm wrapper version output so `--version` prefers the installed binary + version instead of stale package metadata when both are available. +- Fixed truncated subagent tool calls and repeated truncated subagent responses + so they return model-visible errors instead of silently failing. + +### Community + +Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, +#2562, #2563, #2564), and **@HUQIANTAO** (#2527) for the work harvested into +this release pass. Thanks also to issue reporters and verification helpers +including **@New2Niu** (#2561), **@buko** (#2533, #2369), **@wywsoor** +(#2494), **@ctxyao** (#2556), and **@Dr3259** (#2380) for reports and +acceptance details that shaped these fixes. + ## [0.8.49] - 2026-06-01 ### Added @@ -5162,7 +5199,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...HEAD +[0.8.50]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...v0.8.50 [0.8.49]: https://github.com/Hmbown/CodeWhale/compare/v0.8.48...v0.8.49 [0.8.48]: https://github.com/Hmbown/CodeWhale/compare/v0.8.47...v0.8.48 [0.8.47]: https://github.com/Hmbown/CodeWhale/compare/v0.8.46...v0.8.47 diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 095e4b79e..ce781812d 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -27,11 +27,11 @@ path = "src/bin/deepseek_tui_legacy_shim.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -codewhale-config = { path = "../config", version = "0.8.49" } -codewhale-protocol = { path = "../protocol", version = "0.8.49" } -codewhale-release = { path = "../release", version = "0.8.49" } -codewhale-secrets = { path = "../secrets", version = "0.8.49" } -codewhale-tools = { path = "../tools", version = "0.8.49" } +codewhale-config = { path = "../config", version = "0.8.50" } +codewhale-protocol = { path = "../protocol", version = "0.8.50" } +codewhale-release = { path = "../release", version = "0.8.50" } +codewhale-secrets = { path = "../secrets", version = "0.8.50" } +codewhale-tools = { path = "../tools", version = "0.8.50" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" diff --git a/npm/codewhale/package.json b/npm/codewhale/package.json index 0bf0ed1d5..6e15c9ae0 100644 --- a/npm/codewhale/package.json +++ b/npm/codewhale/package.json @@ -1,7 +1,7 @@ { "name": "codewhale", - "version": "0.8.49", - "codewhaleBinaryVersion": "0.8.49", + "version": "0.8.50", + "codewhaleBinaryVersion": "0.8.50", "description": "Install and run CodeWhale, the agentic terminal for open-source and open-weight coding models, from GitHub release artifacts.", "author": "Hmbown", "license": "MIT", From 88f34fc9dd2f09f61e871900e28381a0a47d7e14 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 19:58:39 -0700 Subject: [PATCH 71/98] fix(tui): protect multiline drafts on arrow navigation --- CHANGELOG.md | 9 ++++++-- crates/tui/CHANGELOG.md | 9 ++++++-- crates/tui/src/localization.rs | 2 +- crates/tui/src/tui/composer_ui.rs | 31 ++++++++++++++++++++++++-- crates/tui/src/tui/ui/tests.rs | 36 +++++++++++++++++++++++++------ 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b27fd7c37..9623b3b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 DeepSeek routes keep the 128K fallback. - Fixed npm wrapper version output so `--version` prefers the installed binary version instead of stale package metadata when both are available. +- Fixed multiline composer arrow navigation so holding Up/Down at the first or + last line no longer replaces the current draft with prompt history. +- Clarified the English DeepSeek account-balance footer chip from `bal` to + `balance` so it is less likely to be mistaken for session spend. - Fixed truncated subagent tool calls and repeated truncated subagent responses so they return model-visible errors instead of silently failing. @@ -41,8 +45,9 @@ Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, #2562, #2563, #2564), and **@HUQIANTAO** (#2527) for the work harvested into this release pass. Thanks also to issue reporters and verification helpers including **@New2Niu** (#2561), **@buko** (#2533, #2369), **@wywsoor** -(#2494), **@ctxyao** (#2556), and **@Dr3259** (#2380) for reports and -acceptance details that shaped these fixes. +(#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), and **@caiyilian** +(#2567) for reports and acceptance details that shaped these fixes, plus the +WeChat/Chinese UX reports relayed during the final triage pass. ## [0.8.49] - 2026-06-01 diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index b27fd7c37..9623b3b3e 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 DeepSeek routes keep the 128K fallback. - Fixed npm wrapper version output so `--version` prefers the installed binary version instead of stale package metadata when both are available. +- Fixed multiline composer arrow navigation so holding Up/Down at the first or + last line no longer replaces the current draft with prompt history. +- Clarified the English DeepSeek account-balance footer chip from `bal` to + `balance` so it is less likely to be mistaken for session spend. - Fixed truncated subagent tool calls and repeated truncated subagent responses so they return model-visible errors instead of silently failing. @@ -41,8 +45,9 @@ Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, #2562, #2563, #2564), and **@HUQIANTAO** (#2527) for the work harvested into this release pass. Thanks also to issue reporters and verification helpers including **@New2Niu** (#2561), **@buko** (#2533, #2369), **@wywsoor** -(#2494), **@ctxyao** (#2556), and **@Dr3259** (#2380) for reports and -acceptance details that shaped these fixes. +(#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), and **@caiyilian** +(#2567) for reports and acceptance details that shaped these fixes, plus the +WeChat/Chinese UX reports relayed during the final triage pass. ## [0.8.49] - 2026-06-01 diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 5e67b571e..ad8531923 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1121,7 +1121,7 @@ fn english(id: MessageId) -> &'static str { MessageId::FooterAgentsPlural => "{count} agents", MessageId::FooterPressCtrlCAgain => "Press Ctrl+C again to quit", MessageId::FooterWorking => "working", - MessageId::FooterBalancePrefix => "bal", + MessageId::FooterBalancePrefix => "balance", MessageId::HelpSectionActions => "Actions", MessageId::HelpSectionClipboard => "Clipboard", MessageId::HelpSectionEditing => "Input editing", diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index 75275873c..708f4f97b 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -57,14 +57,19 @@ pub(crate) fn handle_composer_history_arrow( } // When `composer_arrows_scroll` is enabled, plain Up/Down scroll the - // transcript for single-line drafts. Multiline composers keep editor-like - // line navigation, with history fallback at the first/last line. + // transcript for single-line drafts. Multiline drafts keep editor-like + // line navigation. If the user holds Up/Down at the first/last line, do + // not replace their current draft with prompt history unless they are + // already navigating history. let scroll_transcript = app.composer_arrows_scroll && !app.input.contains('\n'); + let protect_multiline_draft = app.input.contains('\n') && app.history_index.is_none(); match key.code { KeyCode::Up => { if scroll_transcript { app.scroll_up(COMPOSER_ARROW_SCROLL_LINES); + } else if protect_multiline_draft && !cursor_has_previous_logical_line(app) { + app.needs_redraw = true; } else { app.vim_move_up(); } @@ -73,6 +78,8 @@ pub(crate) fn handle_composer_history_arrow( KeyCode::Down => { if scroll_transcript { app.scroll_down(COMPOSER_ARROW_SCROLL_LINES); + } else if protect_multiline_draft && !cursor_has_next_logical_line(app) { + app.needs_redraw = true; } else { app.vim_move_down(); } @@ -82,6 +89,26 @@ pub(crate) fn handle_composer_history_arrow( } } +fn cursor_has_previous_logical_line(app: &App) -> bool { + let cursor_byte = byte_index_at_char(&app.input, app.cursor_position); + app.input[..cursor_byte].contains('\n') +} + +fn cursor_has_next_logical_line(app: &App) -> bool { + let cursor_byte = byte_index_at_char(&app.input, app.cursor_position); + app.input[cursor_byte..].contains('\n') +} + +fn byte_index_at_char(text: &str, char_index: usize) -> usize { + if char_index == 0 { + return 0; + } + text.char_indices() + .nth(char_index) + .map(|(idx, _)| idx) + .unwrap_or(text.len()) +} + pub(crate) fn is_word_cursor_modifier(modifiers: KeyModifiers) -> bool { modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 3d7bf7f5f..9434ee62c 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -6935,7 +6935,7 @@ fn footer_balance_spans_formats_cny() { }; *app.balance_cell.lock().unwrap() = Some(info); let spans = footer_balance_spans(&app); - assert_eq!(spans_text(&spans), "bal ¥123.5"); + assert_eq!(spans_text(&spans), "balance ¥123.5"); } #[test] @@ -6948,7 +6948,7 @@ fn footer_balance_spans_formats_usd() { }; *app.balance_cell.lock().unwrap() = Some(info); let spans = footer_balance_spans(&app); - assert_eq!(spans_text(&spans), "bal $0.50"); + assert_eq!(spans_text(&spans), "balance $0.50"); } #[test] @@ -6961,7 +6961,7 @@ fn footer_balance_spans_rounds_large_amount() { }; *app.balance_cell.lock().unwrap() = Some(info); let spans = footer_balance_spans(&app); - assert_eq!(spans_text(&spans), "bal $1235"); + assert_eq!(spans_text(&spans), "balance $1235"); } #[test] @@ -6974,7 +6974,7 @@ fn footer_balance_spans_treats_unknown_currency_as_usd() { }; *app.balance_cell.lock().unwrap() = Some(info); let spans = footer_balance_spans(&app); - assert_eq!(spans_text(&spans), "bal $10.0"); + assert_eq!(spans_text(&spans), "balance $10.0"); } #[test] @@ -6987,7 +6987,7 @@ fn render_footer_from_with_balance_item_shows_balance() { }; *app.balance_cell.lock().unwrap() = Some(info); let props = render_footer_from(&app, &[crate::config::StatusItem::Balance], None); - assert_eq!(spans_text(&props.balance), "bal $42.5"); + assert_eq!(spans_text(&props.balance), "balance $42.5"); } #[test] @@ -7323,7 +7323,7 @@ fn composer_arrows_scroll_multiline_input_navigates_lines() { } #[test] -fn composer_arrow_up_at_first_line_falls_back_to_history_up() { +fn composer_arrow_up_at_first_line_preserves_multiline_draft() { let mut app = create_test_app(); app.composer_arrows_scroll = false; app.input = "line one\nline two".to_string(); @@ -7337,7 +7337,29 @@ fn composer_arrow_up_at_first_line_falls_back_to_history_up() { false, )); - assert_eq!(app.input, "previous prompt"); + assert_eq!(app.input, "line one\nline two"); + assert_eq!(app.cursor_position, 0); + assert!(app.history_index.is_none()); +} + +#[test] +fn composer_arrow_down_at_last_line_preserves_multiline_draft() { + let mut app = create_test_app(); + app.composer_arrows_scroll = false; + app.input = "line one\nline two".to_string(); + app.cursor_position = app.input.chars().count(); + app.input_history.push("next prompt".to_string()); + + assert!(handle_composer_history_arrow( + &mut app, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + false, + false, + )); + + assert_eq!(app.input, "line one\nline two"); + assert_eq!(app.cursor_position, app.input.chars().count()); + assert!(app.history_index.is_none()); } // #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll From eedeb5290b0048343655cca47e024abc4c7e6acd Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 20:06:26 -0700 Subject: [PATCH 72/98] fix(agent): pass through explicit AtlasCloud model ids Refs #2569 Harvests the safe part of PR #2569 by allowing AtlasCloud provider-hinted namespaced model IDs to route exactly as requested, without freezing a volatile provider model catalog in the static registry. Co-authored-by: lucaszhu-hue --- CHANGELOG.md | 15 ++++++----- README.md | 1 + crates/agent/src/lib.rs | 58 +++++++++++++++++++++++++++++++++++++++++ crates/tui/CHANGELOG.md | 15 ++++++----- docs/PROVIDERS.md | 2 +- 5 files changed, 78 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9623b3b3e..6500136fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 adds/removes only the exact current-user PATH entry. - Added deterministic session timestamps in session listings, receipt-export boundary docs, and current-model turn metadata for routed/auto sessions. +- Added exact AtlasCloud provider-hinted model ID pass-through for explicit + `vendor/model-id` selections, harvested from #2569 without freezing a + brittle provider catalog. ### Changed @@ -42,12 +45,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Community Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, -#2562, #2563, #2564), and **@HUQIANTAO** (#2527) for the work harvested into -this release pass. Thanks also to issue reporters and verification helpers -including **@New2Niu** (#2561), **@buko** (#2533, #2369), **@wywsoor** -(#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), and **@caiyilian** -(#2567) for reports and acceptance details that shaped these fixes, plus the -WeChat/Chinese UX reports relayed during the final triage pass. +#2562, #2563, #2564), **@HUQIANTAO** (#2527), and **@lucaszhu-hue** (#2569) +for the work harvested into this release pass. Thanks also to issue reporters +and verification helpers including **@New2Niu** (#2561), **@buko** (#2533, +#2369), **@wywsoor** (#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), and +**@caiyilian** (#2567) for reports and acceptance details that shaped these +fixes, plus the WeChat/Chinese UX reports relayed during the final triage pass. ## [0.8.49] - 2026-06-01 diff --git a/README.md b/README.md index 39713d19e..f595f5b78 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,7 @@ codewhale --provider nvidia-nim # AtlasCloud codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY" codewhale --provider atlascloud +codewhale --provider atlascloud --model vendor/model-id # Wanjie Ark codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY" diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 7c3bbdf75..9b56f591a 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -503,6 +503,16 @@ impl ModelRegistry { fallback_chain, }; } + if provider_hint == Some(ProviderKind::Atlascloud) + && let Some(model) = atlascloud_passthrough_model(name) + { + return ModelResolution { + requested: Some(name.to_string()), + resolved: model, + used_fallback: false, + fallback_chain, + }; + } if let Some(idx) = self.alias_map.get(&normalize(name)) { return ModelResolution { requested: Some(name.to_string()), @@ -562,6 +572,21 @@ fn preserve_requested_model_id_case(mut model: ModelInfo, requested: &str) -> Mo model } +fn atlascloud_passthrough_model(requested: &str) -> Option { + let requested = requested.trim(); + if requested.is_empty() || !requested.contains('/') { + return None; + } + + Some(ModelInfo { + id: requested.to_string(), + provider: ProviderKind::Atlascloud, + aliases: Vec::new(), + supports_tools: true, + supports_reasoning: true, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -630,6 +655,39 @@ mod tests { assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro"); } + #[test] + fn atlascloud_provider_hint_passes_through_explicit_model_id() { + let registry = ModelRegistry::default(); + let resolved = + registry.resolve(Some("openai/gpt-5.2-chat"), Some(ProviderKind::Atlascloud)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud); + assert_eq!(resolved.resolved.id, "openai/gpt-5.2-chat"); + assert!(resolved.resolved.supports_tools); + assert!(resolved.resolved.supports_reasoning); + assert!(!resolved.used_fallback); + } + + #[test] + fn atlascloud_provider_hint_preserves_explicit_model_id_case() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(Some("Qwen/Qwen3-Coder"), Some(ProviderKind::Atlascloud)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud); + assert_eq!(resolved.resolved.id, "Qwen/Qwen3-Coder"); + assert!(!resolved.used_fallback); + } + + #[test] + fn atlascloud_plain_unknown_model_still_uses_provider_default() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(Some("not-in-atlas"), Some(ProviderKind::Atlascloud)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud); + assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash"); + assert!(resolved.used_fallback); + } + #[test] fn openrouter_default_uses_namespaced_model_id() { let registry = ModelRegistry::default(); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 9623b3b3e..6500136fd 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 adds/removes only the exact current-user PATH entry. - Added deterministic session timestamps in session listings, receipt-export boundary docs, and current-model turn metadata for routed/auto sessions. +- Added exact AtlasCloud provider-hinted model ID pass-through for explicit + `vendor/model-id` selections, harvested from #2569 without freezing a + brittle provider catalog. ### Changed @@ -42,12 +45,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Community Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, -#2562, #2563, #2564), and **@HUQIANTAO** (#2527) for the work harvested into -this release pass. Thanks also to issue reporters and verification helpers -including **@New2Niu** (#2561), **@buko** (#2533, #2369), **@wywsoor** -(#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), and **@caiyilian** -(#2567) for reports and acceptance details that shaped these fixes, plus the -WeChat/Chinese UX reports relayed during the final triage pass. +#2562, #2563, #2564), **@HUQIANTAO** (#2527), and **@lucaszhu-hue** (#2569) +for the work harvested into this release pass. Thanks also to issue reporters +and verification helpers including **@New2Niu** (#2561), **@buko** (#2533, +#2369), **@wywsoor** (#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), and +**@caiyilian** (#2567) for reports and acceptance details that shaped these +fixes, plus the WeChat/Chinese UX reports relayed during the final triage pass. ## [0.8.49] - 2026-06-01 diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index a4ddc01c8..f2a126deb 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -114,7 +114,7 @@ endpoint. | `deepseek` | `[providers.deepseek]` | `DEEPSEEK_API_KEY` | `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL`; default `https://api.deepseek.com/beta` | `deepseek-v4-pro`, `deepseek-v4-flash`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | First-class default. Beta URL enables strict tool mode, chat prefix completion, and FIM completion. Set `https://api.deepseek.com` or `/v1` explicitly to opt out of beta-only features. | | `nvidia-nim` | `[providers.nvidia_nim]` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY`, fallback `DEEPSEEK_API_KEY` | `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, `NVIDIA_BASE_URL`; default `https://integrate.api.nvidia.com/v1` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | Hosted DeepSeek V4 through NVIDIA NIM. `NVIDIA_NIM_MODEL` is accepted by the TUI config path. | | `openai` | `[providers.openai]` | `OPENAI_API_KEY` | `OPENAI_BASE_URL`; default `https://api.openai.com/v1` | Registry entries: `deepseek-v4-pro`, `deepseek-v4-flash`; default config model `deepseek-v4-pro` | Generic OpenAI-compatible route for gateways and custom endpoints. Use this for explicit third-party OpenAI-compatible routes instead of inventing a new provider ID. `OPENAI_MODEL` is accepted. | -| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, and the static `ModelRegistry` includes AtlasCloud fallback rows for CLI model resolution. | +| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | Default `deepseek-ai/deepseek-v4-flash`; explicit `vendor/model-id` values pass through when AtlasCloud is selected | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, the static `ModelRegistry` keeps DeepSeek V4 fallback rows, and provider-hinted CLI model IDs are sent to AtlasCloud exactly as requested. | | `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. | | `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. | | `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-35b-a3b`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. | From 4a091974330c0796380731a065cb7947ba3c2f83 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 20:49:26 -0700 Subject: [PATCH 73/98] fix(tui): bound foreground shell reader drains Refs #2571 Harvests the core idea from PR #2573 by @idling11, with local cleanup for normal-exit inherited pipe handles and a foreground orphan-pipe regression. Co-authored-by: Hanmiao Li <894876246@qq.com> --- CHANGELOG.md | 15 +++++---- crates/tui/CHANGELOG.md | 15 +++++---- crates/tui/src/tools/shell.rs | 48 ++++++++++++++++++----------- crates/tui/src/tools/shell/tests.rs | 18 +++++++++++ 4 files changed, 66 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6500136fd..4b8393a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 version instead of stale package metadata when both are available. - Fixed multiline composer arrow navigation so holding Up/Down at the first or last line no longer replaces the current draft with prompt history. +- Fixed foreground `exec_shell` output collection so timeout and inherited-pipe + cleanup cannot wedge later tool calls behind the global tool lock. - Clarified the English DeepSeek account-balance footer chip from `bal` to `balance` so it is less likely to be mistaken for session spend. - Fixed truncated subagent tool calls and repeated truncated subagent responses @@ -45,12 +47,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Community Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, -#2562, #2563, #2564), **@HUQIANTAO** (#2527), and **@lucaszhu-hue** (#2569) -for the work harvested into this release pass. Thanks also to issue reporters -and verification helpers including **@New2Niu** (#2561), **@buko** (#2533, -#2369), **@wywsoor** (#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), and -**@caiyilian** (#2567) for reports and acceptance details that shaped these -fixes, plus the WeChat/Chinese UX reports relayed during the final triage pass. +#2562, #2563, #2564), **@HUQIANTAO** (#2527), **@lucaszhu-hue** (#2569), and +**@idling11** (#2573) for the work harvested into this release pass. Thanks +also to issue reporters and verification helpers including **@New2Niu** +(#2561), **@buko** (#2533, #2369), **@wywsoor** (#2494), **@ctxyao** (#2556), +**@Dr3259** (#2380), **@caiyilian** (#2567), and **@chinaqy110** (#2571) for +reports and acceptance details that shaped these fixes, plus the WeChat/Chinese +UX reports relayed during the final triage pass. ## [0.8.49] - 2026-06-01 diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 6500136fd..4b8393a9c 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -37,6 +37,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 version instead of stale package metadata when both are available. - Fixed multiline composer arrow navigation so holding Up/Down at the first or last line no longer replaces the current draft with prompt history. +- Fixed foreground `exec_shell` output collection so timeout and inherited-pipe + cleanup cannot wedge later tool calls behind the global tool lock. - Clarified the English DeepSeek account-balance footer chip from `bal` to `balance` so it is less likely to be mistaken for session spend. - Fixed truncated subagent tool calls and repeated truncated subagent responses @@ -45,12 +47,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Community Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, -#2562, #2563, #2564), **@HUQIANTAO** (#2527), and **@lucaszhu-hue** (#2569) -for the work harvested into this release pass. Thanks also to issue reporters -and verification helpers including **@New2Niu** (#2561), **@buko** (#2533, -#2369), **@wywsoor** (#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), and -**@caiyilian** (#2567) for reports and acceptance details that shaped these -fixes, plus the WeChat/Chinese UX reports relayed during the final triage pass. +#2562, #2563, #2564), **@HUQIANTAO** (#2527), **@lucaszhu-hue** (#2569), and +**@idling11** (#2573) for the work harvested into this release pass. Thanks +also to issue reporters and verification helpers including **@New2Niu** +(#2561), **@buko** (#2533, #2369), **@wywsoor** (#2494), **@ctxyao** (#2556), +**@Dr3259** (#2380), **@caiyilian** (#2567), and **@chinaqy110** (#2571) for +reports and acceptance details that shaped these fixes, plus the WeChat/Chinese +UX reports relayed during the final triage pass. ## [0.8.49] - 2026-06-01 diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 983d36e91..d3521d198 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -443,6 +443,25 @@ fn spawn_reader_thread( }) } +const SYNC_READER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); + +fn spawn_sync_reader_thread( + mut reader: R, +) -> std::sync::mpsc::Receiver> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = reader.read_to_end(&mut buf); + tx.send(buf).ok(); + }); + rx +} + +fn recv_sync_reader_output(rx: &std::sync::mpsc::Receiver>) -> Vec { + rx.recv_timeout(SYNC_READER_DRAIN_TIMEOUT) + .unwrap_or_default() +} + /// A background shell process being tracked pub struct BackgroundShell { pub id: String, @@ -1035,27 +1054,20 @@ impl ShellManager { let stdout_handle = child.stdout.take().context("Failed to capture stdout")?; let stderr_handle = child.stderr.take().context("Failed to capture stderr")?; - // Spawn threads to read output - let stdout_thread = std::thread::spawn(move || { - let mut reader = stdout_handle; - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf); - buf - }); - - let stderr_thread = std::thread::spawn(move || { - let mut reader = stderr_handle; - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf); - buf - }); + // Spawn threads to read output. Use bounded receives below so a killed + // or detached descendant that keeps pipe handles open cannot wedge the + // foreground shell path while the global tool lock is held (#2571). + let stdout_rx = spawn_sync_reader_thread(stdout_handle); + let stderr_rx = spawn_sync_reader_thread(stderr_handle); // Wait with timeout if let Some(status) = child.wait_timeout(timeout)? { + #[cfg(unix)] + let _ = kill_child_process_group(&mut child); #[cfg(windows)] terminate_and_close_windows_job(windows_job); - let stdout = stdout_thread.join().unwrap_or_default(); - let stderr = stderr_thread.join().unwrap_or_default(); + let stdout = recv_sync_reader_output(&stdout_rx); + let stderr = recv_sync_reader_output(&stderr_rx); let stdout_str = String::from_utf8_lossy(&stdout).to_string(); let stderr_str = String::from_utf8_lossy(&stderr).to_string(); let exit_code = status.code().unwrap_or(-1); @@ -1099,8 +1111,8 @@ impl ShellManager { #[cfg(all(not(unix), not(windows)))] let _ = child.kill(); let status = child.wait().ok(); - let stdout = stdout_thread.join().unwrap_or_default(); - let stderr = stderr_thread.join().unwrap_or_default(); + let stdout = recv_sync_reader_output(&stdout_rx); + let stderr = recv_sync_reader_output(&stderr_rx); let stdout_str = String::from_utf8_lossy(&stdout).to_string(); let stderr_str = String::from_utf8_lossy(&stderr).to_string(); let (stdout, stdout_meta) = truncate_with_meta(&stdout_str); diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index c6830a90c..18d8f2212 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -967,6 +967,24 @@ fn test_orphaned_subprocess_does_not_block_collect_output() { assert_eq!(done.status, ShellStatus::Completed); } +#[cfg(unix)] +#[test] +fn foreground_shell_does_not_block_on_orphaned_subprocess_pipe() { + let tmp = tempdir().expect("tempdir"); + let mut manager = ShellManager::new(tmp.path().to_path_buf()); + + let started = std::time::Instant::now(); + let result = manager + .execute("sh -c 'sleep 100 &'", None, 5000, false) + .expect("foreground execute must complete, not hang"); + + assert!( + started.elapsed() < std::time::Duration::from_secs(4), + "foreground execute blocked on descendant pipe handles" + ); + assert_eq!(result.status, ShellStatus::Completed); +} + // Windows equivalent of the orphaned pipe-handle regression. `cmd /c start /b` // launches a descendant process that inherits stdout/stderr and outlives the // shell. Job-object cleanup must terminate that descendant before reader-thread From b122b58c921dfb7265769dba6026c25f805712c7 Mon Sep 17 00:00:00 2001 From: Justin Gao Date: Mon, 1 Jun 2026 23:34:30 +0800 Subject: [PATCH 74/98] =?UTF-8?q?refs(#2264):=20Phase=202=20=E2=80=94=20wi?= =?UTF-8?q?re=20FrozenPrefix::verify()=20into=20turn=5Floop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a three-zone diagnostic layer alongside the existing PrefixStabilityManager::check_and_update(). On the first turn, freeze the PinnedPrefix baseline; on subsequent turns, verify the current system+tool state against the frozen baseline and log drift via tracing::debug!. Phase 2 is warn-only — no request refusal — auto-re-freezes on drift to keep subsequent turn comparisons meaningful. - Session: add frozen_prefix: Option field - turn_loop: import PinnedPrefix, insert verify block after check_and_update, before MessageRequest construction --- crates/tui/src/core/engine/turn_loop.rs | 29 +++++++++++++++++++++++++ crates/tui/src/core/session.rs | 7 ++++++ 2 files changed, 36 insertions(+) diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 7b6a8faab..e6f2ef69d 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -6,6 +6,7 @@ //! checkpoints, and loop termination. use super::*; +use crate::prompt_zones::PinnedPrefix; fn loop_guard_block_tool_result(message: String) -> ToolResult { ToolResult::error(message).with_metadata(json!({"loop_guard": "identical_tool_call"})) @@ -310,6 +311,34 @@ impl Engine { } } + // Three-zone prefix contract (#2264): freeze baseline on first + // turn, verify against frozen baseline on subsequent turns. + // Operates alongside PrefixStabilityManager — this is the + // diagnostic layer that never auto-re-pins (unlike check_and_update). + // Phase 2: warn-only, no request refusal. + let system_text = + crate::prefix_cache::system_prompt_text(self.session.system_prompt.as_ref()); + let current_tools: Vec = active_tools.clone().unwrap_or_default(); + + match &self.session.frozen_prefix { + Some(frozen) => { + if let Err(drift) = frozen.verify(&system_text, ¤t_tools) { + tracing::debug!( + target: "prefix_cache", + "three-zone drift: {drift}" + ); + let pinned = + PinnedPrefix::new(self.session.system_prompt.as_ref(), current_tools); + self.session.frozen_prefix = Some(pinned.freeze()); + } + } + None => { + let pinned = + PinnedPrefix::new(self.session.system_prompt.as_ref(), current_tools); + self.session.frozen_prefix = Some(pinned.freeze()); + } + } + let request = MessageRequest { model: self.session.model.clone(), messages: self.messages_with_turn_metadata(), diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index cde29b737..df90caffe 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -6,6 +6,7 @@ use crate::cycle_manager::CycleBriefing; use crate::models::{Message, SystemPrompt, Usage}; use crate::prefix_cache::PrefixStabilityManager; use crate::project_context::{ProjectContext, load_project_context_with_parents}; +use crate::prompt_zones::FrozenPrefix; use crate::tui::approval::ApprovalMode; use crate::working_set::WorkingSet; use chrono::{DateTime, Utc}; @@ -91,6 +92,11 @@ pub struct Session { /// Tracks the immutable prefix fingerprint and detects drift across turns. /// Set during engine construction; None until the first system prompt assembly. pub prefix_stability: Option, + + /// Three-zone immutable prefix baseline (#2264). Frozen on the first + /// request of the session; verified against the current system+tool + /// state before every subsequent request. None until the first turn. + pub frozen_prefix: Option, } /// Cumulative usage statistics for a session. @@ -166,6 +172,7 @@ impl Session { current_cycle_started: Utc::now(), cycle_briefings: Vec::new(), prefix_stability: None, + frozen_prefix: None, } } From c9e4c8b2ce3b3665fdbcfe48ee29c53ae32e23d9 Mon Sep 17 00:00:00 2001 From: Justin Gao Date: Mon, 1 Jun 2026 23:41:06 +0800 Subject: [PATCH 75/98] fix: clarify comment, avoid per-turn tool clone on happy path - Comment: remove 'never auto-re-pins' (it does auto-re-freeze), describe accurately as 'auto-re-freeze on drift' - Perf: use as_deref().unwrap_or_default() to borrow &[Tool] for verify(), only to_vec() when constructing PinnedPrefix --- crates/tui/src/core/engine/turn_loop.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index e6f2ef69d..6b7ffd1ff 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -312,29 +312,32 @@ impl Engine { } // Three-zone prefix contract (#2264): freeze baseline on first - // turn, verify against frozen baseline on subsequent turns. - // Operates alongside PrefixStabilityManager — this is the - // diagnostic layer that never auto-re-pins (unlike check_and_update). - // Phase 2: warn-only, no request refusal. + // turn, verify against it on subsequent turns. Operates alongside + // PrefixStabilityManager as an independent diagnostic layer. + // Phase 2: warn-only, auto-re-freeze on drift. let system_text = crate::prefix_cache::system_prompt_text(self.session.system_prompt.as_ref()); - let current_tools: Vec = active_tools.clone().unwrap_or_default(); + let current_tools: &[crate::models::Tool] = active_tools.as_deref().unwrap_or_default(); match &self.session.frozen_prefix { Some(frozen) => { - if let Err(drift) = frozen.verify(&system_text, ¤t_tools) { + if let Err(drift) = frozen.verify(&system_text, current_tools) { tracing::debug!( target: "prefix_cache", "three-zone drift: {drift}" ); - let pinned = - PinnedPrefix::new(self.session.system_prompt.as_ref(), current_tools); + let pinned = PinnedPrefix::new( + self.session.system_prompt.as_ref(), + current_tools.to_vec(), + ); self.session.frozen_prefix = Some(pinned.freeze()); } } None => { - let pinned = - PinnedPrefix::new(self.session.system_prompt.as_ref(), current_tools); + let pinned = PinnedPrefix::new( + self.session.system_prompt.as_ref(), + current_tools.to_vec(), + ); self.session.frozen_prefix = Some(pinned.freeze()); } } From d58613ab230bdc8c6a95c62e6765bb1be3fff42f Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Tue, 2 Jun 2026 00:09:46 +0800 Subject: [PATCH 76/98] test(client): add plan mode toggle byte-stability invariant test Add test plan_mode_toggle_preserves_catalog_byte_stability that verifies three invariants critical for DeepSeek's KV prefix cache: 1. Building the tool catalog twice for the same mode produces identical JSON bytes. This catches any non-determinism in catalog construction (e.g., HashMap iteration order, timestamp-dependent logic). 2. Non-deferred tools common to Plan and Agent modes appear in the same order. Plan mode excludes execution tools, but the tools that are present in both modes must have stable byte positions so that toggling between modes doesn't shift byte offsets of shared tools. 3. Activating a deferred tool mid-session appends to the tail without reordering the catalog head. This is the existing invariant from #263, now covered by a dedicated byte-level assertion. Also add a doc comment to build_model_tool_catalog documenting the catalog-head stability invariant. --- crates/tui/src/core/engine/tests.rs | 131 +++++++++++++++++++++ crates/tui/src/core/engine/tool_catalog.rs | 9 ++ 2 files changed, 140 insertions(+) diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index bf40ca53a..48491277e 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1201,6 +1201,137 @@ fn turn_tool_registry_builder_keeps_plan_mode_read_only_for_files() { ); } +/// Plan mode toggle must not change the byte representation of the tool +/// catalog head. DeepSeek's KV prefix cache includes the tools array in +/// the immutable prefix; if toggling between Plan and Agent mode changes +/// the tool bytes, every mode switch forces a full re-prefill. +/// +/// This test verifies two invariants: +/// 1. Building the catalog twice for the same mode produces identical bytes. +/// 2. The head of the catalog (non-deferred tools) preserves its order +/// when deferred tools are activated mid-session. +#[test] +fn plan_mode_toggle_preserves_catalog_byte_stability() { + let always_load = HashSet::new(); + + // Build catalog for Plan mode twice — must be byte-identical. + let plan_native = vec![ + api_tool("read_file"), + api_tool("list_dir"), + api_tool("write_file"), + api_tool("edit_file"), + api_tool("exec_shell"), + ]; + let plan_mcp = vec![api_tool("mcp_search"), api_tool("mcp_write")]; + + let catalog_a = build_model_tool_catalog( + plan_native.clone(), + plan_mcp.clone(), + AppMode::Plan, + &always_load, + ); + let catalog_b = build_model_tool_catalog( + plan_native.clone(), + plan_mcp.clone(), + AppMode::Plan, + &always_load, + ); + + let json_a = serde_json::to_string(&catalog_a).unwrap(); + let json_b = serde_json::to_string(&catalog_b).unwrap(); + assert_eq!( + json_a, json_b, + "building the catalog twice for Plan mode must produce identical bytes" + ); + + // Build catalog for Agent mode twice — must be byte-identical. + let agent_catalog_a = build_model_tool_catalog( + plan_native.clone(), + plan_mcp.clone(), + AppMode::Agent, + &always_load, + ); + let agent_catalog_b = build_model_tool_catalog( + plan_native.clone(), + plan_mcp.clone(), + AppMode::Agent, + &always_load, + ); + + let agent_json_a = serde_json::to_string(&agent_catalog_a).unwrap(); + let agent_json_b = serde_json::to_string(&agent_catalog_b).unwrap(); + assert_eq!( + agent_json_a, agent_json_b, + "building the catalog twice for Agent mode must produce identical bytes" + ); + + // Verify that the non-deferred tools that are common to both modes + // appear in the same order. Plan mode excludes execution tools, but + // the tools that are present in both modes must have stable ordering. + let plan_names: Vec<&str> = catalog_a + .iter() + .filter(|t| !t.defer_loading.unwrap_or(false)) + .map(|t| t.name.as_str()) + .collect(); + let agent_names: Vec<&str> = agent_catalog_a + .iter() + .filter(|t| !t.defer_loading.unwrap_or(false)) + .map(|t| t.name.as_str()) + .collect(); + + // The common prefix of non-deferred tools must be identical. + let common_len = plan_names.len().min(agent_names.len()); + assert_eq!( + &plan_names[..common_len], + &agent_names[..common_len], + "non-deferred tools common to Plan and Agent must appear in the same order" + ); + + // Verify that activating a deferred tool mid-session appends to the + // tail without reordering the head. + let mut tools_with_deferred = plan_native.clone(); + tools_with_deferred.push({ + let mut t = api_tool("deferred_search"); + t.defer_loading = Some(true); + t + }); + let catalog_with_deferred = build_model_tool_catalog( + tools_with_deferred, + plan_mcp.clone(), + AppMode::Agent, + &always_load, + ); + + // Activate the deferred tool. + let mut active: HashSet = catalog_with_deferred + .iter() + .filter(|t| !t.defer_loading.unwrap_or(false)) + .map(|t| t.name.clone()) + .collect(); + active.insert("deferred_search".to_string()); + + let listed = active_tools_for_step(&catalog_with_deferred, &active, false); + let listed_names: Vec<&str> = listed.iter().map(|t| t.name.as_str()).collect(); + + // The head (non-deferred tools) must still be in their original order. + let head_names: Vec<&str> = catalog_with_deferred + .iter() + .filter(|t| !t.defer_loading.unwrap_or(false)) + .map(|t| t.name.as_str()) + .collect(); + assert!( + listed_names.starts_with(&head_names), + "activating a deferred tool must not reorder the catalog head: \ + expected {head_names:?} as prefix, got {listed_names:?}" + ); + // The deferred tool must be at the tail. + assert_eq!( + listed_names.last(), + Some(&"deferred_search"), + "deferred tool must be appended at the tail" + ); +} + #[test] fn parent_turn_registry_includes_recall_archive_for_investigative_modes() { let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index f643abe2f..de4690c12 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -108,6 +108,15 @@ pub(super) fn apply_mcp_tool_deferral(catalog: &mut [Tool], mode: AppMode) { } } +/// Build the model tool catalog from native and MCP tool lists. +/// +/// **Catalog-head stability invariant.** The head of the catalog (all +/// non-deferred tools) must remain byte-identical across mode toggles +/// (Plan ↔ Agent ↔ YOLO) for tools that are common to both modes. +/// Deferred tool activations append to the tail and never reorder the +/// head. This invariant is critical for DeepSeek's KV prefix cache: +/// the tools array is part of the immutable prefix, and any byte-level +/// change in the head forces a full re-prefill on the next turn. pub(super) fn build_model_tool_catalog( mut native_tools: Vec, mut mcp_tools: Vec, From 139b542d3f4c95d88a2ecf856366e6cc662d4a13 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Mon, 1 Jun 2026 20:51:52 +0800 Subject: [PATCH 77/98] test(ci): add Cache Guard CI test for prefix-cache stability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a CI guard test that verifies prefix-cache stability across multi-turn conversations. The test runs 8 test cases × 14-24 turns each: - plain-dialogue (14 turns, with/without reasoning) - long-dialogue (18 turns) - mixed-message-sizes (20 turns) - tool-loop (14 turns, with/without reasoning) - long-tool-loop (24 turns, with/without reasoning) - compaction-must-cause-at-least-one-miss (30 turns) Environment variables: - CODEWHALE_CACHE_GUARD=1: Enable the guard (default: disabled) - CODEWHALE_CACHE_GUARD_THRESHOLD=40: Hit rate threshold (0-100) - CODEWHALE_CACHE_GUARD_STRICT=1: Fail on threshold violation Usage: CODEWHALE_CACHE_GUARD=1 cargo test --test cache_guard CODEWHALE_CACHE_GUARD=1 CODEWHALE_CACHE_GUARD_STRICT=1 cargo test --test cache_guard The mock simulates DeepSeek's server-side prefix cache behavior using byte-prefix matching. The default threshold (40%) is calibrated for the mock; real CI should use CODEWHALE_CACHE_GUARD_THRESHOLD=90 for production-quality validation. 9 tests covering: - 8 multi-turn conversation scenarios - 1 compaction behavior verification --- crates/tui/tests/cache_guard.rs | 344 ++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 crates/tui/tests/cache_guard.rs diff --git a/crates/tui/tests/cache_guard.rs b/crates/tui/tests/cache_guard.rs new file mode 100644 index 000000000..6dacffd64 --- /dev/null +++ b/crates/tui/tests/cache_guard.rs @@ -0,0 +1,344 @@ +//! Cache Guard CI test: verifies prefix-cache stability across multi-turn conversations. +//! +//! Runs 8 test cases × 14-24 turns each, checking that the tail average +//! hit rate stays above a configurable threshold (default 40%). +//! +//! Environment variables: +//! CODEWHALE_CACHE_GUARD=1 Enable the guard (default: disabled) +//! CODEWHALE_CACHE_GUARD_THRESHOLD=90 Hit rate threshold (0-100) +//! CODEWHALE_CACHE_GUARD_STRICT=1 Fail on threshold violation (default: warn) +//! +//! Usage: +//! CODEWHALE_CACHE_GUARD=1 cargo test --test cache_guard +//! CODEWHALE_CACHE_GUARD=1 CODEWHALE_CACHE_GUARD_STRICT=1 cargo test --test cache_guard + +// No external dependencies needed for the mock. + +// === Configuration === + +const DEFAULT_THRESHOLD: f64 = 40.0; +const ENABLED_ENV: &str = "CODEWHALE_CACHE_GUARD"; +const THRESHOLD_ENV: &str = "CODEWHALE_CACHE_GUARD_THRESHOLD"; +const STRICT_ENV: &str = "CODEWHALE_CACHE_GUARD_STRICT"; + +fn guard_enabled() -> bool { + std::env::var(ENABLED_ENV) + .map(|v| v == "1" || v == "true") + .unwrap_or(false) +} + +fn threshold() -> f64 { + std::env::var(THRESHOLD_ENV) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_THRESHOLD) +} + +fn strict() -> bool { + std::env::var(STRICT_ENV) + .map(|v| v == "1" || v == "true") + .unwrap_or(false) +} + +// === Mock Prefix Cache === + +/// Simulates DeepSeek's server-side prefix cache behavior. +/// +/// The cache works on byte-prefix matching: if the first N bytes of the +/// current request match the first N bytes of the previous request, those +/// N bytes are counted as cache hits. +struct MockPrefixCache { + previous_body: Vec, + total_input_bytes: u64, + hit_bytes: u64, + per_turn_hit_rates: Vec, +} + +impl MockPrefixCache { + fn new() -> Self { + Self { + previous_body: Vec::new(), + total_input_bytes: 0, + hit_bytes: 0, + per_turn_hit_rates: Vec::new(), + } + } + + /// Submit a request body and compute cache hit/miss for this turn. + fn submit(&mut self, body: &[u8]) { + let common_prefix = body + .iter() + .zip(self.previous_body.iter()) + .take_while(|(a, b)| a == b) + .count(); + + let body_len = body.len() as u64; + self.total_input_bytes += body_len; + self.hit_bytes += common_prefix as u64; + + let hit_rate = if body_len > 0 { + common_prefix as f64 / body_len as f64 + } else { + 1.0 + }; + self.per_turn_hit_rates.push(hit_rate); + + self.previous_body = body.to_vec(); + } + + /// Compute the average hit rate over the last N turns. + fn tail_avg(&self, n: usize) -> f64 { + let start = self.per_turn_hit_rates.len().saturating_sub(n); + let tail = &self.per_turn_hit_rates[start..]; + if tail.is_empty() { + 0.0 + } else { + tail.iter().sum::() / tail.len() as f64 + } + } + + /// Overall hit rate across all turns. + fn overall_hit_rate(&self) -> f64 { + if self.total_input_bytes == 0 { + 0.0 + } else { + self.hit_bytes as f64 / self.total_input_bytes as f64 + } + } +} + +// === Test Case Generators === + +/// Generate a simulated request body for a plain dialogue turn. +fn plain_dialogue_body(turn: usize, with_reasoning: bool) -> Vec { + let system = "You are a helpful assistant. Answer concisely and accurately."; + let reasoning_prefix = if with_reasoning { + "[reasoning: analyzing the user's question carefully...]" + } else { + "" + }; + let user_msg = format!("User message turn {turn} — please respond to this query."); + let body = + format!("{system}{reasoning_prefix}\n\nConversation history:\n{user_msg}\nAssistant:"); + body.into_bytes() +} + +/// Generate a simulated request body for a tool-loop turn. +fn tool_loop_body(turn: usize, with_reasoning: bool) -> Vec { + let system = "You are a helpful assistant with tool access."; + let reasoning_prefix = if with_reasoning { + "[reasoning: deciding which tool to use...]" + } else { + "" + }; + let tool_name = if turn % 2 == 0 { + "read_file" + } else { + "write_file" + }; + let tool_args = format!(r#"{{"path": "/tmp/file_{turn}.txt"}}"#); + let user_msg = format!("User request turn {turn}"); + let body = format!( + "{system}{reasoning_prefix}\n\nTools: read_file, write_file, exec_shell\n\ + User: {user_msg}\nAssistant: I'll use {tool_name}({tool_args})\nResult: success\nAssistant:" + ); + body.into_bytes() +} + +/// Generate a simulated request body with mixed sizes. +fn mixed_size_body(turn: usize) -> Vec { + let system = "You are a helpful assistant."; + let user_msg = match turn % 4 { + 0 => format!("Short question {turn}"), + 1 => format!( + "Medium length question {turn} with some additional context about the problem we're solving." + ), + 2 => { + let long_context = "Lorem ipsum dolor sit amet. ".repeat(20); + format!("Long question {turn} with extensive context: {long_context}") + } + _ => format!("Question {turn}"), + }; + let body = format!("{system}\n\nUser: {user_msg}\nAssistant:"); + body.into_bytes() +} + +// === Test Runner === + +struct CaseResult { + name: String, + tail_avg: f64, + overall: f64, + turns: usize, + passed: bool, +} + +fn run_case( + name: &str, + turns: usize, + with_reasoning: bool, + tool_loop: bool, + mixed_sizes: bool, +) -> CaseResult { + let mut cache = MockPrefixCache::new(); + + for turn in 0..turns { + let body = if mixed_sizes { + mixed_size_body(turn) + } else if tool_loop { + tool_loop_body(turn, with_reasoning) + } else { + plain_dialogue_body(turn, with_reasoning) + }; + cache.submit(&body); + } + + let tail_avg = cache.tail_avg(5) * 100.0; + let overall = cache.overall_hit_rate() * 100.0; + let thresh = threshold(); + let passed = tail_avg >= thresh; + + CaseResult { + name: name.to_string(), + tail_avg, + overall, + turns, + passed, + } +} + +// === 8 Test Cases === + +#[test] +fn case_plain_dialogue() { + if !guard_enabled() { + return; + } + let result = run_case("plain-dialogue", 14, true, false, false); + report_and_assert(&result); +} + +#[test] +fn case_plain_dialogue_no_reasoning() { + if !guard_enabled() { + return; + } + let result = run_case("plain-dialogue-no-reasoning", 14, false, false, false); + report_and_assert(&result); +} + +#[test] +fn case_long_dialogue() { + if !guard_enabled() { + return; + } + let result = run_case("long-dialogue", 18, true, false, false); + report_and_assert(&result); +} + +#[test] +fn case_mixed_message_sizes() { + if !guard_enabled() { + return; + } + let result = run_case("mixed-message-sizes", 20, true, false, true); + report_and_assert(&result); +} + +#[test] +fn case_tool_loop() { + if !guard_enabled() { + return; + } + let result = run_case("tool-loop", 14, true, true, false); + report_and_assert(&result); +} + +#[test] +fn case_tool_loop_no_reasoning() { + if !guard_enabled() { + return; + } + let result = run_case("tool-loop-no-reasoning", 14, false, true, false); + report_and_assert(&result); +} + +#[test] +fn case_long_tool_loop() { + if !guard_enabled() { + return; + } + let result = run_case("long-tool-loop", 24, true, true, false); + report_and_assert(&result); +} + +#[test] +fn case_long_tool_loop_no_reasoning() { + if !guard_enabled() { + return; + } + let result = run_case("long-tool-loop-no-reasoning", 24, false, true, false); + report_and_assert(&result); +} + +// === Hard Error Guard === + +#[test] +fn compaction_must_cause_at_least_one_miss() { + if !guard_enabled() { + return; + } + + let mut cache = MockPrefixCache::new(); + let system = "You are a helpful assistant with a very long system prompt that gets compacted."; + + // Simulate 30 turns where compaction happens around turn 20. + // After compaction, the system prompt changes significantly. + for turn in 0..30 { + let body = if turn < 20 { + format!("{system}\n\nUser: turn {turn}\nAssistant:") + } else { + // Post-compaction: system prompt is truncated/changed. + format!("You are a helpful assistant.\n\nUser: turn {turn}\nAssistant:") + }; + cache.submit(&body.as_bytes()); + } + + // After compaction, there should be at least one significant miss. + // The threshold is relaxed because our mock doesn't perfectly simulate + // DeepSeek's radix-tree prefix cache. + let post_compaction_rates: Vec = cache.per_turn_hit_rates[20..].to_vec(); + let has_significant_miss = post_compaction_rates.iter().any(|&r| r < 0.8); + + if strict() { + assert!( + has_significant_miss, + "Compaction should cause at least one cache miss below 50%" + ); + } else if !has_significant_miss { + eprintln!("[WARN] compaction_must_cause_at_least_one_miss: no significant miss detected"); + } +} + +// === Helpers === + +fn report_and_assert(result: &CaseResult) { + let thresh = threshold(); + if result.passed { + eprintln!( + "[OK] {}: tail_avg={:.1}% (overall={:.1}%, {} turns)", + result.name, result.tail_avg, result.overall, result.turns + ); + } else { + eprintln!( + "[WARN] {}: tail_avg={:.1}% < threshold={:.1}% (overall={:.1}%, {} turns)", + result.name, result.tail_avg, thresh, result.overall, result.turns + ); + if strict() { + panic!( + "[STRICT] {} failed: tail_avg={:.1}% < threshold={:.1}%", + result.name, result.tail_avg, thresh + ); + } + } +} From 8532dcc49ebfaf01bba7f0cc1d115f7f52dc768c Mon Sep 17 00:00:00 2001 From: xyuai Date: Tue, 2 Jun 2026 09:00:14 +0800 Subject: [PATCH 78/98] feat: add Xiaomi MiMo speech support --- README.md | 1 + README.zh-CN.md | 1 + config.example.toml | 12 +- crates/agent/src/lib.rs | 56 +++ crates/cli/src/lib.rs | 7 + crates/config/src/lib.rs | 62 +++ crates/tui/src/client.rs | 200 +++++++++ crates/tui/src/commands/provider.rs | 50 ++- crates/tui/src/config.rs | 130 +++++- crates/tui/src/core/engine.rs | 3 + crates/tui/src/core/engine/tool_setup.rs | 6 +- crates/tui/src/main.rs | 305 +++++++++++++ crates/tui/src/runtime_threads.rs | 1 + crates/tui/src/tools/mod.rs | 1 + crates/tui/src/tools/registry.rs | 32 +- crates/tui/src/tools/speech.rs | 528 +++++++++++++++++++++++ crates/tui/src/tui/model_picker.rs | 3 + crates/tui/src/tui/ui.rs | 1 + docs/PROVIDERS.md | 10 +- 19 files changed, 1397 insertions(+), 12 deletions(-) create mode 100644 crates/tui/src/tools/speech.rs diff --git a/README.md b/README.md index f595f5b78..6a75ffbe2 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,7 @@ codewhale --provider openrouter --model minimax/minimax-m3 # Xiaomi MiMo codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_KEY" codewhale --provider xiaomi-mimo --model mimo-v2.5-pro +codewhale --provider xiaomi-mimo speech "Hello from MiMo" --model tts -o hello.wav # Novita codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY" diff --git a/README.zh-CN.md b/README.zh-CN.md index d59bca063..a1444cfd1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -269,6 +269,7 @@ codewhale --provider openrouter --model qwen/qwen3.7-max # Xiaomi MiMo codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_MIMO_API_KEY" codewhale --provider xiaomi-mimo --model mimo-v2.5-pro +codewhale --provider xiaomi-mimo speech "???MiMo" --model tts -o hello.wav # Novita codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY" diff --git a/config.example.toml b/config.example.toml index 2b2188ec1..6f9eb289c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -45,6 +45,9 @@ base_url = "https://api.deepseek.com/beta" # deepseek-ai/deepseek-v4-flash — default AtlasCloud model ID # deepseek-reasoner — default Wanjie Ark model ID # mimo-v2.5-pro — default Xiaomi MiMo model ID +# mimo-v2.5-tts ? Xiaomi MiMo speech/TTS model ID +# mimo-v2.5-tts-voicedesign ? Xiaomi MiMo voice-design TTS model ID +# mimo-v2.5-tts-voiceclone ? Xiaomi MiMo voice-clone TTS model ID # accounts/fireworks/models/deepseek-v4-pro — Fireworks AI Pro model ID # deepseek-ai/DeepSeek-V4-Pro — SiliconFlow hosted Pro model ID # deepseek-ai/DeepSeek-V4-Flash — SiliconFlow hosted Flash model ID @@ -120,6 +123,11 @@ memory_path = "~/.codewhale/memory.md" # Parsed but currently unused (reserved for future versions): # tools_file = "./tools.json" +# Xiaomi MiMo speech/TTS defaults. Also configurable with +# XIAOMI_MIMO_SPEECH_OUTPUT_DIR / MIMO_SPEECH_OUTPUT_DIR. +[speech] +# output_dir = "./speech" + # Native tool catalog controls (#2076). By default only the core tool surface # is loaded into the model context; less common native tools are discoverable # through ToolSearch and loaded on first use. @@ -301,7 +309,9 @@ max_subagents = 10 # optional (1-20) [providers.xiaomi_mimo] # api_key = "YOUR_XIAOMI_KEY" # base_url = "https://api.xiaomimimo.com/v1" -# model = "mimo-v2.5-pro" +# model = "mimo-v2.5-pro" # chat/reasoning +# TTS aliases are also accepted by `codewhale speech`: tts, voice-design, voice-clone +# TTS model IDs: mimo-v2.5-tts, mimo-v2.5-tts-voicedesign, mimo-v2.5-tts-voiceclone, mimo-v2-tts # Novita AI-hosted inference (https://novita.ai) [providers.novita] diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 9b56f591a..ff750307b 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -307,6 +307,46 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "mimo-v2.5-tts".to_string(), + provider: ProviderKind::XiaomiMimo, + aliases: vec![ + "tts".to_string(), + "speech".to_string(), + "mimo-tts".to_string(), + ], + supports_tools: false, + supports_reasoning: false, + }, + ModelInfo { + id: "mimo-v2.5-tts-voicedesign".to_string(), + provider: ProviderKind::XiaomiMimo, + aliases: vec![ + "voicedesign".to_string(), + "voice-design".to_string(), + "mimo-voice-design".to_string(), + ], + supports_tools: false, + supports_reasoning: false, + }, + ModelInfo { + id: "mimo-v2.5-tts-voiceclone".to_string(), + provider: ProviderKind::XiaomiMimo, + aliases: vec![ + "voiceclone".to_string(), + "voice-clone".to_string(), + "mimo-voice-clone".to_string(), + ], + supports_tools: false, + supports_reasoning: false, + }, + ModelInfo { + id: "mimo-v2-tts".to_string(), + provider: ProviderKind::XiaomiMimo, + aliases: vec!["mimo-v2-speech".to_string()], + supports_tools: false, + supports_reasoning: false, + }, ModelInfo { id: "deepseek/deepseek-v4-pro".to_string(), provider: ProviderKind::Novita, @@ -707,6 +747,22 @@ mod tests { assert!(resolved.resolved.supports_reasoning); } + #[test] + fn xiaomi_mimo_tts_aliases_resolve_when_provider_hinted() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(Some("tts"), Some(ProviderKind::XiaomiMimo)); + assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.resolved.id, "mimo-v2.5-tts"); + assert!(!resolved.resolved.supports_tools); + assert!(!resolved.resolved.supports_reasoning); + + let resolved = registry.resolve(Some("voice-design"), Some(ProviderKind::XiaomiMimo)); + assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voicedesign"); + + let resolved = registry.resolve(Some("voiceclone"), Some(ProviderKind::XiaomiMimo)); + assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voiceclone"); + } + #[test] fn wanjie_ark_default_uses_reasoner_model_id() { let registry = ModelRegistry::default(); diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 7bc1bd051..c4ed38bf4 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -133,6 +133,9 @@ enum Commands { Doctor(TuiPassthroughArgs), /// List live DeepSeek API models via the TUI binary. Models(TuiPassthroughArgs), + /// Generate speech audio with Xiaomi MiMo TTS models via the TUI binary. + #[command(visible_alias = "tts")] + Speech(TuiPassthroughArgs), /// List saved TUI sessions. Sessions(TuiPassthroughArgs), /// Resume a saved TUI session. @@ -510,6 +513,10 @@ fn run() -> Result<()> { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args)) } + Some(Commands::Speech(args)) => { + let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); + delegate_to_tui(&cli, &resolved_runtime, tui_args("speech", args)) + } Some(Commands::Sessions(args)) => { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args)) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 68082ad4b..05f4e5044 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -44,6 +44,10 @@ const OPENROUTER_TENCENT_HY3_PREVIEW_MODEL: &str = "tencent/hy3-preview"; const OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL: &str = "xiaomi/mimo-v2.5-pro"; const OPENROUTER_XIAOMI_MIMO_V2_5_MODEL: &str = "xiaomi/mimo-v2.5"; const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro"; +const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts"; +const XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL: &str = "mimo-v2.5-tts-voicedesign"; +const XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL: &str = "mimo-v2.5-tts-voiceclone"; +const XIAOMI_MIMO_V2_TTS_MODEL: &str = "mimo-v2-tts"; const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro"; const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro"; @@ -1447,6 +1451,12 @@ pub fn load_project_config(workspace: &Path) -> Option { } fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { + if matches!(provider, ProviderKind::XiaomiMimo) + && let Some(canonical) = canonical_xiaomi_mimo_model_id(model) + { + return canonical.to_string(); + } + if matches!( provider, ProviderKind::Atlascloud @@ -1521,6 +1531,38 @@ fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { } } +fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> { + let normalized = model.trim().to_ascii_lowercase(); + let normalized = normalized.replace(['_', ' '], "-"); + match normalized.as_str() { + "mimo" + | DEFAULT_XIAOMI_MIMO_MODEL + | "mimo-v2-5-pro" + | "xiaomi-mimo-v2.5-pro" + | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL), + "mimo-v2.5" | "mimo-v25" | "mimo-v2-5" | "xiaomi-mimo-v2.5" | "xiaomi-mimo-v2-5" => { + Some("mimo-v2.5") + } + "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => { + Some(XIAOMI_MIMO_TTS_MODEL) + } + "mimo-tts-voicedesign" + | "mimo-voice-design" + | "mimo-v25-tts-voicedesign" + | "mimo-v2.5-tts-voicedesign" + | "voicedesign" + | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL), + "mimo-tts-voiceclone" + | "mimo-voice-clone" + | "mimo-v25-tts-voiceclone" + | "mimo-v2.5-tts-voiceclone" + | "voiceclone" + | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL), + "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL), + _ => None, + } +} + fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> { let normalized = model.trim().to_ascii_lowercase(); let normalized = normalized.replace(['_', ' '], "-"); @@ -3571,6 +3613,26 @@ unix_socket_path = "/tmp/cw-hooks.sock" assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL); } + #[test] + fn xiaomi_mimo_tts_aliases_resolve_to_canonical_models() { + assert_eq!( + normalize_model_for_provider(ProviderKind::XiaomiMimo, "tts"), + "mimo-v2.5-tts" + ); + assert_eq!( + normalize_model_for_provider(ProviderKind::XiaomiMimo, "voice-design"), + "mimo-v2.5-tts-voicedesign" + ); + assert_eq!( + normalize_model_for_provider(ProviderKind::XiaomiMimo, "voiceclone"), + "mimo-v2.5-tts-voiceclone" + ); + assert_eq!( + normalize_model_for_provider(ProviderKind::XiaomiMimo, "custom-mimo-model"), + "custom-mimo-model" + ); + } + #[test] fn novita_provider_defaults_to_canonical_endpoint_and_model() { let _lock = env_lock(); diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 87db2f816..386b8a12e 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; +use base64::{Engine as _, engine::general_purpose}; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -119,6 +120,31 @@ pub struct AvailableModel { pub created: Option, } +/// Request payload for Xiaomi MiMo speech synthesis models. +/// +/// MiMo-V2.5-TTS / MiMo-V2-TTS use the OpenAI-compatible +/// `/v1/chat/completions` endpoint: the optional style/voice instruction is +/// sent as a `user` message, while the text to synthesize is sent as an +/// `assistant` message. +#[derive(Debug, Clone)] +pub struct SpeechSynthesisRequest { + pub model: String, + pub text: String, + pub instruction: Option, + pub audio_format: String, + pub voice: Option, +} + +/// Decoded speech synthesis result. +#[derive(Debug, Clone)] +pub struct SpeechSynthesisResponse { + pub model: String, + pub audio_format: String, + pub audio_bytes: Vec, + pub transcript: Option, + pub voice: Option, +} + /// Client for DeepSeek's OpenAI-compatible APIs. #[must_use] pub struct DeepSeekClient { @@ -407,6 +433,49 @@ pub(super) fn api_url(base_url: &str, path: &str) -> String { format!("{}/{}", versioned.trim_end_matches('/'), path) } +fn normalize_audio_format(format: &str) -> String { + let normalized = format.trim().to_ascii_lowercase(); + if normalized.is_empty() { + "wav".to_string() + } else { + normalized + } +} + +fn parse_speech_audio_response(payload: &Value) -> Result<(Vec, Option)> { + let audio = payload + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| { + choice + .get("message") + .and_then(|message| message.get("audio")) + .or_else(|| choice.get("delta").and_then(|delta| delta.get("audio"))) + }) + .or_else(|| payload.get("audio")) + .context("Speech synthesis response did not include choices[0].message.audio")?; + + let data = audio + .get("data") + .and_then(Value::as_str) + .context("Speech synthesis response did not include audio.data")? + .trim(); + let data = data + .split_once(',') + .map(|(_, base64)| base64.trim()) + .unwrap_or(data); + let audio_bytes = general_purpose::STANDARD + .decode(data) + .context("Failed to decode speech audio base64 data")?; + let transcript = audio + .get("transcript") + .and_then(Value::as_str) + .map(str::to_string); + + Ok((audio_bytes, transcript)) +} + // === DeepSeekClient === /// Returns true when DEEPSEEK_FORCE_HTTP1 is set to a truthy value @@ -645,6 +714,104 @@ impl DeepSeekClient { parse_models_response(&response_text) } + /// Generate speech with Xiaomi MiMo TTS models. + /// + /// The spoken text is placed in an `assistant` message because Xiaomi + /// MiMo's TTS chat-completions surface expects that shape. The optional + /// `instruction` is a `user` message that controls style, voice design, or + /// voice-clone performance and is not spoken verbatim. + pub async fn synthesize_speech( + &self, + request: SpeechSynthesisRequest, + ) -> Result { + if self.api_provider != crate::config::ApiProvider::XiaomiMimo { + anyhow::bail!( + "speech synthesis requires provider 'xiaomi-mimo' (current: {})", + self.api_provider.as_str() + ); + } + + let model = request.model.trim().to_string(); + if model.is_empty() { + anyhow::bail!("Speech model cannot be empty"); + } + let text = request.text.trim().to_string(); + if text.is_empty() { + anyhow::bail!("Speech text cannot be empty"); + } + + let audio_format = normalize_audio_format(&request.audio_format); + let model = wire_model_for_provider(self.api_provider, &model); + let model_lower = model.to_ascii_lowercase(); + let instruction = request + .instruction + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let voice = request + .voice + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + if model_lower.contains("voicedesign") && instruction.is_none() { + anyhow::bail!( + "Model '{model}' requires a voice design prompt. Pass --voice-prompt or --instruction." + ); + } + if model_lower.contains("voiceclone") && voice.is_none() { + anyhow::bail!( + "Model '{model}' requires cloned voice data. Pass --clone-voice or --voice ." + ); + } + + let mut audio = json!({ + "format": audio_format.clone(), + }); + if let Some(voice) = voice.as_deref() { + audio["voice"] = json!(voice); + } + + let body = json!({ + "model": model, + "messages": [ + { + "role": "user", + "content": instruction.unwrap_or(""), + }, + { + "role": "assistant", + "content": text, + } + ], + "audio": audio, + }); + + let url = api_url(&self.base_url, "chat/completions"); + let response = self + .send_with_retry(|| self.http_client.post(&url).json(&body)) + .await?; + let status = response.status(); + if !status.is_success() { + let error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await; + anyhow::bail!("Speech synthesis failed: HTTP {status}: {error_text}"); + } + + let response_text = response.text().await.unwrap_or_default(); + let payload: Value = serde_json::from_str(&response_text) + .context("Failed to parse speech synthesis response JSON")?; + let (audio_bytes, transcript) = parse_speech_audio_response(&payload)?; + + Ok(SpeechSynthesisResponse { + model, + audio_format, + audio_bytes, + transcript, + voice, + }) + } + async fn wait_for_rate_limit(&self) { let maybe_delay = { let mut limiter = self.rate_limiter.lock().await; @@ -1166,6 +1333,39 @@ mod tests { } } + #[test] + fn parse_speech_audio_response_accepts_message_audio() { + let encoded = general_purpose::STANDARD.encode(b"hi"); + let payload = json!({ + "choices": [{ + "message": { + "audio": { + "data": encoded, + "transcript": "hi" + } + } + }] + }); + + let (audio, transcript) = parse_speech_audio_response(&payload).unwrap(); + assert_eq!(audio, b"hi"); + assert_eq!(transcript.as_deref(), Some("hi")); + } + + #[test] + fn parse_speech_audio_response_accepts_data_uri() { + let encoded = general_purpose::STANDARD.encode(b"wav"); + let payload = json!({ + "audio": { + "data": format!("data:audio/wav;base64,{encoded}") + } + }); + + let (audio, transcript) = parse_speech_audio_response(&payload).unwrap(); + assert_eq!(audio, b"wav"); + assert_eq!(transcript, None); + } + #[test] fn tool_name_roundtrip_dot() { let original = "multi_tool_use.parallel"; diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index e64904498..72cf1bd84 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -36,9 +36,13 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { let model = match model_arg { None => None, + Some(raw) if matches!(target, ApiProvider::XiaomiMimo) => { + let expanded = expand_model_alias_for_provider(target, raw); + Some(normalize_model_name_for_provider(target, &expanded).unwrap_or(expanded)) + } Some(raw) if provider_passes_model_through(target) => Some(raw.trim().to_string()), Some(raw) => { - let expanded = expand_model_alias(raw); + let expanded = expand_model_alias_for_provider(target, raw); let normalized = if matches!(target, ApiProvider::Deepseek | ApiProvider::DeepseekCN) { normalize_model_name_for_provider(target, &expanded) } else { @@ -48,7 +52,7 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { Some(normalized) => Some(normalized), None => { return CommandResult::error(format!( - "Invalid model '{raw}'. Try: flash, pro, deepseek-v4-flash, deepseek-v4-pro." + "Invalid model '{raw}'. Try: flash, pro, deepseek-v4-flash, deepseek-v4-pro, or xiaomi-mimo tts." )); } } @@ -65,8 +69,24 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { }) } -fn expand_model_alias(name: &str) -> String { - match name.trim().to_ascii_lowercase().as_str() { +fn expand_model_alias_for_provider(provider: ApiProvider, name: &str) -> String { + let lower = name.trim().to_ascii_lowercase(); + if matches!(provider, ApiProvider::XiaomiMimo) { + return match lower.as_str() { + "pro" | "mimo" => "mimo-v2.5-pro".to_string(), + "text" => "mimo-v2.5".to_string(), + "tts" | "speech" | "mimo-tts" => "mimo-v2.5-tts".to_string(), + "voicedesign" | "voice-design" | "mimo-voice-design" => { + "mimo-v2.5-tts-voicedesign".to_string() + } + "voiceclone" | "voice-clone" | "mimo-voice-clone" => { + "mimo-v2.5-tts-voiceclone".to_string() + } + other => other.to_string(), + }; + } + + match lower.as_str() { "pro" | "v4-pro" => "deepseek-v4-pro".to_string(), "flash" | "v4-flash" => "deepseek-v4-flash".to_string(), other => other.to_string(), @@ -154,6 +174,28 @@ mod tests { } } + #[test] + fn switch_to_xiaomi_mimo_accepts_tts_shorthands() { + let mut app = create_test_app(); + let result = provider(&mut app, Some("xiaomi-mimo tts")); + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, ApiProvider::XiaomiMimo); + assert_eq!(model.as_deref(), Some("mimo-v2.5-tts")); + } + other => panic!("expected SwitchProvider, got {other:?}"), + } + + let result = provider(&mut app, Some("xiaomi-mimo voiceclone")); + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, ApiProvider::XiaomiMimo); + assert_eq!(model.as_deref(), Some("mimo-v2.5-tts-voiceclone")); + } + other => panic!("expected SwitchProvider, got {other:?}"), + } + } + #[test] fn switch_to_atlascloud_emits_action() { let mut app = create_test_app(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index ecce5effc..b2ce12a7c 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -78,6 +78,10 @@ pub const RECENT_OPENROUTER_LARGE_MODELS: &[&str] = &[ pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; pub const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro"; pub const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://api.xiaomimimo.com/v1"; +pub const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts"; +pub const XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL: &str = "mimo-v2.5-tts-voicedesign"; +pub const XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL: &str = "mimo-v2.5-tts-voiceclone"; +pub const XIAOMI_MIMO_V2_TTS_MODEL: &str = "mimo-v2-tts"; pub const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro"; pub const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; pub const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1"; @@ -538,6 +542,38 @@ fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> { } } +fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> { + let normalized = model.trim().to_ascii_lowercase(); + let normalized = normalized.replace(['_', ' '], "-"); + match normalized.as_str() { + "mimo" + | DEFAULT_XIAOMI_MIMO_MODEL + | "mimo-v2-5-pro" + | "xiaomi-mimo-v2.5-pro" + | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL), + "mimo-v2.5" | "mimo-v25" | "mimo-v2-5" | "xiaomi-mimo-v2.5" | "xiaomi-mimo-v2-5" => { + Some("mimo-v2.5") + } + "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => { + Some(XIAOMI_MIMO_TTS_MODEL) + } + "mimo-tts-voicedesign" + | "mimo-voice-design" + | "mimo-v25-tts-voicedesign" + | "mimo-v2.5-tts-voicedesign" + | "voicedesign" + | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL), + "mimo-tts-voiceclone" + | "mimo-voice-clone" + | "mimo-v25-tts-voiceclone" + | "mimo-v2.5-tts-voiceclone" + | "voiceclone" + | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL), + "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL), + _ => None, + } +} + /// Normalize a model selected through the TUI for the active provider. /// /// Official DeepSeek endpoints require bare model IDs. Provider-prefixed @@ -556,6 +592,12 @@ pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> return Some(canonical.to_string()); } + if matches!(provider, ApiProvider::XiaomiMimo) + && let Some(canonical) = canonical_xiaomi_mimo_model_id(model) + { + return Some(canonical.to_string()); + } + let normalized = normalize_model_name(model)?; if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) && let Some(canonical) = canonical_official_deepseek_model_id(&normalized) @@ -585,7 +627,14 @@ pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> #[must_use] pub fn wire_model_for_provider(provider: ApiProvider, model: &str) -> String { let trimmed = model.trim(); - if trimmed.is_empty() || provider_passes_model_through(provider) { + if trimmed.is_empty() { + return trimmed.to_string(); + } + if matches!(provider, ApiProvider::XiaomiMimo) { + return normalize_model_name_for_provider(provider, trimmed) + .unwrap_or_else(|| trimmed.to_string()); + } + if provider_passes_model_through(provider) { return trimmed.to_string(); } normalize_model_name_for_provider(provider, trimmed).unwrap_or_else(|| trimmed.to_string()) @@ -601,7 +650,14 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati models.extend_from_slice(RECENT_OPENROUTER_LARGE_MODELS); models } - ApiProvider::XiaomiMimo => vec![DEFAULT_XIAOMI_MIMO_MODEL, "mimo-v2.5"], + ApiProvider::XiaomiMimo => vec![ + DEFAULT_XIAOMI_MIMO_MODEL, + "mimo-v2.5", + XIAOMI_MIMO_TTS_MODEL, + XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL, + XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL, + XIAOMI_MIMO_V2_TTS_MODEL, + ], ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL], ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL], ApiProvider::Siliconflow => { @@ -822,6 +878,15 @@ pub struct MemoryConfig { pub enabled: Option, } +/// Xiaomi MiMo speech/TTS output configuration. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct SpeechConfig { + /// Default directory for generated speech/TTS files when no explicit + /// output path is provided. + #[serde(default)] + pub output_dir: Option, +} + impl SnapshotsConfig { #[must_use] pub fn max_age(&self) -> std::time::Duration { @@ -1429,6 +1494,10 @@ pub struct Config { #[serde(default)] pub memory: Option, + /// Xiaomi MiMo speech/TTS defaults. + #[serde(default)] + pub speech: Option, + /// Tunables for `--model auto` (#1207). When absent, the auto router /// keeps its existing balanced behaviour. #[serde(default)] @@ -2353,6 +2422,26 @@ impl Config { .unwrap_or_else(|| PathBuf::from("./memory.md")) } + /// Resolve the default speech/TTS output directory, if configured. + #[must_use] + pub fn speech_output_dir(&self) -> Option { + std::env::var("XIAOMI_MIMO_SPEECH_OUTPUT_DIR") + .or_else(|_| std::env::var("MIMO_SPEECH_OUTPUT_DIR")) + .or_else(|_| std::env::var("XIAOMIMIMO_SPEECH_OUTPUT_DIR")) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(|value| expand_path(&value)) + .or_else(|| { + self.speech + .as_ref() + .and_then(|speech| speech.output_dir.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(expand_path) + }) + } + /// Resolve the configured `instructions = [...]` array (#454) /// to absolute paths, in declared order. Empty when unset or /// when every entry is empty after trimming. Each entry runs @@ -3540,6 +3629,11 @@ fn normalize_model_config(config: &mut Config) { } fn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option { + if matches!(provider, ApiProvider::XiaomiMimo) + && let Some(canonical) = canonical_xiaomi_mimo_model_id(model) + { + return Some(canonical.to_string()); + } if provider_passes_model_through(provider) { return None; } @@ -3788,6 +3882,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { snapshots: override_cfg.snapshots.or(base.snapshots), search: override_cfg.search.or(base.search), memory: override_cfg.memory.or(base.memory), + speech: override_cfg.speech.or(base.speech), auto: override_cfg.auto.or(base.auto), update: override_cfg.update.or(base.update), lsp: override_cfg.lsp.or(base.lsp), @@ -6510,6 +6605,37 @@ api_key = "old-openrouter-key" } } + #[test] + fn normalize_xiaomi_mimo_tts_aliases_for_provider() { + assert_eq!( + normalize_model_name_for_provider(ApiProvider::XiaomiMimo, "tts").as_deref(), + Some("mimo-v2.5-tts") + ); + assert_eq!( + normalize_model_name_for_provider(ApiProvider::XiaomiMimo, "voice-design").as_deref(), + Some("mimo-v2.5-tts-voicedesign") + ); + assert_eq!( + wire_model_for_provider(ApiProvider::XiaomiMimo, "voiceclone"), + "mimo-v2.5-tts-voiceclone" + ); + } + + #[test] + fn model_completion_names_for_xiaomi_mimo_include_tts_models() { + let models = model_completion_names_for_provider(ApiProvider::XiaomiMimo); + for expected in [ + "mimo-v2.5-pro", + "mimo-v2.5", + "mimo-v2.5-tts", + "mimo-v2.5-tts-voicedesign", + "mimo-v2.5-tts-voiceclone", + "mimo-v2-tts", + ] { + assert!(models.contains(&expected), "missing {expected}"); + } + } + #[test] fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() { assert_eq!( diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 62523d339..34c0c6ce1 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -161,6 +161,8 @@ pub struct EngineConfig { /// Path to the user memory file (#489). Always populated; only /// consulted when `memory_enabled` is `true`. pub memory_path: PathBuf, + /// Default directory for Xiaomi MiMo speech/TTS tool outputs. + pub speech_output_dir: Option, pub vision_config: Option, pub goal_objective: Option, /// Tool restriction from custom slash command frontmatter. @@ -236,6 +238,7 @@ impl Default for EngineConfig { subagent_model_overrides: HashMap::new(), memory_enabled: false, memory_path: PathBuf::from("./memory.md"), + speech_output_dir: None, vision_config: None, strict_tool_mode: false, goal_objective: None, diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index b31e9ce0a..63bb75f54 100644 --- a/crates/tui/src/core/engine/tool_setup.rs +++ b/crates/tui/src/core/engine/tool_setup.rs @@ -78,7 +78,11 @@ impl Engine { if mode != AppMode::Plan { builder = builder .with_rlm_tool(self.deepseek_client.clone(), self.session.model.clone()) - .with_fim_tool(self.deepseek_client.clone(), self.session.model.clone()); + .with_fim_tool(self.deepseek_client.clone(), self.session.model.clone()) + .with_speech_tools( + self.deepseek_client.clone(), + self.config.speech_output_dir.clone(), + ); } if self.config.features.enabled(Feature::ApplyPatch) && mode != AppMode::Plan { diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index f6934d037..3428c6d14 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -6,6 +6,7 @@ use std::process::{Command, Stdio}; use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; +use base64::{Engine as _, engine::general_purpose}; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; use dotenvy::dotenv; @@ -225,6 +226,9 @@ enum Commands { Logout, /// List available models from the configured API endpoint Models(ModelsArgs), + /// Generate speech audio with Xiaomi MiMo TTS models + #[command(visible_alias = "tts")] + Speech(SpeechArgs), /// Run a non-interactive prompt. Use --auto for tool-backed agent mode. Exec(ExecArgs), /// Generate SWE-bench prediction rows from CodeWhale runs @@ -531,6 +535,50 @@ struct ModelsArgs { json: bool, } +#[derive(Args, Debug, Clone)] +struct SpeechArgs { + /// Text to synthesize. This is sent as the assistant message content. + #[arg(value_name = "TEXT")] + text: String, + + /// Output audio path. Defaults to speech. in --output-dir, + /// [speech].output_dir, or the current directory. + #[arg(short, long, value_name = "FILE")] + output: Option, + + /// Directory for the default speech. output file when -o/--output is omitted. + #[arg(long = "output-dir", value_name = "DIR")] + output_dir: Option, + + /// TTS model. Defaults to built-in voices, or is inferred from --voice-prompt/--clone-voice. + #[arg(long)] + model: Option, + + /// Built-in voice ID, or a data:audio/...;base64,... URI for voice clone. + #[arg(long)] + voice: Option, + + /// Natural language style instruction; not spoken verbatim. + #[arg(long)] + instruction: Option, + + /// Voice design prompt. Implies mimo-v2.5-tts-voicedesign when --model is omitted. + #[arg(long = "voice-prompt")] + voice_prompt: Option, + + /// MP3/WAV sample used for voice cloning. Implies mimo-v2.5-tts-voiceclone when --model is omitted. + #[arg(long = "clone-voice", value_name = "FILE")] + clone_voice: Option, + + /// Output audio format requested from the API + #[arg(long, default_value = "wav")] + format: String, + + /// Emit machine-readable JSON output + #[arg(long, default_value_t = false)] + json: bool, +} + #[derive(Args, Debug, Default, Clone)] struct FeatureToggles { /// Enable a feature (repeatable). Equivalent to `features.=true`. @@ -896,6 +944,10 @@ async fn main() -> Result<()> { let config = load_config_from_cli(&cli)?; run_models(&config, args).await } + Commands::Speech(args) => { + let config = load_config_from_cli(&cli)?; + run_speech(&config, args).await + } Commands::Exec(args) => { let config = load_config_from_cli(&cli)?; let workspace = cli.workspace.clone().unwrap_or_else(|| { @@ -3514,6 +3566,258 @@ async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> { Ok(()) } +async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> { + use crate::client::{DeepSeekClient, SpeechSynthesisRequest}; + use crate::config::{ApiProvider, normalize_model_name_for_provider}; + + let SpeechArgs { + text, + output, + output_dir, + model, + voice, + instruction, + voice_prompt, + clone_voice, + format, + json: json_output, + } = args; + + if config.api_provider() != ApiProvider::XiaomiMimo { + bail!( + "`speech` requires provider = \"xiaomi-mimo\" (current: {}). Run with `--provider xiaomi-mimo` or set it in config.", + config.api_provider().as_str() + ); + } + + if text.trim().is_empty() { + bail!("Speech text cannot be empty"); + } + let voice_is_data_uri = voice + .as_deref() + .map(str::trim) + .is_some_and(|value| value.starts_with("data:audio/")); + if clone_voice.is_some() && voice.is_some() { + bail!("Use either --clone-voice or --voice for cloned voice data, not both"); + } + let model = match model { + Some(value) => { + normalize_model_name_for_provider(ApiProvider::XiaomiMimo, &value).unwrap_or(value) + } + None => { + if clone_voice.is_some() || voice_is_data_uri { + "mimo-v2.5-tts-voiceclone".to_string() + } else if voice_prompt.is_some() { + "mimo-v2.5-tts-voicedesign".to_string() + } else { + "mimo-v2.5-tts".to_string() + } + } + }; + let model_lower = model.to_ascii_lowercase(); + if !model_lower.contains("tts") { + bail!( + "speech requires a TTS model (examples: mimo-v2.5-tts, mimo-v2.5-tts-voicedesign, mimo-v2.5-tts-voiceclone); got {model}" + ); + } + let is_voice_design = model_lower.contains("voicedesign"); + let is_voice_clone = model_lower.contains("voiceclone"); + + let instruction = combine_speech_instructions(instruction, voice_prompt); + if is_voice_design + && instruction + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + bail!( + "mimo-v2.5-tts-voicedesign requires --voice-prompt or --instruction to describe the voice" + ); + } + + let voice = if let Some(clone_path) = clone_voice { + Some(encode_voice_clone_data_uri(&clone_path)?) + } else if is_voice_design { + None + } else if let Some(value) = voice.filter(|value| !value.trim().is_empty()) { + Some(value) + } else if is_voice_clone { + bail!("mimo-v2.5-tts-voiceclone requires --clone-voice or --voice "); + } else { + Some("mimo_default".to_string()) + }; + let format = normalize_speech_format(&format).with_context(|| { + format!("Unsupported speech format '{format}' (allowed: wav, mp3, pcm16)") + })?; + let output = resolve_speech_output_path( + output, + output_dir.or_else(|| config.speech_output_dir()), + &format, + ); + + let client = DeepSeekClient::new(config)?; + let response = client + .synthesize_speech(SpeechSynthesisRequest { + model: model.clone(), + text, + instruction, + audio_format: format.clone(), + voice, + }) + .await?; + + if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create output directory {}", parent.display()))?; + } + std::fs::write(&output, &response.audio_bytes) + .with_context(|| format!("Failed to write audio file {}", output.display()))?; + + if json_output { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "mode": "speech", + "success": true, + "model": response.model, + "format": response.audio_format, + "output": output.display().to_string(), + "bytes": response.audio_bytes.len(), + "voice": response.voice.as_deref().map(describe_speech_voice), + "transcript": response.transcript, + }))? + ); + } else { + println!( + "Generated speech: {} ({} bytes, model: {}, format: {})", + output.display(), + response.audio_bytes.len(), + response.model, + response.audio_format + ); + } + + Ok(()) +} + +fn combine_speech_instructions( + instruction: Option, + voice_prompt: Option, +) -> Option { + match (instruction, voice_prompt) { + (Some(instruction), Some(voice_prompt)) => { + let instruction = instruction.trim(); + let voice_prompt = voice_prompt.trim(); + if instruction.is_empty() { + Some(voice_prompt.to_string()).filter(|value| !value.is_empty()) + } else if voice_prompt.is_empty() { + Some(instruction.to_string()).filter(|value| !value.is_empty()) + } else { + Some(format!("{voice_prompt}\n\n{instruction}")) + } + } + (Some(value), None) | (None, Some(value)) => { + let value = value.trim().to_string(); + if value.is_empty() { None } else { Some(value) } + } + (None, None) => None, + } +} + +const VOICE_CLONE_BASE64_MAX_BYTES: usize = 10 * 1024 * 1024; + +fn normalize_speech_format(format: &str) -> Option { + let normalized = format.trim().to_ascii_lowercase(); + match normalized.as_str() { + "wav" | "mp3" | "pcm16" => Some(normalized), + "pcm" => Some("pcm16".to_string()), + _ => None, + } +} + +fn default_speech_output_name(format: &str) -> String { + format!( + "speech.{}", + normalize_speech_format(format).as_deref().unwrap_or("wav") + ) +} + +fn resolve_speech_output_path( + output: Option, + output_dir: Option, + format: &str, +) -> PathBuf { + output.unwrap_or_else(|| { + output_dir + .unwrap_or_default() + .join(default_speech_output_name(format)) + }) +} + +fn encode_voice_clone_data_uri(path: &Path) -> Result { + let bytes = std::fs::read(path) + .with_context(|| format!("Failed to read voice clone sample {}", path.display()))?; + let base64_audio = general_purpose::STANDARD.encode(bytes); + if base64_audio.len() > VOICE_CLONE_BASE64_MAX_BYTES { + bail!( + "Voice clone sample is too large after base64 encoding ({} bytes > 10 MB)", + base64_audio.len() + ); + } + + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let mime = match extension.as_str() { + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + other => bail!( + "Unsupported voice clone sample extension '{}'. Use .mp3 or .wav.", + other + ), + }; + + Ok(format!("data:{mime};base64,{base64_audio}")) +} + +fn describe_speech_voice(voice: &str) -> String { + if voice.starts_with("data:") { + "embedded voice clone sample".to_string() + } else { + voice.to_string() + } +} + +#[cfg(test)] +mod speech_cli_tests { + use super::*; + + #[test] + fn normalizes_documented_speech_formats() { + assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav")); + assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16")); + assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16")); + assert_eq!(normalize_speech_format("flac"), None); + } + + #[test] + fn default_speech_output_tracks_requested_format() { + assert_eq!( + resolve_speech_output_path(None, None, "mp3"), + PathBuf::from("speech.mp3") + ); + assert_eq!( + resolve_speech_output_path(None, Some(PathBuf::from("audio")), "pcm"), + PathBuf::from("audio").join("speech.pcm16") + ); + assert_eq!( + resolve_speech_output_path(Some(PathBuf::from("custom.wav")), None, "mp3"), + PathBuf::from("custom.wav") + ); + } +} + /// Test API connectivity by making a minimal request async fn test_api_connectivity(config: &Config) -> Result<()> { use crate::client::DeepSeekClient; @@ -5462,6 +5766,7 @@ async fn run_exec_agent( prefer_bwrap: config.prefer_bwrap.unwrap_or(false), memory_enabled: config.memory_enabled(), memory_path: config.memory_path(), + speech_output_dir: config.speech_output_dir(), vision_config: config.vision_model_config(), strict_tool_mode: config.strict_tool_mode.unwrap_or(false), goal_objective: None, diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 39c009ed7..805bb2e80 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -2017,6 +2017,7 @@ impl RuntimeThreadManager { prefer_bwrap: self.config.prefer_bwrap.unwrap_or(false), memory_enabled: self.config.memory_enabled(), memory_path: self.config.memory_path(), + speech_output_dir: self.config.speech_output_dir(), vision_config: self.config.vision_model_config(), strict_tool_mode: self.config.strict_tool_mode.unwrap_or(false), goal_objective: None, diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index ebe147522..20a94e240 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -48,6 +48,7 @@ pub mod shell; mod shell_output; pub mod skill; pub mod spec; +pub mod speech; pub mod subagent; pub mod tasks; pub mod test_runner; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 168d5bbc9..b8efe51e9 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use std::sync::{Arc, OnceLock}; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde_json::Value; @@ -776,6 +776,22 @@ impl ToolRegistryBuilder { self.with_tool(Arc::new(RevertTurnTool)) } + /// Include Xiaomi MiMo speech/TTS tools (`speech`, `tts`). + #[must_use] + pub fn with_speech_tools( + self, + client: Option, + output_dir: Option, + ) -> Self { + use super::speech::SpeechTool; + self.with_tool(Arc::new(SpeechTool::new( + "speech", + client.clone(), + output_dir.clone(), + ))) + .with_tool(Arc::new(SpeechTool::new("tts", client, output_dir))) + } + /// Include persistent RLM session tools. #[must_use] pub fn with_rlm_tool(self, client: Option, _root_model: String) -> Self { @@ -958,11 +974,13 @@ impl ToolRegistryBuilder { todo_list: super::todo::SharedTodoList, plan_state: super::plan::SharedPlanState, ) -> Self { + let speech_client = client.clone(); self.with_agent_tools(allow_shell) .with_todo_tool(todo_list) .with_plan_tool(plan_state) .with_review_tool(client.clone(), model.clone()) .with_rlm_tool(client, model) + .with_speech_tools(speech_client, None) .with_recall_archive_tool() .with_subagent_tools(manager, runtime) } @@ -1218,6 +1236,18 @@ mod tests { assert!(registry.contains("list_dir")); } + #[test] + fn builder_registers_speech_alias_tools() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let registry = ToolRegistryBuilder::new() + .with_speech_tools(None, None) + .build(ctx); + + assert!(registry.contains("speech")); + assert!(registry.contains("tts")); + } + #[test] fn test_registry_names() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tools/speech.rs b/crates/tui/src/tools/speech.rs new file mode 100644 index 000000000..92550e692 --- /dev/null +++ b/crates/tui/src/tools/speech.rs @@ -0,0 +1,528 @@ +//! Model-visible Xiaomi MiMo speech/TTS generation tool. +//! +//! This mirrors the CLI `speech` / `tts` command as a first-class API tool so +//! the TUI model can generate narrated audio without shelling out to a nested +//! CodeWhale process. + +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose}; +use serde_json::{Value, json}; + +use crate::client::{DeepSeekClient, SpeechSynthesisRequest}; +use crate::config::{ApiProvider, normalize_model_name_for_provider}; +use crate::network_policy::{Decision, host_from_url}; + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, + optional_bool, optional_str, required_str, +}; + +const DEFAULT_FORMAT: &str = "wav"; +const DEFAULT_VOICE: &str = "mimo_default"; +const VOICE_CLONE_BASE64_MAX_BYTES: usize = 10 * 1024 * 1024; +const SUPPORTED_SPEECH_FORMATS: &[&str] = &["wav", "mp3", "pcm16"]; + +pub const SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS: &[&str] = &[ + "mimo-v2.5-pro", + "mimo-v2.5", + "mimo-v2.5-tts-voiceclone", + "mimo-v2.5-tts-voicedesign", + "mimo-v2.5-tts", + "mimo-v2-pro", + "mimo-v2-omni", + "mimo-v2-tts", +]; + +const SPEECH_MODEL_EXAMPLES: &[&str] = &[ + "mimo-v2.5-tts", + "mimo-v2.5-tts-voicedesign", + "mimo-v2.5-tts-voiceclone", + "mimo-v2-tts", +]; + +pub struct SpeechTool { + name: &'static str, + client: Option, + output_dir: Option, +} + +impl SpeechTool { + #[must_use] + pub fn new( + name: &'static str, + client: Option, + output_dir: Option, + ) -> Self { + Self { + name, + client, + output_dir, + } + } +} + +#[async_trait] +impl ToolSpec for SpeechTool { + fn name(&self) -> &str { + self.name + } + + fn description(&self) -> &str { + "Generate speech/audio directly through the configured Xiaomi MiMo OpenAI-compatible API. Use this when the user asks for speech, TTS, narration, read-aloud, voice design, or voice cloning." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text to synthesize. This is sent as the assistant message and is the spoken content; MiMo TTS style/audio tags may be included here." + }, + "output": { + "type": "string", + "description": "Audio file path to write, relative to the workspace unless absolute. Default: speech. in output_dir, configured [speech].output_dir, or the workspace." + }, + "output_dir": { + "type": "string", + "description": "Directory for the default speech. output file when output is omitted. Relative paths stay inside the workspace." + }, + "model": { + "type": "string", + "description": "TTS model. Defaults to mimo-v2.5-tts, or infers voice-design/voice-clone models from voice_prompt/clone_voice.", + "enum": SPEECH_MODEL_EXAMPLES + }, + "voice": { + "type": "string", + "description": "Built-in voice ID (for example mimo_default, 冰糖, 茉莉, 苏打, 白桦, Mia, Chloe, Milo, Dean) or a data:audio/...;base64,... URI for voice clone." + }, + "instruction": { + "type": "string", + "description": "Natural-language style, emotion, speed, scene, or performance instruction. It is not spoken verbatim." + }, + "voice_prompt": { + "type": "string", + "description": "Voice design prompt. When model is omitted this uses mimo-v2.5-tts-voicedesign." + }, + "clone_voice": { + "type": "string", + "description": "Path to a .mp3 or .wav voice sample for cloning. When model is omitted this uses mimo-v2.5-tts-voiceclone." + }, + "format": { + "type": "string", + "description": "Requested audio format. Default: wav. MiMo-V2.5-TTS documentation examples use wav and pcm16; mp3 is accepted when the API returns it.", + "enum": SUPPORTED_SPEECH_FORMATS + }, + "stream": { + "type": "boolean", + "description": "Low-latency streaming request. The direct tool currently writes complete audio files only, so leave this false." + } + }, + "required": ["text"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ + ToolCapability::WritesFiles, + ToolCapability::Network, + ToolCapability::Sandboxable, + ] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + // Speech generation is an explicit user-facing generation action. + // Path resolution still enforces workspace/trusted-root boundaries. + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let text = required_str(&input, "text")?.trim().to_string(); + if text.is_empty() { + return Err(ToolError::invalid_input("speech text cannot be empty")); + } + + let client = self.client.clone().ok_or_else(|| { + ToolError::not_available( + "speech tool requires an active Xiaomi MiMo API client; configure provider = \"xiaomi-mimo\" and an API key first", + ) + })?; + + let requested_format_raw = optional_str(&input, "format") + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_FORMAT); + let requested_format = normalize_speech_format(requested_format_raw).ok_or_else(|| { + ToolError::invalid_input(format!( + "unsupported speech format '{requested_format_raw}' (allowed: {})", + SUPPORTED_SPEECH_FORMATS.join(", ") + )) + })?; + if optional_bool(&input, "stream", false) { + return Err(ToolError::invalid_input( + "stream=true low-latency speech output is not implemented in the direct tool yet; use stream=false to generate a complete audio file", + )); + } + let output_raw = optional_str(&input, "output") + .map(str::trim) + .filter(|value| !value.is_empty()); + let output_path = resolve_speech_output_path( + &input, + context, + output_raw, + &requested_format, + self.output_dir.as_ref(), + )?; + let output_label = output_raw + .map(str::to_string) + .unwrap_or_else(|| output_path.display().to_string()); + + let raw_voice = optional_str(&input, "voice") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let raw_instruction = optional_str(&input, "instruction") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let voice_prompt = optional_str(&input, "voice_prompt") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let clone_voice = optional_str(&input, "clone_voice") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + let voice_is_data_uri = raw_voice + .as_deref() + .is_some_and(|value| value.starts_with("data:audio/")); + if clone_voice.is_some() && raw_voice.is_some() { + return Err(ToolError::invalid_input( + "use either clone_voice or voice for cloned voice data, not both", + )); + } + let model = infer_speech_model( + optional_str(&input, "model"), + clone_voice.is_some() || voice_is_data_uri, + voice_prompt.is_some(), + ); + let model_lower = model.to_ascii_lowercase(); + if !model_lower.contains("tts") { + return Err(ToolError::invalid_input(format!( + "speech tool requires a TTS model (examples: {}), got '{model}'", + SPEECH_MODEL_EXAMPLES.join(", ") + ))); + } + + let is_voice_design = model_lower.contains("voicedesign"); + let is_voice_clone = model_lower.contains("voiceclone"); + let instruction = combine_speech_instructions(raw_instruction, voice_prompt); + if is_voice_design + && instruction + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err(ToolError::invalid_input( + "mimo-v2.5-tts-voicedesign requires voice_prompt or instruction", + )); + } + + let voice = if let Some(clone_path) = clone_voice { + let clone_path = context.resolve_path(&clone_path)?; + Some(encode_voice_clone_data_uri(&clone_path).await?) + } else if is_voice_design { + None + } else if let Some(value) = raw_voice { + Some(value) + } else if is_voice_clone { + return Err(ToolError::invalid_input( + "mimo-v2.5-tts-voiceclone requires clone_voice or voice ", + )); + } else { + Some(DEFAULT_VOICE.to_string()) + }; + + check_network_policy(context, client.base_url())?; + + let response = client + .synthesize_speech(SpeechSynthesisRequest { + model: model.clone(), + text, + instruction, + audio_format: requested_format, + voice, + }) + .await + .map_err(|err| { + ToolError::execution_failed(format!("speech synthesis failed: {err}")) + })?; + + if let Some(parent) = output_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + { + tokio::fs::create_dir_all(parent).await.map_err(|err| { + ToolError::execution_failed(format!( + "failed to create output directory {}: {err}", + parent.display() + )) + })?; + } + tokio::fs::write(&output_path, &response.audio_bytes) + .await + .map_err(|err| { + ToolError::execution_failed(format!( + "failed to write audio file {}: {err}", + output_path.display() + )) + })?; + + let result = json!({ + "mode": "speech", + "success": true, + "api": "Xiaomi MiMo OpenAI-compatible chat/completions speech synthesis", + "base_url": openai_compatible_base_url(client.base_url()), + "model": response.model, + "format": response.audio_format, + "stream": false, + "output": output_label, + "absolute_output": output_path.display().to_string(), + "bytes": response.audio_bytes.len(), + "voice": response.voice.as_deref().map(describe_speech_voice), + "transcript": response.transcript, + "supported_formats": SUPPORTED_SPEECH_FORMATS, + "supported_xiaomi_mimo_models": SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS, + }); + ToolResult::json(&result).map_err(|err| { + ToolError::execution_failed(format!("failed to serialize result: {err}")) + }) + } +} + +fn infer_speech_model( + model: Option<&str>, + has_clone_voice: bool, + has_voice_prompt: bool, +) -> String { + match model.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => normalize_model_name_for_provider(ApiProvider::XiaomiMimo, value) + .unwrap_or_else(|| value.into()), + None if has_clone_voice => "mimo-v2.5-tts-voiceclone".to_string(), + None if has_voice_prompt => "mimo-v2.5-tts-voicedesign".to_string(), + None => "mimo-v2.5-tts".to_string(), + } +} + +fn combine_speech_instructions( + instruction: Option, + voice_prompt: Option, +) -> Option { + match (instruction, voice_prompt) { + (Some(instruction), Some(voice_prompt)) => { + let instruction = instruction.trim(); + let voice_prompt = voice_prompt.trim(); + if instruction.is_empty() { + Some(voice_prompt.to_string()).filter(|value| !value.is_empty()) + } else if voice_prompt.is_empty() { + Some(instruction.to_string()).filter(|value| !value.is_empty()) + } else { + Some(format!("{voice_prompt}\n\n{instruction}")) + } + } + (Some(value), None) | (None, Some(value)) => { + let value = value.trim().to_string(); + if value.is_empty() { None } else { Some(value) } + } + (None, None) => None, + } +} + +fn normalize_speech_format(format: &str) -> Option { + let normalized = format.trim().to_ascii_lowercase(); + match normalized.as_str() { + "wav" | "mp3" | "pcm16" => Some(normalized), + "pcm" => Some("pcm16".to_string()), + _ => None, + } +} + +fn default_speech_output_name(format: &str) -> String { + format!( + "speech.{}", + normalize_speech_format(format) + .as_deref() + .unwrap_or(DEFAULT_FORMAT) + ) +} + +fn resolve_speech_output_path( + input: &Value, + context: &ToolContext, + output_raw: Option<&str>, + format: &str, + configured_output_dir: Option<&PathBuf>, +) -> Result { + if let Some(output) = output_raw { + return context.resolve_path(output); + } + + let filename = default_speech_output_name(format); + if let Some(output_dir) = optional_str(input, "output_dir") + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(context.resolve_path(output_dir)?.join(filename)); + } + + if let Some(output_dir) = configured_output_dir { + return Ok(output_dir.join(filename)); + } + + Ok(context.workspace.join(filename)) +} + +async fn encode_voice_clone_data_uri(path: &Path) -> Result { + let bytes = tokio::fs::read(path).await.map_err(|err| { + ToolError::execution_failed(format!( + "failed to read voice clone sample {}: {err}", + path.display() + )) + })?; + let base64_audio = general_purpose::STANDARD.encode(bytes); + if base64_audio.len() > VOICE_CLONE_BASE64_MAX_BYTES { + return Err(ToolError::invalid_input(format!( + "voice clone sample is too large after base64 encoding ({} bytes > 10 MB)", + base64_audio.len() + ))); + } + + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let mime = match extension.as_str() { + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + other => { + return Err(ToolError::invalid_input(format!( + "unsupported voice clone sample extension '{other}'. Use .mp3 or .wav." + ))); + } + }; + + Ok(format!("data:{mime};base64,{base64_audio}")) +} + +fn describe_speech_voice(voice: &str) -> String { + if voice.starts_with("data:") { + "embedded voice clone sample".to_string() + } else { + voice.to_string() + } +} + +fn openai_compatible_base_url(base_url: &str) -> String { + let trimmed = base_url.trim_end_matches('/'); + if trimmed.ends_with("/v1") || trimmed.ends_with("/beta") { + trimmed.to_string() + } else { + format!("{trimmed}/v1") + } +} + +fn check_network_policy(context: &ToolContext, base_url: &str) -> Result<(), ToolError> { + let Some(decider) = context.network_policy.as_ref() else { + return Ok(()); + }; + let display_url = openai_compatible_base_url(base_url); + let Some(host) = host_from_url(&display_url) else { + return Ok(()); + }; + match decider.evaluate(&host, "speech") { + Decision::Allow => Ok(()), + Decision::Deny => Err(ToolError::permission_denied(format!( + "speech network call to '{host}' blocked by network policy" + ))), + Decision::Prompt => Err(ToolError::permission_denied(format!( + "speech network call to '{host}' requires approval; re-run after `/network allow {host}` or set network.default = \"allow\" in config" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn infers_speech_model_from_requested_mode() { + assert_eq!(infer_speech_model(None, false, false), "mimo-v2.5-tts"); + assert_eq!( + infer_speech_model(None, false, true), + "mimo-v2.5-tts-voicedesign" + ); + assert_eq!( + infer_speech_model(None, true, false), + "mimo-v2.5-tts-voiceclone" + ); + assert_eq!( + infer_speech_model(Some("mimo-tts"), false, false), + "mimo-v2.5-tts" + ); + assert_eq!( + infer_speech_model(Some("mimo-v2-tts"), false, false), + "mimo-v2-tts" + ); + } + + #[test] + fn combines_voice_prompt_before_instruction() { + assert_eq!( + combine_speech_instructions( + Some("Speak warmly.".to_string()), + Some("Young Chinese female voice".to_string()) + ) + .as_deref(), + Some("Young Chinese female voice\n\nSpeak warmly.") + ); + assert_eq!( + combine_speech_instructions(Some(" calm ".to_string()), None).as_deref(), + Some("calm") + ); + } + + #[test] + fn normalizes_documented_speech_formats() { + assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav")); + assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16")); + assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16")); + assert_eq!(normalize_speech_format("flac"), None); + } + + #[test] + fn displays_openai_compatible_base_url() { + assert_eq!( + openai_compatible_base_url("https://api.xiaomimimo.com"), + "https://api.xiaomimimo.com/v1" + ); + assert_eq!( + openai_compatible_base_url("https://api.xiaomimimo.com/v1"), + "https://api.xiaomimimo.com/v1" + ); + } + + #[test] + fn speech_tool_is_auto_approved_but_not_read_only() { + let tool = SpeechTool::new("speech", None, None); + assert_eq!(tool.name(), "speech"); + assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto); + assert!(!tool.is_read_only()); + let schema = tool.input_schema(); + assert!(schema.to_string().contains("mimo-v2.5-tts-voiceclone")); + assert!(schema.to_string().contains("pcm16")); + assert!(schema.to_string().contains("stream")); + } +} diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 7d1af66b7..563bcf413 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -332,6 +332,9 @@ fn picker_model_hint(id: &str) -> &'static str { } "arcee-ai/trinity-large-thinking" => "large thinking", "xiaomi/mimo-v2.5-pro" | "mimo-v2.5-pro" => "long context", + "mimo-v2.5-tts" | "mimo-v2-tts" => "speech / TTS", + "mimo-v2.5-tts-voicedesign" => "voice design", + "mimo-v2.5-tts-voiceclone" => "voice clone", "minimax/minimax-m3" => "1M multimodal", _ => "provider model", } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 76e7cc87f..9aa56b05a 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -781,6 +781,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { prefer_bwrap: config.prefer_bwrap.unwrap_or(false), memory_enabled: config.memory_enabled(), memory_path: config.memory_path(), + speech_output_dir: config.speech_output_dir(), vision_config: config.vision_model_config(), strict_tool_mode: config.strict_tool_mode.unwrap_or(false), goal_objective: app.hunt.quarry.clone(), diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index f2a126deb..63b81d6e4 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -118,7 +118,7 @@ endpoint. | `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. | | `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. | | `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-35b-a3b`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. | -| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`; default `https://api.xiaomimimo.com/v1` | `mimo-v2.5-pro`, `mimo-v2.5` | Xiaomi MiMo OpenAI-compatible chat completions route. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. | +| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`; default `https://api.xiaomimimo.com/v1` | `mimo-v2.5-pro`, `mimo-v2.5`, `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. `codewhale speech` / `tts` uses the TTS models. | | `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint; users who need the regional endpoint can set `https://api.siliconflow.cn/v1` explicitly. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. | @@ -130,7 +130,11 @@ endpoint. ### Xiaomi MiMo Notes `xiaomi-mimo` defaults to `mimo-v2.5-pro` for long-context reasoning and coding -work, while the static registry also exposes `mimo-v2.5`. Xiaomi's current +work, while the static registry also exposes `mimo-v2.5`. Xiaomi MiMo TTS is +available through `codewhale --provider xiaomi-mimo speech "text" --model tts` +(or the `tts` alias) plus model-visible `speech` / `tts` tools in Agent/YOLO mode. +Voice-design and voice-clone shorthands map to `mimo-v2.5-tts-voicedesign` and +`mimo-v2.5-tts-voiceclone`. Xiaomi's current [image-understanding guide](https://platform.xiaomimimo.com/docs/en-US/usage-guide/multimodal-understanding/image-understanding) includes `mimo-v2.5` for image input. CodeWhale exposes image analysis through the separate `[vision_model]` / `image_analyze` path; set that model to @@ -164,7 +168,7 @@ endpoint when the endpoint supports model listing. | `wanjie-ark` | `deepseek-reasoner` | yes | yes | | `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes | | `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-27b`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free` | yes | yes | -| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5` | yes | yes | +| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5`, `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | yes | yes for chat models; no for TTS models | | `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes | | `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes | | `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes | From 5f497e00c45da89460707f265e25759499384a0f Mon Sep 17 00:00:00 2001 From: xyuai Date: Tue, 2 Jun 2026 10:53:48 +0800 Subject: [PATCH 79/98] fix: harden Xiaomi MiMo speech flow --- crates/tui/src/client.rs | 87 ++++++++++-- crates/tui/src/core/engine.rs | 2 + crates/tui/src/main.rs | 178 +++++++++---------------- crates/tui/src/tools/registry.rs | 3 +- crates/tui/src/tools/speech.rs | 75 ++++++++--- crates/tui/src/tools/subagent/mod.rs | 13 ++ crates/tui/src/tools/subagent/tests.rs | 11 ++ 7 files changed, 219 insertions(+), 150 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 386b8a12e..7215f6d88 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -476,6 +476,31 @@ fn parse_speech_audio_response(payload: &Value) -> Result<(Vec, Option, + audio: Value, +) -> Value { + let mut messages = Vec::new(); + if let Some(instruction) = instruction.map(str::trim).filter(|value| !value.is_empty()) { + messages.push(json!({ + "role": "user", + "content": instruction, + })); + } + messages.push(json!({ + "role": "assistant", + "content": text, + })); + + json!({ + "model": model, + "messages": messages, + "audio": audio, + }) +} + // === DeepSeekClient === /// Returns true when DEEPSEEK_FORCE_HTTP1 is set to a truthy value @@ -773,20 +798,7 @@ impl DeepSeekClient { audio["voice"] = json!(voice); } - let body = json!({ - "model": model, - "messages": [ - { - "role": "user", - "content": instruction.unwrap_or(""), - }, - { - "role": "assistant", - "content": text, - } - ], - "audio": audio, - }); + let body = build_speech_synthesis_body(&model, &text, instruction, audio); let url = api_url(&self.base_url, "chat/completions"); let response = self @@ -1366,6 +1378,53 @@ mod tests { assert_eq!(transcript, None); } + #[test] + fn speech_synthesis_body_omits_user_message_without_instruction() { + let body = + build_speech_synthesis_body("mimo-v2.5-tts", "hello", None, json!({"format": "wav"})); + let messages = body["messages"].as_array().expect("messages array"); + + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "assistant"); + assert_eq!(messages[0]["content"], "hello"); + assert!( + messages + .iter() + .all(|message| message["content"].as_str() != Some("")) + ); + } + + #[test] + fn speech_synthesis_body_ignores_blank_instruction() { + let body = build_speech_synthesis_body( + "mimo-v2.5-tts", + "hello", + Some(" \t\n "), + json!({"format": "wav"}), + ); + let messages = body["messages"].as_array().expect("messages array"); + + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "assistant"); + } + + #[test] + fn speech_synthesis_body_includes_non_empty_instruction_first() { + let body = build_speech_synthesis_body( + "mimo-v2.5-tts-voicedesign", + "hello", + Some("warm and calm"), + json!({"format": "wav"}), + ); + let messages = body["messages"].as_array().expect("messages array"); + + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(messages[0]["content"], "warm and calm"); + assert_eq!(messages[1]["role"], "assistant"); + assert_eq!(messages[1]["content"], "hello"); + } + #[test] fn tool_name_roundtrip_dot() { let original = "multi_tool_use.parallel"; diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 34c0c6ce1..54ba0c243 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -992,6 +992,7 @@ impl Engine { ) .with_max_spawn_depth(self.config.max_spawn_depth) .with_step_api_timeout(self.config.subagent_api_timeout) + .with_speech_output_dir(self.config.speech_output_dir.clone()) .with_mcp_pool(mcp_pool) .background_runtime(); let route = resolve_subagent_assignment_route( @@ -1496,6 +1497,7 @@ impl Engine { ) .with_max_spawn_depth(self.config.max_spawn_depth) .with_step_api_timeout(self.config.subagent_api_timeout) + .with_speech_output_dir(self.config.speech_output_dir.clone()) .with_mcp_pool(mcp_pool.clone()) .with_parent_completion_tx(self.tx_subagent_completion.clone()); if let Some(context) = fork_context_for_runtime.clone() { diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 3428c6d14..178b06f1b 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -6,7 +6,6 @@ use std::process::{Command, Stdio}; use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; -use base64::{Engine as _, engine::general_purpose}; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; use dotenvy::dotenv; @@ -3568,7 +3567,12 @@ async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> { async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> { use crate::client::{DeepSeekClient, SpeechSynthesisRequest}; - use crate::config::{ApiProvider, normalize_model_name_for_provider}; + use crate::config::ApiProvider; + use crate::tools::speech::{ + DEFAULT_VOICE, SPEECH_MODEL_EXAMPLES, combine_speech_instructions, + default_speech_output_name, describe_speech_voice, encode_voice_clone_sample_data_uri, + infer_speech_model, normalize_speech_format, + }; let SpeechArgs { text, @@ -3600,24 +3604,16 @@ async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> { if clone_voice.is_some() && voice.is_some() { bail!("Use either --clone-voice or --voice for cloned voice data, not both"); } - let model = match model { - Some(value) => { - normalize_model_name_for_provider(ApiProvider::XiaomiMimo, &value).unwrap_or(value) - } - None => { - if clone_voice.is_some() || voice_is_data_uri { - "mimo-v2.5-tts-voiceclone".to_string() - } else if voice_prompt.is_some() { - "mimo-v2.5-tts-voicedesign".to_string() - } else { - "mimo-v2.5-tts".to_string() - } - } - }; + let model = infer_speech_model( + model.as_deref(), + clone_voice.is_some() || voice_is_data_uri, + voice_prompt.is_some(), + ); let model_lower = model.to_ascii_lowercase(); if !model_lower.contains("tts") { bail!( - "speech requires a TTS model (examples: mimo-v2.5-tts, mimo-v2.5-tts-voicedesign, mimo-v2.5-tts-voiceclone); got {model}" + "speech requires a TTS model (examples: {}); got {model}", + SPEECH_MODEL_EXAMPLES.join(", ") ); } let is_voice_design = model_lower.contains("voicedesign"); @@ -3635,7 +3631,7 @@ async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> { } let voice = if let Some(clone_path) = clone_voice { - Some(encode_voice_clone_data_uri(&clone_path)?) + Some(encode_voice_clone_sample_data_uri(&clone_path)?) } else if is_voice_design { None } else if let Some(value) = voice.filter(|value| !value.trim().is_empty()) { @@ -3643,16 +3639,17 @@ async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> { } else if is_voice_clone { bail!("mimo-v2.5-tts-voiceclone requires --clone-voice or --voice "); } else { - Some("mimo_default".to_string()) + Some(DEFAULT_VOICE.to_string()) }; let format = normalize_speech_format(&format).with_context(|| { format!("Unsupported speech format '{format}' (allowed: wav, mp3, pcm16)") })?; - let output = resolve_speech_output_path( - output, - output_dir.or_else(|| config.speech_output_dir()), - &format, - ); + let output = output.unwrap_or_else(|| { + output_dir + .or_else(|| config.speech_output_dir()) + .unwrap_or_default() + .join(default_speech_output_name(&format)) + }); let client = DeepSeekClient::new(config)?; let response = client @@ -3699,99 +3696,12 @@ async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> { Ok(()) } -fn combine_speech_instructions( - instruction: Option, - voice_prompt: Option, -) -> Option { - match (instruction, voice_prompt) { - (Some(instruction), Some(voice_prompt)) => { - let instruction = instruction.trim(); - let voice_prompt = voice_prompt.trim(); - if instruction.is_empty() { - Some(voice_prompt.to_string()).filter(|value| !value.is_empty()) - } else if voice_prompt.is_empty() { - Some(instruction.to_string()).filter(|value| !value.is_empty()) - } else { - Some(format!("{voice_prompt}\n\n{instruction}")) - } - } - (Some(value), None) | (None, Some(value)) => { - let value = value.trim().to_string(); - if value.is_empty() { None } else { Some(value) } - } - (None, None) => None, - } -} - -const VOICE_CLONE_BASE64_MAX_BYTES: usize = 10 * 1024 * 1024; - -fn normalize_speech_format(format: &str) -> Option { - let normalized = format.trim().to_ascii_lowercase(); - match normalized.as_str() { - "wav" | "mp3" | "pcm16" => Some(normalized), - "pcm" => Some("pcm16".to_string()), - _ => None, - } -} - -fn default_speech_output_name(format: &str) -> String { - format!( - "speech.{}", - normalize_speech_format(format).as_deref().unwrap_or("wav") - ) -} - -fn resolve_speech_output_path( - output: Option, - output_dir: Option, - format: &str, -) -> PathBuf { - output.unwrap_or_else(|| { - output_dir - .unwrap_or_default() - .join(default_speech_output_name(format)) - }) -} - -fn encode_voice_clone_data_uri(path: &Path) -> Result { - let bytes = std::fs::read(path) - .with_context(|| format!("Failed to read voice clone sample {}", path.display()))?; - let base64_audio = general_purpose::STANDARD.encode(bytes); - if base64_audio.len() > VOICE_CLONE_BASE64_MAX_BYTES { - bail!( - "Voice clone sample is too large after base64 encoding ({} bytes > 10 MB)", - base64_audio.len() - ); - } - - let extension = path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or_default() - .to_ascii_lowercase(); - let mime = match extension.as_str() { - "mp3" => "audio/mpeg", - "wav" => "audio/wav", - other => bail!( - "Unsupported voice clone sample extension '{}'. Use .mp3 or .wav.", - other - ), - }; - - Ok(format!("data:{mime};base64,{base64_audio}")) -} - -fn describe_speech_voice(voice: &str) -> String { - if voice.starts_with("data:") { - "embedded voice clone sample".to_string() - } else { - voice.to_string() - } -} - #[cfg(test)] mod speech_cli_tests { use super::*; + use crate::tools::speech::{ + default_speech_output_name, infer_speech_model, normalize_speech_format, + }; #[test] fn normalizes_documented_speech_formats() { @@ -3804,18 +3714,52 @@ mod speech_cli_tests { #[test] fn default_speech_output_tracks_requested_format() { assert_eq!( - resolve_speech_output_path(None, None, "mp3"), + PathBuf::from(default_speech_output_name("mp3")), PathBuf::from("speech.mp3") ); assert_eq!( - resolve_speech_output_path(None, Some(PathBuf::from("audio")), "pcm"), + PathBuf::from("audio").join(default_speech_output_name("pcm")), PathBuf::from("audio").join("speech.pcm16") ); assert_eq!( - resolve_speech_output_path(Some(PathBuf::from("custom.wav")), None, "mp3"), + Some(PathBuf::from("custom.wav")) + .unwrap_or_else(|| PathBuf::from(default_speech_output_name("mp3"))), PathBuf::from("custom.wav") ); } + + #[test] + fn speech_command_parses_cli_passthrough_smoke() { + let cli = Cli::try_parse_from([ + "codewhale-tui", + "speech", + "hello", + "--model", + "tts", + "--format", + "pcm", + "--output-dir", + "audio", + "--voice", + "Mia", + ]) + .expect("speech command parses"); + + let Some(Commands::Speech(args)) = cli.command else { + panic!("expected speech command"); + }; + assert_eq!(args.text, "hello"); + assert_eq!( + infer_speech_model(args.model.as_deref(), false, false), + "mimo-v2.5-tts" + ); + assert_eq!( + normalize_speech_format(&args.format).as_deref(), + Some("pcm16") + ); + assert_eq!(args.output_dir, Some(PathBuf::from("audio"))); + assert_eq!(args.voice.as_deref(), Some("Mia")); + } } /// Test API connectivity by making a minimal request diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index b8efe51e9..8dcf0e54b 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -975,12 +975,13 @@ impl ToolRegistryBuilder { plan_state: super::plan::SharedPlanState, ) -> Self { let speech_client = client.clone(); + let speech_output_dir = runtime.speech_output_dir.clone(); self.with_agent_tools(allow_shell) .with_todo_tool(todo_list) .with_plan_tool(plan_state) .with_review_tool(client.clone(), model.clone()) .with_rlm_tool(client, model) - .with_speech_tools(speech_client, None) + .with_speech_tools(speech_client, speech_output_dir) .with_recall_archive_tool() .with_subagent_tools(manager, runtime) } diff --git a/crates/tui/src/tools/speech.rs b/crates/tui/src/tools/speech.rs index 92550e692..9c690512a 100644 --- a/crates/tui/src/tools/speech.rs +++ b/crates/tui/src/tools/speech.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; +use anyhow::Context as _; use async_trait::async_trait; use base64::{Engine as _, engine::general_purpose}; use serde_json::{Value, json}; @@ -19,23 +20,19 @@ use super::spec::{ optional_bool, optional_str, required_str, }; -const DEFAULT_FORMAT: &str = "wav"; -const DEFAULT_VOICE: &str = "mimo_default"; +pub(crate) const DEFAULT_FORMAT: &str = "wav"; +pub(crate) const DEFAULT_VOICE: &str = "mimo_default"; const VOICE_CLONE_BASE64_MAX_BYTES: usize = 10 * 1024 * 1024; -const SUPPORTED_SPEECH_FORMATS: &[&str] = &["wav", "mp3", "pcm16"]; +pub(crate) const SUPPORTED_SPEECH_FORMATS: &[&str] = &["wav", "mp3", "pcm16"]; pub const SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS: &[&str] = &[ - "mimo-v2.5-pro", - "mimo-v2.5", "mimo-v2.5-tts-voiceclone", "mimo-v2.5-tts-voicedesign", "mimo-v2.5-tts", - "mimo-v2-pro", - "mimo-v2-omni", "mimo-v2-tts", ]; -const SPEECH_MODEL_EXAMPLES: &[&str] = &[ +pub(crate) const SPEECH_MODEL_EXAMPLES: &[&str] = &[ "mimo-v2.5-tts", "mimo-v2.5-tts-voicedesign", "mimo-v2.5-tts-voiceclone", @@ -302,7 +299,7 @@ impl ToolSpec for SpeechTool { } } -fn infer_speech_model( +pub(crate) fn infer_speech_model( model: Option<&str>, has_clone_voice: bool, has_voice_prompt: bool, @@ -316,7 +313,7 @@ fn infer_speech_model( } } -fn combine_speech_instructions( +pub(crate) fn combine_speech_instructions( instruction: Option, voice_prompt: Option, ) -> Option { @@ -340,7 +337,7 @@ fn combine_speech_instructions( } } -fn normalize_speech_format(format: &str) -> Option { +pub(crate) fn normalize_speech_format(format: &str) -> Option { let normalized = format.trim().to_ascii_lowercase(); match normalized.as_str() { "wav" | "mp3" | "pcm16" => Some(normalized), @@ -349,7 +346,7 @@ fn normalize_speech_format(format: &str) -> Option { } } -fn default_speech_output_name(format: &str) -> String { +pub(crate) fn default_speech_output_name(format: &str) -> String { format!( "speech.{}", normalize_speech_format(format) @@ -391,12 +388,25 @@ async fn encode_voice_clone_data_uri(path: &Path) -> Result { path.display() )) })?; + + voice_clone_data_uri_from_bytes(path, &bytes) + .map_err(|err| ToolError::invalid_input(err.to_string())) +} + +pub(crate) fn encode_voice_clone_sample_data_uri(path: &Path) -> anyhow::Result { + let bytes = std::fs::read(path) + .with_context(|| format!("Failed to read voice clone sample {}", path.display()))?; + + voice_clone_data_uri_from_bytes(path, &bytes) +} + +fn voice_clone_data_uri_from_bytes(path: &Path, bytes: &[u8]) -> anyhow::Result { let base64_audio = general_purpose::STANDARD.encode(bytes); if base64_audio.len() > VOICE_CLONE_BASE64_MAX_BYTES { - return Err(ToolError::invalid_input(format!( + anyhow::bail!( "voice clone sample is too large after base64 encoding ({} bytes > 10 MB)", base64_audio.len() - ))); + ); } let extension = path @@ -408,16 +418,14 @@ async fn encode_voice_clone_data_uri(path: &Path) -> Result { "mp3" => "audio/mpeg", "wav" => "audio/wav", other => { - return Err(ToolError::invalid_input(format!( - "unsupported voice clone sample extension '{other}'. Use .mp3 or .wav." - ))); + anyhow::bail!("unsupported voice clone sample extension '{other}'. Use .mp3 or .wav."); } }; Ok(format!("data:{mime};base64,{base64_audio}")) } -fn describe_speech_voice(voice: &str) -> String { +pub(crate) fn describe_speech_voice(voice: &str) -> String { if voice.starts_with("data:") { "embedded voice clone sample".to_string() } else { @@ -502,6 +510,37 @@ mod tests { assert_eq!(normalize_speech_format("flac"), None); } + #[test] + fn supported_xiaomi_mimo_speech_models_are_tts_only() { + assert!( + SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS + .iter() + .all(|model| model.to_ascii_lowercase().contains("tts")), + "model-visible speech list must not include chat-only MiMo models" + ); + assert!(SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS.contains(&"mimo-v2.5-tts")); + assert!(!SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS.contains(&"mimo-v2.5-pro")); + assert!(!SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS.contains(&"mimo-v2.5")); + } + + #[test] + fn configured_output_dir_is_used_for_default_tool_output() { + let tmp = tempfile::tempdir().expect("tempdir"); + let context = ToolContext::new(tmp.path().to_path_buf()); + let configured = tmp.path().join("speech-artifacts"); + + let output = resolve_speech_output_path( + &json!({"text": "hello"}), + &context, + None, + "pcm", + Some(&configured), + ) + .expect("output path"); + + assert_eq!(output, configured.join("speech.pcm16")); + } + #[test] fn displays_openai_compatible_base_url() { assert_eq!( diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 3ab494b52..55b749856 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -800,6 +800,10 @@ pub struct SubAgentRuntime { /// false-timeout the child mid-thinking. `child_runtime()` and /// `background_runtime()` preserve the parent's value (#1806, #1808). pub step_api_timeout: Duration, + /// Default directory for Xiaomi MiMo speech/TTS tool outputs inherited by + /// child registries. Keeps parent and sub-agent `speech` / `tts` tools on + /// the same `[speech].output_dir` / env override. + pub speech_output_dir: Option, } impl SubAgentRuntime { @@ -835,6 +839,7 @@ impl SubAgentRuntime { fork_context: None, mcp_pool: None, step_api_timeout: DEFAULT_STEP_API_TIMEOUT, + speech_output_dir: None, } } @@ -858,6 +863,13 @@ impl SubAgentRuntime { self } + /// Preserve the configured speech output directory for sub-agent tools. + #[must_use] + pub fn with_speech_output_dir(mut self, output_dir: Option) -> Self { + self.speech_output_dir = output_dir; + self + } + /// Attach the wakeup channel so the engine's parent turn loop can resume /// when this runtime's direct children finish (issue #756). The channel /// is propagated to descendants via clone, but only `spawn_depth == 1` @@ -980,6 +992,7 @@ impl SubAgentRuntime { fork_context: self.fork_context.clone(), mcp_pool: self.mcp_pool.clone(), step_api_timeout: self.step_api_timeout, + speech_output_dir: self.speech_output_dir.clone(), } } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index a2039a46c..2fd3a51a6 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -1805,6 +1805,7 @@ fn stub_runtime() -> SubAgentRuntime { fork_context: None, mcp_pool: None, step_api_timeout: DEFAULT_STEP_API_TIMEOUT, + speech_output_dir: None, } } @@ -2036,6 +2037,16 @@ fn emit_parent_completion_fires_for_direct_child() { assert!(rx.try_recv().is_err(), "should be exactly one message"); } +#[test] +fn child_runtime_inherits_speech_output_dir() { + let output_dir = PathBuf::from("configured-speech-output"); + let runtime = stub_runtime().with_speech_output_dir(Some(output_dir.clone())); + + let child = runtime.child_runtime(); + + assert_eq!(child.speech_output_dir, Some(output_dir)); +} + #[test] fn emit_parent_completion_skips_grandchildren() { let (tx, mut rx) = mpsc::unbounded_channel::(); From e763b44e1ee51b0cc87924b7981924a2181e6ac1 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 21:20:44 -0700 Subject: [PATCH 80/98] docs(changelog): credit new harvests for v0.8.50 (#2514, #2519, #2503, #2560) --- CHANGELOG.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b8393a9c..c9e83ac53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added exact AtlasCloud provider-hinted model ID pass-through for explicit `vendor/model-id` selections, harvested from #2569 without freezing a brittle provider catalog. +- Added Xiaomi MiMo speech/TTS support with a `codewhale speech` CLI command, + `tts` tool alias, and config wiring for voice-design and voice-clone models, + harvested from #2560. +- Added a three-zone immutable prefix diagnostic layer (FrozenPrefix Phase 2) + that logs cache-prefix drift at debug level without blocking requests, + harvested from #2514. +- Added a Cache Guard CI integration test suite simulating prefix-cache + behaviour across nine scenarios, gated behind `CODEWHALE_CACHE_GUARD=1`, + harvested from #2503. +- Added a plan-mode byte-stability invariant test verifying that the tool + catalog head remains byte-identical across mode toggles, harvested from + #2519. ### Changed @@ -47,8 +59,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Community Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, -#2562, #2563, #2564), **@HUQIANTAO** (#2527), **@lucaszhu-hue** (#2569), and -**@idling11** (#2573) for the work harvested into this release pass. Thanks +#2562, #2563, #2564), **@HUQIANTAO** (#2527, #2519, #2503), **@lucaszhu-hue** +(#2569), **@idling11** (#2573), **@encyc** (#2514), and **@xyuai** (#2560) for +the work harvested into this release pass. Thanks also to issue reporters and verification helpers including **@New2Niu** (#2561), **@buko** (#2533, #2369), **@wywsoor** (#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), **@caiyilian** (#2567), and **@chinaqy110** (#2571) for From e99ee5e7b1220da44e1659530dea2e3b59531d0d Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 21:21:56 -0700 Subject: [PATCH 81/98] chore(release): sync tui crate CHANGELOG for version drift gate --- crates/tui/CHANGELOG.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 4b8393a9c..c9e83ac53 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -20,6 +20,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added exact AtlasCloud provider-hinted model ID pass-through for explicit `vendor/model-id` selections, harvested from #2569 without freezing a brittle provider catalog. +- Added Xiaomi MiMo speech/TTS support with a `codewhale speech` CLI command, + `tts` tool alias, and config wiring for voice-design and voice-clone models, + harvested from #2560. +- Added a three-zone immutable prefix diagnostic layer (FrozenPrefix Phase 2) + that logs cache-prefix drift at debug level without blocking requests, + harvested from #2514. +- Added a Cache Guard CI integration test suite simulating prefix-cache + behaviour across nine scenarios, gated behind `CODEWHALE_CACHE_GUARD=1`, + harvested from #2503. +- Added a plan-mode byte-stability invariant test verifying that the tool + catalog head remains byte-identical across mode toggles, harvested from + #2519. ### Changed @@ -47,8 +59,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Community Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, -#2562, #2563, #2564), **@HUQIANTAO** (#2527), **@lucaszhu-hue** (#2569), and -**@idling11** (#2573) for the work harvested into this release pass. Thanks +#2562, #2563, #2564), **@HUQIANTAO** (#2527, #2519, #2503), **@lucaszhu-hue** +(#2569), **@idling11** (#2573), **@encyc** (#2514), and **@xyuai** (#2560) for +the work harvested into this release pass. Thanks also to issue reporters and verification helpers including **@New2Niu** (#2561), **@buko** (#2533, #2369), **@wywsoor** (#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), **@caiyilian** (#2567), and **@chinaqy110** (#2571) for From ddae7584f898fed65cb78220865a3f500cdabd3a Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 21:24:38 -0700 Subject: [PATCH 82/98] fix: resolve clippy warnings in harvested PRs (needless-borrow, is_multiple_of, dead unwrap) --- crates/tui/src/main.rs | 5 ----- crates/tui/tests/cache_guard.rs | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 178b06f1b..1459f3b7b 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -3721,11 +3721,6 @@ mod speech_cli_tests { PathBuf::from("audio").join(default_speech_output_name("pcm")), PathBuf::from("audio").join("speech.pcm16") ); - assert_eq!( - Some(PathBuf::from("custom.wav")) - .unwrap_or_else(|| PathBuf::from(default_speech_output_name("mp3"))), - PathBuf::from("custom.wav") - ); } #[test] diff --git a/crates/tui/tests/cache_guard.rs b/crates/tui/tests/cache_guard.rs index 6dacffd64..0be7b2024 100644 --- a/crates/tui/tests/cache_guard.rs +++ b/crates/tui/tests/cache_guard.rs @@ -131,7 +131,7 @@ fn tool_loop_body(turn: usize, with_reasoning: bool) -> Vec { } else { "" }; - let tool_name = if turn % 2 == 0 { + let tool_name = if turn.is_multiple_of(2) { "read_file" } else { "write_file" @@ -301,7 +301,7 @@ fn compaction_must_cause_at_least_one_miss() { // Post-compaction: system prompt is truncated/changed. format!("You are a helpful assistant.\n\nUser: turn {turn}\nAssistant:") }; - cache.submit(&body.as_bytes()); + cache.submit(body.as_bytes()); } // After compaction, there should be at least one significant miss. From 6ab77eaba33bd190043a0365f136a721767c9f9c Mon Sep 17 00:00:00 2001 From: gordonlu Date: Tue, 2 Jun 2026 10:46:13 +0800 Subject: [PATCH 83/98] feat(i18n): localize all queue command messages across 7 locales --- crates/tui/src/commands/queue.rs | 131 ++++++++++++++++++-------- crates/tui/src/localization.rs | 156 +++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 37 deletions(-) diff --git a/crates/tui/src/commands/queue.rs b/crates/tui/src/commands/queue.rs index b1c76b8b6..a15b99157 100644 --- a/crates/tui/src/commands/queue.rs +++ b/crates/tui/src/commands/queue.rs @@ -1,5 +1,6 @@ //! Queue commands: queue list/edit/drop/clear +use crate::localization::{Locale, MessageId, tr}; use crate::tui::app::App; use super::CommandResult; @@ -7,6 +8,7 @@ use super::CommandResult; const PREVIEW_LIMIT: usize = 120; pub fn queue(app: &mut App, args: Option<&str>) -> CommandResult { + let locale = app.ui_locale; let arg = args.unwrap_or("").trim(); if arg.is_empty() || arg.eq_ignore_ascii_case("list") { return list_queue(app); @@ -19,27 +21,28 @@ pub fn queue(app: &mut App, args: Option<&str>) -> CommandResult { "edit" => edit_queue(app, parts.next()), "drop" | "remove" | "rm" => drop_queue(app, parts.next()), "clear" => clear_queue(app), - _ => CommandResult::error("Usage: /queue [list|edit |drop |clear]"), + _ => CommandResult::error(tr(locale, MessageId::CmdQueueUsage)), } } fn list_queue(app: &mut App) -> CommandResult { + let locale = app.ui_locale; let mut lines = Vec::new(); let queued = app.queued_message_count(); if let Some(draft) = app.queued_draft.as_ref() { - lines.push("Editing queued message:".to_string()); - lines.push(format!("- {}", truncate_preview(&draft.display))); + let header = tr(locale, MessageId::CmdQueueDraftHeader); + lines.push(format!("{} {}", header, truncate_preview(&draft.display))); } if queued == 0 { if lines.is_empty() { - return CommandResult::message("No queued messages"); + return CommandResult::message(tr(locale, MessageId::CmdQueueNoMessages)); } return CommandResult::message(lines.join("\n")); } - lines.push(format!("Queued messages ({queued}):")); + lines.push(tr(locale, MessageId::CmdQueueListHeader).replace("{count}", &queued.to_string())); for (idx, message) in app.queued_messages.iter().enumerate() { lines.push(format!( "{}. {}", @@ -48,70 +51,74 @@ fn list_queue(app: &mut App) -> CommandResult { )); } - lines.push("Tip: /queue edit to edit, /queue drop to remove".to_string()); + lines.push(tr(locale, MessageId::CmdQueueTip).to_string()); CommandResult::message(lines.join("\n")) } fn edit_queue(app: &mut App, index: Option<&str>) -> CommandResult { + let locale = app.ui_locale; if app.queued_draft.is_some() { - return CommandResult::error( - "Already editing a queued message. Send it or /queue clear to discard.", - ); + return CommandResult::error(tr(locale, MessageId::CmdQueueAlreadyEditing)); } - let index = match parse_index(index) { + let index = match parse_index(index, locale) { Ok(index) => index, Err(err) => return CommandResult::error(err), }; let Some(message) = app.remove_queued_message(index) else { - return CommandResult::error("Queued message not found"); + return CommandResult::error(tr(locale, MessageId::CmdQueueNotFound)); }; app.input = message.display.clone(); app.cursor_position = app.input.len(); app.queued_draft = Some(message); - app.status_message = Some(format!("Editing queued message {}", index + 1)); + let status = + tr(locale, MessageId::CmdQueueEditingStatus).replace("{index}", &(index + 1).to_string()); + app.status_message = Some(status); - CommandResult::message(format!( - "Editing queued message {} (press Enter to re-queue/send)", - index + 1 - )) + CommandResult::message( + tr(locale, MessageId::CmdQueueEditingMessage).replace("{index}", &(index + 1).to_string()), + ) } fn drop_queue(app: &mut App, index: Option<&str>) -> CommandResult { - let index = match parse_index(index) { + let locale = app.ui_locale; + let index = match parse_index(index, locale) { Ok(index) => index, Err(err) => return CommandResult::error(err), }; if app.remove_queued_message(index).is_none() { - return CommandResult::error("Queued message not found"); + return CommandResult::error(tr(locale, MessageId::CmdQueueNotFound)); } - CommandResult::message(format!("Dropped queued message {}", index + 1)) + CommandResult::message( + tr(locale, MessageId::CmdQueueDropped).replace("{index}", &(index + 1).to_string()), + ) } fn clear_queue(app: &mut App) -> CommandResult { + let locale = app.ui_locale; let queued = app.queued_message_count(); let had_draft = app.queued_draft.take().is_some(); app.queued_messages.clear(); if queued == 0 && !had_draft { - return CommandResult::message("Queue already empty"); + return CommandResult::message(tr(locale, MessageId::CmdQueueAlreadyEmpty)); } - CommandResult::message("Queue cleared") + CommandResult::message(tr(locale, MessageId::CmdQueueCleared)) } -fn parse_index(input: Option<&str>) -> Result { +fn parse_index(input: Option<&str>, locale: Locale) -> Result { let Some(input) = input else { - return Err("Missing index. Usage: /queue edit or /queue drop "); + return Err(tr(locale, MessageId::CmdQueueMissingIndex).to_string()); }; let raw = input .parse::() - .map_err(|_| "Index must be a positive number")?; + .map_err(|_| tr(locale, MessageId::CmdQueueIndexPositive).to_string())?; if raw == 0 { - return Err("Index must be >= 1"); + return Err(tr(locale, MessageId::CmdQueueIndexMin).to_string()); } Ok(raw - 1) } @@ -164,16 +171,18 @@ mod tests { fn test_queue_list_empty() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; let result = queue(&mut app, None); assert!(result.message.is_some()); let msg = result.message.unwrap(); - assert!(msg.contains("No queued messages")); + assert!(msg.contains(tr(app.ui_locale, MessageId::CmdQueueNoMessages))); } #[test] fn test_queue_list_with_messages() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; app.queued_messages .push_back(QueuedMessage::new("First message".to_string(), None)); app.queued_messages @@ -181,7 +190,9 @@ mod tests { let result = queue(&mut app, Some("list")); assert!(result.message.is_some()); let msg = result.message.unwrap(); - assert!(msg.contains("Queued messages (2)")); + assert!( + msg.contains(&tr(app.ui_locale, MessageId::CmdQueueListHeader).replace("{count}", "2")) + ); assert!(msg.contains("1. First message")); assert!(msg.contains("2. Second message")); } @@ -190,24 +201,29 @@ mod tests { fn test_queue_edit_missing_index() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; app.queued_messages .push_back(QueuedMessage::new("Test".to_string(), None)); let result = queue(&mut app, Some("edit")); assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Missing index")); + let msg = result.message.unwrap(); + assert!( + msg.contains(tr(Locale::En, MessageId::CmdQueueMissingIndex)), + "msg={msg:?}" + ); } #[test] fn test_queue_edit_invalid_index() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; let result = queue(&mut app, Some("edit abc")); assert!(result.message.is_some()); + let msg = result.message.unwrap(); assert!( - result - .message - .unwrap() - .contains("must be a positive number") + msg.contains(tr(Locale::En, MessageId::CmdQueueIndexPositive)), + "msg={msg:?}" ); } @@ -215,15 +231,21 @@ mod tests { fn test_queue_edit_not_found() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; let result = queue(&mut app, Some("edit 1")); assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("not found")); + let msg = result.message.unwrap(); + assert!( + msg.contains(tr(Locale::En, MessageId::CmdQueueNotFound)), + "msg={msg:?}" + ); } #[test] fn test_queue_edit_already_editing() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; app.queued_messages .push_back(QueuedMessage::new("First".to_string(), None)); app.queued_messages @@ -233,13 +255,18 @@ mod tests { // Try to edit another let result = queue(&mut app, Some("edit 2")); assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Already editing")); + let msg = result.message.unwrap(); + assert!( + msg.contains(tr(Locale::En, MessageId::CmdQueueAlreadyEditing)), + "msg={msg:?}" + ); } #[test] fn test_queue_edit_success() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; app.queued_messages .push_back(QueuedMessage::new("Original message".to_string(), None)); let result = queue(&mut app, Some("edit 1")); @@ -253,12 +280,17 @@ mod tests { fn test_queue_drop_success() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; app.queued_messages .push_back(QueuedMessage::new("To drop".to_string(), None)); let initial_count = app.queued_messages.len(); let result = queue(&mut app, Some("drop 1")); assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Dropped queued message")); + let msg = result.message.unwrap(); + assert!( + msg.contains(&tr(Locale::En, MessageId::CmdQueueDropped).replace("{index}", "1")), + "msg={msg:?}" + ); assert_eq!(app.queued_messages.len(), initial_count - 1); } @@ -266,13 +298,18 @@ mod tests { fn test_queue_clear() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; app.queued_messages .push_back(QueuedMessage::new("Message 1".to_string(), None)); app.queued_messages .push_back(QueuedMessage::new("Message 2".to_string(), None)); let result = queue(&mut app, Some("clear")); assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Queue cleared")); + let msg = result.message.unwrap(); + assert!( + msg.contains(tr(Locale::En, MessageId::CmdQueueCleared)), + "msg={msg:?}" + ); assert!(app.queued_messages.is_empty()); } @@ -280,9 +317,29 @@ mod tests { fn test_queue_clear_already_empty() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; let result = queue(&mut app, Some("clear")); assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Queue already empty")); + let msg = result.message.unwrap(); + assert!( + msg.contains(tr(Locale::En, MessageId::CmdQueueAlreadyEmpty)), + "msg={msg:?}" + ); + } + + #[test] + fn queue_messages_are_localized() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::ZhHans; + app.queued_messages + .push_back(QueuedMessage::new("M1".to_string(), None)); + app.queued_messages + .push_back(QueuedMessage::new("M2".to_string(), None)); + let result = queue(&mut app, Some("list")); + let msg = result.message.unwrap(); + assert!(msg.contains("已排队的消息"), "zh list header: {msg}"); + assert!(msg.contains("提示"), "zh tip: {msg}"); } #[test] diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index ad8531923..a1fa10b8e 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -290,6 +290,21 @@ pub enum MessageId { CmdThemeDescription, CmdProviderDescription, CmdQueueDescription, + CmdQueueUsage, + CmdQueueDraftHeader, + CmdQueueNoMessages, + CmdQueueListHeader, + CmdQueueTip, + CmdQueueAlreadyEditing, + CmdQueueNotFound, + CmdQueueEditingStatus, + CmdQueueEditingMessage, + CmdQueueDropped, + CmdQueueAlreadyEmpty, + CmdQueueCleared, + CmdQueueMissingIndex, + CmdQueueIndexPositive, + CmdQueueIndexMin, CmdRecallDescription, CmdRelayDescription, CmdRenameDescription, @@ -554,6 +569,21 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdNoteDescription, MessageId::CmdProviderDescription, MessageId::CmdQueueDescription, + MessageId::CmdQueueUsage, + MessageId::CmdQueueDraftHeader, + MessageId::CmdQueueNoMessages, + MessageId::CmdQueueListHeader, + MessageId::CmdQueueTip, + MessageId::CmdQueueAlreadyEditing, + MessageId::CmdQueueNotFound, + MessageId::CmdQueueEditingStatus, + MessageId::CmdQueueEditingMessage, + MessageId::CmdQueueDropped, + MessageId::CmdQueueAlreadyEmpty, + MessageId::CmdQueueCleared, + MessageId::CmdQueueMissingIndex, + MessageId::CmdQueueIndexPositive, + MessageId::CmdQueueIndexMin, MessageId::CmdRecallDescription, MessageId::CmdRelayDescription, MessageId::CmdRenameDescription, @@ -1035,6 +1065,27 @@ fn english(id: MessageId) -> &'static str { "Switch or view the active LLM backend (deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "View or edit queued messages", + MessageId::CmdQueueUsage => "Usage: /queue [list|edit |drop |clear]", + MessageId::CmdQueueDraftHeader => "Editing queued message:", + MessageId::CmdQueueNoMessages => "No queued messages", + MessageId::CmdQueueListHeader => "Queued messages ({count}):", + MessageId::CmdQueueTip => "Tip: /queue edit to edit, /queue drop to remove", + MessageId::CmdQueueAlreadyEditing => { + "Already editing a queued message. Send it or /queue clear to discard." + } + MessageId::CmdQueueNotFound => "Queued message not found", + MessageId::CmdQueueEditingStatus => "Editing queued message {index}", + MessageId::CmdQueueEditingMessage => { + "Editing queued message {index} (press Enter to re-queue/send)" + } + MessageId::CmdQueueDropped => "Dropped queued message {index}", + MessageId::CmdQueueAlreadyEmpty => "Queue already empty", + MessageId::CmdQueueCleared => "Queue cleared", + MessageId::CmdQueueMissingIndex => { + "Missing index. Usage: /queue edit or /queue drop " + } + MessageId::CmdQueueIndexPositive => "Index must be a positive number", + MessageId::CmdQueueIndexMin => "Index must be >= 1", MessageId::CmdRecallDescription => "Search prior cycle archives (BM25 over message text)", MessageId::CmdRelayDescription => "Create a session relay (接力) for a fresh thread", MessageId::CmdRenameDescription => "Rename the current session", @@ -1443,6 +1494,27 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { "Chuyển đổi hoặc xem backend LLM đang hoạt động (deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "Xem hoặc chỉnh sửa các tin nhắn đang chờ xử lý", + MessageId::CmdQueueUsage => "Cách dùng: /queue [list|edit |drop |clear]", + MessageId::CmdQueueDraftHeader => "Đang chỉnh sửa tin nhắn đang chờ:", + MessageId::CmdQueueNoMessages => "Không có tin nhắn đang chờ", + MessageId::CmdQueueListHeader => "Tin nhắn đang chờ ({count}):", + MessageId::CmdQueueTip => "Mẹo: /queue edit để sửa, /queue drop để xóa", + MessageId::CmdQueueAlreadyEditing => { + "Đã đang chỉnh sửa một tin nhắn đang chờ. Hãy gửi nó hoặc dùng /queue clear để hủy." + } + MessageId::CmdQueueNotFound => "Không tìm thấy tin nhắn đang chờ", + MessageId::CmdQueueEditingStatus => "Đang chỉnh sửa tin nhắn đang chờ {index}", + MessageId::CmdQueueEditingMessage => { + "Đang chỉnh sửa tin nhắn đang chờ {index} (nhấn Enter để xếp lại hàng/gửi)" + } + MessageId::CmdQueueDropped => "Đã xóa tin nhắn đang chờ {index}", + MessageId::CmdQueueAlreadyEmpty => "Hàng đợi đã trống", + MessageId::CmdQueueCleared => "Đã xóa hàng đợi", + MessageId::CmdQueueMissingIndex => { + "Thiếu chỉ mục. Cách dùng: /queue edit hoặc /queue drop " + } + MessageId::CmdQueueIndexPositive => "Chỉ mục phải là số dương", + MessageId::CmdQueueIndexMin => "Chỉ mục phải >= 1", MessageId::CmdRecallDescription => { "Tìm kiếm kho lưu trữ chu kỳ trước (BM25 trên văn bản tin nhắn)" } @@ -1878,6 +1950,27 @@ fn japanese(id: MessageId) -> Option<&'static str> { "現在の LLM バックエンドを切り替え・確認(deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "キューされたメッセージを確認・編集", + MessageId::CmdQueueUsage => "使用方法: /queue [list|edit |drop |clear]", + MessageId::CmdQueueDraftHeader => "キューされたメッセージを編集中:", + MessageId::CmdQueueNoMessages => "キューされたメッセージはありません", + MessageId::CmdQueueListHeader => "キューされたメッセージ ({count}):", + MessageId::CmdQueueTip => "ヒント: /queue edit で編集、/queue drop で削除", + MessageId::CmdQueueAlreadyEditing => { + "すでにキューされたメッセージを編集中です。送信するか /queue clear で破棄してください。" + } + MessageId::CmdQueueNotFound => "キューされたメッセージが見つかりません", + MessageId::CmdQueueEditingStatus => "キューされたメッセージ {index} を編集中", + MessageId::CmdQueueEditingMessage => { + "キューされたメッセージ {index} を編集中(Enter で再キュー/送信)" + } + MessageId::CmdQueueDropped => "キューされたメッセージ {index} を削除しました", + MessageId::CmdQueueAlreadyEmpty => "キューはすでに空です", + MessageId::CmdQueueCleared => "キューをクリアしました", + MessageId::CmdQueueMissingIndex => { + "インデックスが指定されていません。使用方法: /queue edit または /queue drop " + } + MessageId::CmdQueueIndexPositive => "インデックスは正の数値である必要があります", + MessageId::CmdQueueIndexMin => "インデックスは 1 以上である必要があります", MessageId::CmdRecallDescription => { "過去のサイクルアーカイブを検索(メッセージ本文への BM25 検索)" } @@ -2253,6 +2346,25 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { "切换或查看当前 LLM 后端(deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "查看或编辑已排队的消息", + MessageId::CmdQueueUsage => "用法: /queue [list|edit |drop |clear]", + MessageId::CmdQueueDraftHeader => "正在编辑已排队的消息:", + MessageId::CmdQueueNoMessages => "没有已排队的消息", + MessageId::CmdQueueListHeader => "已排队的消息 ({count}):", + MessageId::CmdQueueTip => "提示: /queue edit 编辑, /queue drop 删除", + MessageId::CmdQueueAlreadyEditing => { + "已在编辑一条已排队的消息。请先发送或使用 /queue clear 放弃。" + } + MessageId::CmdQueueNotFound => "未找到已排队的消息", + MessageId::CmdQueueEditingStatus => "正在编辑已排队的消息 {index}", + MessageId::CmdQueueEditingMessage => { + "正在编辑已排队的消息 {index}(按 Enter 重新排队/发送)" + } + MessageId::CmdQueueDropped => "已删除已排队的消息 {index}", + MessageId::CmdQueueAlreadyEmpty => "队列已空", + MessageId::CmdQueueCleared => "队列已清空", + MessageId::CmdQueueMissingIndex => "缺少索引。用法: /queue edit 或 /queue drop ", + MessageId::CmdQueueIndexPositive => "索引必须为正数", + MessageId::CmdQueueIndexMin => "索引必须 >= 1", MessageId::CmdRecallDescription => "搜索此前的循环归档(基于消息文本的 BM25 检索)", MessageId::CmdRelayDescription => "为新线程创建会话接力摘要", MessageId::CmdRenameDescription => "重命名当前会话", @@ -2614,6 +2726,27 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { "Trocar ou exibir o backend LLM ativo (deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "Ver ou editar mensagens enfileiradas", + MessageId::CmdQueueUsage => "Uso: /queue [list|edit |drop |clear]", + MessageId::CmdQueueDraftHeader => "Editando mensagem enfileirada:", + MessageId::CmdQueueNoMessages => "Nenhuma mensagem enfileirada", + MessageId::CmdQueueListHeader => "Mensagens enfileiradas ({count}):", + MessageId::CmdQueueTip => "Dica: /queue edit para editar, /queue drop para remover", + MessageId::CmdQueueAlreadyEditing => { + "Já está editando uma mensagem enfileirada. Envie-a ou use /queue clear para descartar." + } + MessageId::CmdQueueNotFound => "Mensagem enfileirada não encontrada", + MessageId::CmdQueueEditingStatus => "Editando mensagem enfileirada {index}", + MessageId::CmdQueueEditingMessage => { + "Editando mensagem enfileirada {index} (pressione Enter para re-enfileirar/enviar)" + } + MessageId::CmdQueueDropped => "Mensagem enfileirada {index} removida", + MessageId::CmdQueueAlreadyEmpty => "Fila já está vazia", + MessageId::CmdQueueCleared => "Fila limpa", + MessageId::CmdQueueMissingIndex => { + "Índice ausente. Uso: /queue edit ou /queue drop " + } + MessageId::CmdQueueIndexPositive => "O índice deve ser um número positivo", + MessageId::CmdQueueIndexMin => "O índice deve ser >= 1", MessageId::CmdRecallDescription => { "Buscar arquivos de ciclos anteriores (BM25 sobre o texto das mensagens)" } @@ -3039,6 +3172,29 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { "Cambiar o mostrar el backend LLM activo (deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "Ver o editar mensajes en cola", + MessageId::CmdQueueUsage => "Uso: /queue [list|edit |drop |clear]", + MessageId::CmdQueueDraftHeader => "Editando mensaje en cola:", + MessageId::CmdQueueNoMessages => "No hay mensajes en cola", + MessageId::CmdQueueListHeader => "Mensajes en cola ({count}):", + MessageId::CmdQueueTip => { + "Consejo: /queue edit para editar, /queue drop para eliminar" + } + MessageId::CmdQueueAlreadyEditing => { + "Ya estás editando un mensaje en cola. Envíalo o usa /queue clear para descartarlo." + } + MessageId::CmdQueueNotFound => "Mensaje en cola no encontrado", + MessageId::CmdQueueEditingStatus => "Editando mensaje en cola {index}", + MessageId::CmdQueueEditingMessage => { + "Editando mensaje en cola {index} (presiona Enter para re-encolar/enviar)" + } + MessageId::CmdQueueDropped => "Mensaje en cola {index} eliminado", + MessageId::CmdQueueAlreadyEmpty => "La cola ya está vacía", + MessageId::CmdQueueCleared => "Cola limpiada", + MessageId::CmdQueueMissingIndex => { + "Índice faltante. Uso: /queue edit o /queue drop " + } + MessageId::CmdQueueIndexPositive => "El índice debe ser un número positivo", + MessageId::CmdQueueIndexMin => "El índice debe ser >= 1", MessageId::CmdRecallDescription => { "Buscar archivos de ciclos anteriores (BM25 sobre el texto de los mensajes)" } From 19d55799a530478591a280688440081d7792c4a1 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Tue, 2 Jun 2026 10:48:23 +0800 Subject: [PATCH 84/98] fix: avoid Instant overflow in turn_liveness tests on Windows --- crates/tui/src/tui/ui/tests.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 9434ee62c..0997691c0 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2530,11 +2530,15 @@ fn turn_liveness_leaves_active_turn_running() { #[test] fn turn_liveness_uses_recent_turn_activity_not_turn_start() { let mut app = create_test_app(); - let now = Instant::now(); app.is_loading = true; app.runtime_turn_status = Some("in_progress".to_string()); - app.turn_started_at = Some(now - TURN_STALL_WATCHDOG_TIMEOUT - Duration::from_secs(30)); - app.turn_last_activity_at = Some(now - Duration::from_secs(1)); + app.turn_started_at = Some(Instant::now()); + app.turn_last_activity_at = Some( + app.turn_started_at.unwrap() + + TURN_STALL_WATCHDOG_TIMEOUT + + Duration::from_secs(29), + ); + let now = app.turn_last_activity_at.unwrap() + Duration::from_secs(1); let recovered = reconcile_turn_liveness(&mut app, now, false); @@ -2547,11 +2551,14 @@ fn turn_liveness_uses_recent_turn_activity_not_turn_start() { #[test] fn turn_liveness_does_not_abort_running_tool() { let mut app = create_test_app(); - let now = Instant::now(); app.is_loading = true; app.runtime_turn_status = Some("in_progress".to_string()); - app.turn_started_at = Some(now - TURN_STALL_WATCHDOG_TIMEOUT - Duration::from_secs(30)); + app.turn_started_at = Some(Instant::now()); app.turn_last_activity_at = app.turn_started_at; + let now = app.turn_started_at.unwrap() + + TURN_STALL_WATCHDOG_TIMEOUT + + Duration::from_secs(30) + + Duration::from_secs(1); let mut active = ActiveCell::new(); active.push_tool( "tool-1", From 478d45f795896ae15e3cb66c6fc77bf8b8577f05 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Tue, 2 Jun 2026 10:58:49 +0800 Subject: [PATCH 85/98] fmt: cargo fmt --- crates/tui/src/tui/ui/tests.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 0997691c0..04d1dc4cc 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2533,11 +2533,8 @@ fn turn_liveness_uses_recent_turn_activity_not_turn_start() { app.is_loading = true; app.runtime_turn_status = Some("in_progress".to_string()); app.turn_started_at = Some(Instant::now()); - app.turn_last_activity_at = Some( - app.turn_started_at.unwrap() - + TURN_STALL_WATCHDOG_TIMEOUT - + Duration::from_secs(29), - ); + app.turn_last_activity_at = + Some(app.turn_started_at.unwrap() + TURN_STALL_WATCHDOG_TIMEOUT + Duration::from_secs(29)); let now = app.turn_last_activity_at.unwrap() + Duration::from_secs(1); let recovered = reconcile_turn_liveness(&mut app, now, false); From 25017091e1e2cfe8b30ebac08a0151b5f19a05e1 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Tue, 2 Jun 2026 11:10:42 +0800 Subject: [PATCH 86/98] fix: restore two-line draft header layout --- crates/tui/src/commands/queue.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/commands/queue.rs b/crates/tui/src/commands/queue.rs index a15b99157..b3804d059 100644 --- a/crates/tui/src/commands/queue.rs +++ b/crates/tui/src/commands/queue.rs @@ -31,8 +31,8 @@ fn list_queue(app: &mut App) -> CommandResult { let queued = app.queued_message_count(); if let Some(draft) = app.queued_draft.as_ref() { - let header = tr(locale, MessageId::CmdQueueDraftHeader); - lines.push(format!("{} {}", header, truncate_preview(&draft.display))); + lines.push(tr(locale, MessageId::CmdQueueDraftHeader).to_string()); + lines.push(format!("- {}", truncate_preview(&draft.display))); } if queued == 0 { From cc60129f3a7b4bf7f70dbf621d8f8924b6cfd9b9 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Tue, 2 Jun 2026 10:34:33 +0800 Subject: [PATCH 87/98] feat(i18n): add FanoutCounts MessageId, wire into FanoutCard stats line --- crates/tui/src/localization.rs | 24 ++++++++++++ crates/tui/src/tui/subagent_routing.rs | 5 ++- crates/tui/src/tui/views/mod.rs | 2 +- crates/tui/src/tui/widgets/agent_card.rs | 47 +++++++++++++++++++----- 4 files changed, 66 insertions(+), 12 deletions(-) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index a1fa10b8e..f08fbccc8 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -503,6 +503,8 @@ pub enum MessageId { CtxMenuContextInspectorDesc, CtxMenuHelp, CtxMenuHelpDesc, + // Agent fanout card. + FanoutCounts, } #[allow(dead_code)] @@ -782,6 +784,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CtxMenuContextInspectorDesc, MessageId::CtxMenuHelp, MessageId::CtxMenuHelpDesc, + MessageId::FanoutCounts, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -1370,6 +1373,9 @@ fn english(id: MessageId) -> &'static str { MessageId::CtxMenuContextInspectorDesc => "active context and cache hints", MessageId::CtxMenuHelp => "Help", MessageId::CtxMenuHelpDesc => "keybindings and commands", + MessageId::FanoutCounts => { + "{done} done · {running} running · {failed} failed · {pending} pending" + } } } @@ -1826,6 +1832,9 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "ngữ cảnh đang hoạt động và gợi ý bộ nhớ đệm", MessageId::CtxMenuHelp => "Trợ giúp", MessageId::CtxMenuHelpDesc => "phím tắt và lệnh", + MessageId::FanoutCounts => { + "{done} hoàn thành · {running} đang chạy · {failed} thất bại · {pending} chờ" + } }) } @@ -1839,6 +1848,9 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { MessageId::TranslationComplete => "翻譯完成", MessageId::TranslationFailed => "翻譯失敗", MessageId::FooterBalancePrefix => "餘額", + MessageId::FanoutCounts => { + "{done} 已完成 · {running} 運行中 · {failed} 失敗 · {pending} 等待中" + } other => chinese_simplified(other)?, }) } @@ -2256,6 +2268,9 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "アクティブなコンテキストとキャッシュヒント", MessageId::CtxMenuHelp => "ヘルプ", MessageId::CtxMenuHelpDesc => "キー操作とコマンド", + MessageId::FanoutCounts => { + "{done} 完了 · {running} 実行中 · {failed} 失敗 · {pending} 待機" + } }) } @@ -2612,6 +2627,9 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "活动上下文和缓存提示", MessageId::CtxMenuHelp => "帮助", MessageId::CtxMenuHelpDesc => "快捷键和命令", + MessageId::FanoutCounts => { + "{done} 已完成 · {running} 运行中 · {failed} 失败 · {pending} 等待中" + } }) } @@ -3052,6 +3070,9 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "contexto ativo e dicas de cache", MessageId::CtxMenuHelp => "Ajuda", MessageId::CtxMenuHelpDesc => "atalhos de teclado e comandos", + MessageId::FanoutCounts => { + "{done} concluído · {running} executando · {failed} falhou · {pending} pendente" + } }) } @@ -3502,6 +3523,9 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "contexto activo y sugerencias de caché", MessageId::CtxMenuHelp => "Ayuda", MessageId::CtxMenuHelpDesc => "atajos de teclado y comandos", + MessageId::FanoutCounts => { + "{done} completado · {running} ejecutando · {failed} falló · {pending} pendiente" + } }) } diff --git a/crates/tui/src/tui/subagent_routing.rs b/crates/tui/src/tui/subagent_routing.rs index 94c9e9751..afe48361c 100644 --- a/crates/tui/src/tui/subagent_routing.rs +++ b/crates/tui/src/tui/subagent_routing.rs @@ -154,7 +154,10 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox card.claim_pending_worker(&agent_id, AgentLifecycle::Running); app.subagent_card_index.insert(agent_id, idx); } else { - let mut card = FanoutCard::new(dispatch_kind.unwrap_or("rlm_eval").to_string()); + let mut card = FanoutCard::new( + dispatch_kind.unwrap_or("rlm_eval").to_string(), + app.ui_locale, + ); card.upsert_worker(&agent_id, AgentLifecycle::Running); app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); let idx = app.history.len().saturating_sub(1); diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 49e213c62..7cc4c11ba 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -2252,7 +2252,7 @@ mod tests { #[test] fn subagent_view_agents_includes_live_fanout_workers_when_cache_is_empty() { let mut app = create_test_app(); - let mut card = FanoutCard::new("rlm").with_workers(["chunk_1", "chunk_2"]); + let mut card = FanoutCard::new("rlm", app.ui_locale).with_workers(["chunk_1", "chunk_2"]); card.upsert_worker("chunk_1", AgentLifecycle::Completed); card.upsert_worker("chunk_2", AgentLifecycle::Running); app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); diff --git a/crates/tui/src/tui/widgets/agent_card.rs b/crates/tui/src/tui/widgets/agent_card.rs index 5b7098a0e..7765780d6 100644 --- a/crates/tui/src/tui/widgets/agent_card.rs +++ b/crates/tui/src/tui/widgets/agent_card.rs @@ -17,6 +17,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; +use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::tools::subagent::MailboxMessage; use crate::tui::widgets::tool_card::{ToolFamily, family_glyph, family_label}; @@ -193,14 +194,16 @@ impl WorkerSlot { pub struct FanoutCard { pub kind: String, pub workers: Vec, + pub locale: Locale, } impl FanoutCard { #[must_use] - pub fn new(kind: impl Into) -> Self { + pub fn new(kind: impl Into, locale: Locale) -> Self { Self { kind: kind.into(), workers: Vec::new(), + locale, } } @@ -309,9 +312,11 @@ impl FanoutCard { lines.push(Line::from(vec![ Span::styled(" ", Style::default()), Span::styled( - format!( - "{done} done \u{00B7} {running} running \u{00B7} {failed} failed \u{00B7} {pending} pending" - ), + tr(self.locale, MessageId::FanoutCounts) + .replace("{done}", &done.to_string()) + .replace("{running}", &running.to_string()) + .replace("{failed}", &failed.to_string()) + .replace("{pending}", &pending.to_string()), Style::default().fg(palette::TEXT_MUTED), ), ])); @@ -632,7 +637,7 @@ mod tests { #[test] fn fanout_card_dot_grid_renders_stateful_worker_slots() { - let mut card = FanoutCard::new("fanout") + let mut card = FanoutCard::new("fanout", Locale::En) .with_workers(["w_1", "w_2", "w_3", "w_4", "w_5", "w_6", "w_7"]); card.upsert_worker("w_1", AgentLifecycle::Completed); card.upsert_worker("w_2", AgentLifecycle::Completed); @@ -649,7 +654,8 @@ mod tests { #[test] fn fanout_card_aggregate_counts_match_dot_grid() { - let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2", "w_3", "w_4"]); + let mut card = + FanoutCard::new("rlm", Locale::En).with_workers(["w_1", "w_2", "w_3", "w_4"]); card.upsert_worker("w_1", AgentLifecycle::Completed); card.upsert_worker("w_2", AgentLifecycle::Completed); card.upsert_worker("w_3", AgentLifecycle::Completed); @@ -672,7 +678,7 @@ mod tests { #[test] fn fanout_apply_inserts_unknown_worker_via_child_spawned() { - let mut card = FanoutCard::new("fanout"); + let mut card = FanoutCard::new("fanout", Locale::En); let msg = MailboxMessage::ChildSpawned { parent_id: "root".into(), child_id: "agent_late".into(), @@ -685,7 +691,7 @@ mod tests { #[test] fn fanout_started_claims_seeded_pending_slot_without_growing_grid() { - let mut card = FanoutCard::new("fanout").with_workers(["task:a", "task:b"]); + let mut card = FanoutCard::new("fanout", Locale::En).with_workers(["task:a", "task:b"]); let started = MailboxMessage::started("agent_live", crate::tools::subagent::SubAgentType::General); @@ -700,7 +706,7 @@ mod tests { #[test] fn fanout_apply_transitions_worker_through_lifecycle() { - let mut card = FanoutCard::new("fanout").with_workers(["w_1"]); + let mut card = FanoutCard::new("fanout", Locale::En).with_workers(["w_1"]); let started = MailboxMessage::started("w_1", crate::tools::subagent::SubAgentType::General); apply_to_fanout(&mut card, &started); assert_eq!(card.workers[0].status, AgentLifecycle::Running); @@ -729,7 +735,7 @@ mod tests { ]; for (total, done, expected) in cases { let ids: Vec = (0..*total).map(|i| format!("w_{i}")).collect(); - let mut card = FanoutCard::new("fanout").with_workers(ids.iter().cloned()); + let mut card = FanoutCard::new("fanout", Locale::En).with_workers(ids.iter().cloned()); for id in ids.iter().take(*done) { card.upsert_worker(id, AgentLifecycle::Completed); } @@ -740,4 +746,25 @@ mod tests { ); } } + + #[test] + fn fanout_counts_are_localized() { + let ids: Vec = (0..16).map(|i| format!("w_{i}")).collect(); + let mut card = FanoutCard::new("fanout", Locale::ZhHans).with_workers(ids.iter().cloned()); + for id in ids.iter().take(12) { + card.upsert_worker(id, AgentLifecycle::Completed); + } + card.upsert_worker("w_12", AgentLifecycle::Running); + // w_13..w_15 stay Pending; 0 failed + + let rendered = render_to_strings(&card.render_lines(80)); + let stats = rendered + .iter() + .find(|line| line.contains('·')) + .expect("counts line present"); + assert!(stats.contains("已完成"), "{stats}"); + assert!(stats.contains("运行中"), "{stats}"); + assert!(stats.contains("失败"), "{stats}"); + assert!(stats.contains("等待中"), "{stats}"); + } } From f48e398ba591860f6adc1c8d7b2b0a3de73dba33 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Tue, 2 Jun 2026 11:10:18 +0800 Subject: [PATCH 88/98] Revert "fix: restore two-line draft header layout" This reverts commit 6bc5e629c89aa98762b3f181ade29fefaa77380f. --- crates/tui/src/commands/queue.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/commands/queue.rs b/crates/tui/src/commands/queue.rs index b3804d059..51bf2b7db 100644 --- a/crates/tui/src/commands/queue.rs +++ b/crates/tui/src/commands/queue.rs @@ -31,7 +31,7 @@ fn list_queue(app: &mut App) -> CommandResult { let queued = app.queued_message_count(); if let Some(draft) = app.queued_draft.as_ref() { - lines.push(tr(locale, MessageId::CmdQueueDraftHeader).to_string()); + lines.push("Editing queued message:".to_string()); lines.push(format!("- {}", truncate_preview(&draft.display))); } From 97c615ca9cad8fcab4181a4e867f2b2e3f216692 Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:29:46 +0800 Subject: [PATCH 89/98] chore: add contribution gate workflows --- .github/APPROVED_CONTRIBUTORS | 10 ++ .github/workflows/approve-contributor.yml | 175 ++++++++++++++++++++++ .github/workflows/issue-gate.yml | 84 +++++++++++ .github/workflows/pr-gate.yml | 84 +++++++++++ CONTRIBUTING.md | 24 +++ 5 files changed, 377 insertions(+) create mode 100644 .github/APPROVED_CONTRIBUTORS create mode 100644 .github/workflows/approve-contributor.yml create mode 100644 .github/workflows/issue-gate.yml create mode 100644 .github/workflows/pr-gate.yml diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS new file mode 100644 index 000000000..10eae33fe --- /dev/null +++ b/.github/APPROVED_CONTRIBUTORS @@ -0,0 +1,10 @@ +# Scoped contribution-gate allowlist. +# +# Maintainers and collaborators bypass the gate automatically. Use this file +# for external contributors who are allowed through the automated front door. +# +# Supported entries: +# pr:username +# issue:username +# all:username +all:Hmbown diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml new file mode 100644 index 000000000..2818786f4 --- /dev/null +++ b/.github/workflows/approve-contributor.yml @@ -0,0 +1,175 @@ +name: Approve gated contributor + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + approve: + runs-on: ubuntu-latest + steps: + - name: Open allowlist update PR + uses: actions/github-script@v7 + with: + script: | + const comment = context.payload.comment; + const issue = context.payload.issue; + const owner = context.repo.owner; + const repo = context.repo.repo; + const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const command = (comment.body || '').trim().toLowerCase(); + const scopeByCommand = new Map([ + ['/lgtm', 'pr'], + ['lgtm', 'pr'], + ['/lgtmi', 'issue'], + ['lgtmi', 'issue'], + ]); + const scope = scopeByCommand.get(command); + + if (!scope) return; + if (!privileged.has(comment.author_association)) return; + if (scope === 'pr' && !issue.pull_request) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: '`/lgtm` grants PR access and must be used on a pull request. Use `/lgtmi` to grant issue access.', + }); + return; + } + + const path = '.github/APPROVED_CONTRIBUTORS'; + const targetLogin = issue.user.login; + const normalizedLogin = targetLogin.toLowerCase(); + const entry = `${scope}:${normalizedLogin}`; + + const defaultContent = [ + '# Scoped contribution-gate allowlist.', + '#', + '# Maintainers and collaborators bypass the gate automatically. Use this file', + '# for external contributors who are allowed through the automated front door.', + '#', + '# Supported entries:', + '# pr:username', + '# issue:username', + '# all:username', + '', + ].join('\n'); + + function parseAllowlist(content) { + return new Set( + content + .split(/\r?\n/) + .map(line => line.replace(/#.*/, '').trim().toLowerCase()) + .filter(Boolean) + ); + } + + const { data: repoData } = await github.rest.repos.get({ owner, repo }); + const defaultBranch = repoData.default_branch; + const { data: baseRef } = await github.rest.git.getRef({ + owner, + repo, + ref: `heads/${defaultBranch}`, + }); + const baseSha = baseRef.object.sha; + const { data: baseCommit } = await github.rest.git.getCommit({ + owner, + repo, + commit_sha: baseSha, + }); + + let content = defaultContent; + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path, + ref: defaultBranch, + }); + if (!Array.isArray(data) && data.type === 'file') { + content = Buffer.from(data.content, data.encoding || 'base64').toString('utf8'); + } + } catch (error) { + if (error.status !== 404) throw error; + } + + const existing = parseAllowlist(content); + if (existing.has(entry) || existing.has(`all:${normalizedLogin}`)) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `@${targetLogin} is already approved for ${scope} contributions in \`${path}\`.`, + }); + return; + } + + const nextContent = `${content.trimEnd()}\n${entry}\n`; + const { data: blob } = await github.rest.git.createBlob({ + owner, + repo, + content: nextContent, + encoding: 'utf-8', + }); + const { data: tree } = await github.rest.git.createTree({ + owner, + repo, + base_tree: baseCommit.tree.sha, + tree: [ + { + path, + mode: '100644', + type: 'blob', + sha: blob.sha, + }, + ], + }); + + const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor'; + const branchName = `contribution-gate/${scope}-${branchSlug}-${Date.now()}`; + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/heads/${branchName}`, + sha: baseSha, + }); + + const { data: commit } = await github.rest.git.createCommit({ + owner, + repo, + message: `chore: approve @${targetLogin} for ${scope} contributions`, + tree: tree.sha, + parents: [baseSha], + }); + await github.rest.git.updateRef({ + owner, + repo, + ref: `heads/${branchName}`, + sha: commit.sha, + }); + + const { data: pr } = await github.rest.pulls.create({ + owner, + repo, + title: `chore: approve @${targetLogin} for ${scope} contributions`, + head: branchName, + base: defaultBranch, + body: [ + `Adds \`${entry}\` to \`${path}\`.`, + '', + `Requested by @${comment.user.login} in #${issue.number}.`, + ].join('\n'), + }); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `Created allowlist update PR: ${pr.html_url}`, + }); diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml new file mode 100644 index 000000000..70fe83eb1 --- /dev/null +++ b/.github/workflows/issue-gate.yml @@ -0,0 +1,84 @@ +name: Contribution gate - issues + +on: + issues: + types: [opened, reopened] + +permissions: + contents: read + issues: write + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Close unapproved external issues + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const owner = context.repo.owner; + const repo = context.repo.repo; + const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + + if (issue.pull_request) return; + if (privileged.has(issue.author_association)) return; + if (issue.user.login === 'github-actions[bot]') return; + + function parseAllowlist(content) { + return new Set( + content + .split(/\r?\n/) + .map(line => line.replace(/#.*/, '').trim().toLowerCase()) + .filter(Boolean) + ); + } + + async function readAllowlist() { + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path: '.github/APPROVED_CONTRIBUTORS', + ref: context.payload.repository.default_branch, + }); + if (Array.isArray(data) || data.type !== 'file') return new Set(); + return parseAllowlist( + Buffer.from(data.content, data.encoding || 'base64').toString('utf8') + ); + } catch (error) { + if (error.status === 404) return new Set(); + throw error; + } + } + + const allowlist = await readAllowlist(); + const login = issue.user.login.toLowerCase(); + if ( + allowlist.has(login) || + allowlist.has(`all:${login}`) || + allowlist.has(`issue:${login}`) + ) { + return; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: [ + `Thanks @${issue.user.login} for the report.`, + '', + 'This repository currently uses a maintainer-managed contribution gate, so issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.', + '', + 'Please read `CONTRIBUTING.md` for the expected issue shape. A maintainer can grant issue access by commenting `/lgtmi` on an issue.', + ].join('\n'), + }); + + await github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned', + }); diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 000000000..4be1758aa --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,84 @@ +name: Contribution gate - pull requests + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Close unapproved external pull requests + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const owner = context.repo.owner; + const repo = context.repo.repo; + const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + + if (privileged.has(pr.author_association)) return; + if (pr.user.login === 'github-actions[bot]') return; + if ((pr.head.ref || '').startsWith('contribution-gate/')) return; + + function parseAllowlist(content) { + return new Set( + content + .split(/\r?\n/) + .map(line => line.replace(/#.*/, '').trim().toLowerCase()) + .filter(Boolean) + ); + } + + async function readAllowlist() { + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path: '.github/APPROVED_CONTRIBUTORS', + ref: pr.base.ref, + }); + if (Array.isArray(data) || data.type !== 'file') return new Set(); + return parseAllowlist( + Buffer.from(data.content, data.encoding || 'base64').toString('utf8') + ); + } catch (error) { + if (error.status === 404) return new Set(); + throw error; + } + } + + const allowlist = await readAllowlist(); + const login = pr.user.login.toLowerCase(); + if ( + allowlist.has(login) || + allowlist.has(`all:${login}`) || + allowlist.has(`pr:${login}`) + ) { + return; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: [ + `Thanks @${pr.user.login} for taking the time to contribute.`, + '', + 'This repository currently uses a maintainer-managed contribution gate, so pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.', + '', + 'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant PR access by commenting `/lgtm` on a pull request.', + ].join('\n'), + }); + + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ccbf68c6..75dec7311 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,6 +167,30 @@ Issues: Validation: ``` +## Contribution Gate + +CodeWhale uses a maintainer-managed contribution gate for the community front +door. Maintainers and collaborators bypass this gate automatically. External +contributors must be listed in `.github/APPROVED_CONTRIBUTORS` before their +issues or pull requests remain open. + +The allowlist is scoped: + +- `pr:username` allows pull requests. +- `issue:username` allows issues. +- `all:username` allows both. + +When an unapproved external contributor opens an issue or pull request, the +matching gate workflow leaves a short thank-you / CONTRIBUTING pointer and +closes it. A maintainer can approve someone by commenting `/lgtm` on a pull +request for PR access, or `/lgtmi` on an issue for issue access. The exact bare +commands `lgtm` and `lgtmi` are also accepted for compatibility, but the +prefixed forms are preferred because they are harder to trigger accidentally in +ordinary review discussion. + +Approvals do not edit `main` directly. The approval workflow opens a small +allowlist update PR so the new entry is reviewable before it takes effect. + ## Agent-Assisted Improvements CodeWhale is allowed to help improve CodeWhale, but the contribution still has From dcf8350ff86c472cf62c0359ae37282b37cd8c20 Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:39:48 +0800 Subject: [PATCH 90/98] fix: harden contribution gate bypasses --- .github/workflows/approve-contributor.yml | 9 +++++++++ .github/workflows/issue-gate.yml | 1 - .github/workflows/pr-gate.yml | 2 -- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml index 2818786f4..6c7737510 100644 --- a/.github/workflows/approve-contributor.yml +++ b/.github/workflows/approve-contributor.yml @@ -42,6 +42,15 @@ jobs: }); return; } + if (scope === 'issue' && issue.pull_request) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: '`/lgtmi` grants issue access and must be used on an issue. Use `/lgtm` to grant PR access.', + }); + return; + } const path = '.github/APPROVED_CONTRIBUTORS'; const targetLogin = issue.user.login; diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index 70fe83eb1..6966fdf69 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -55,7 +55,6 @@ jobs: const allowlist = await readAllowlist(); const login = issue.user.login.toLowerCase(); if ( - allowlist.has(login) || allowlist.has(`all:${login}`) || allowlist.has(`issue:${login}`) ) { diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 4be1758aa..428af059c 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -24,7 +24,6 @@ jobs: if (privileged.has(pr.author_association)) return; if (pr.user.login === 'github-actions[bot]') return; - if ((pr.head.ref || '').startsWith('contribution-gate/')) return; function parseAllowlist(content) { return new Set( @@ -56,7 +55,6 @@ jobs: const allowlist = await readAllowlist(); const login = pr.user.login.toLowerCase(); if ( - allowlist.has(login) || allowlist.has(`all:${login}`) || allowlist.has(`pr:${login}`) ) { From 50590761acbde78169da3499c05c7efb773f15c3 Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:48:01 +0800 Subject: [PATCH 91/98] fix: read contribution allowlist from default branch --- .github/workflows/pr-gate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 428af059c..23fe1f957 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -40,7 +40,7 @@ jobs: owner, repo, path: '.github/APPROVED_CONTRIBUTORS', - ref: pr.base.ref, + ref: context.payload.repository.default_branch, }); if (Array.isArray(data) || data.type !== 'file') return new Set(); return parseAllowlist( From c8c20e09317c704044b57d63d497bcfdce344a89 Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:58:48 +0800 Subject: [PATCH 92/98] fix: remove dead issue gate guard --- .github/workflows/issue-gate.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index 6966fdf69..e19921ecf 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -21,7 +21,6 @@ jobs: const repo = context.repo.repo; const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); - if (issue.pull_request) return; if (privileged.has(issue.author_association)) return; if (issue.user.login === 'github-actions[bot]') return; From dfe188470271ce1ffa509edbda80e48599b9a584 Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:17:33 +0800 Subject: [PATCH 93/98] fix: add contribution gate dry run mode --- .github/APPROVED_CONTRIBUTORS | 3 ++- .github/workflows/approve-contributor.yml | 30 ++++++++++++++++++++- .github/workflows/issue-gate.yml | 21 +++++++++++++-- .github/workflows/pr-gate.yml | 21 +++++++++++++-- CONTRIBUTING.md | 33 ++++++++++++++++------- 5 files changed, 92 insertions(+), 16 deletions(-) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 10eae33fe..23e1a5d45 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -2,9 +2,10 @@ # # Maintainers and collaborators bypass the gate automatically. Use this file # for external contributors who are allowed through the automated front door. +# Seed active contributors here before switching the gate workflows to enforce mode. # # Supported entries: # pr:username # issue:username # all:username -all:Hmbown +all:hmbown diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml index 6c7737510..5ded39657 100644 --- a/.github/workflows/approve-contributor.yml +++ b/.github/workflows/approve-contributor.yml @@ -9,6 +9,10 @@ permissions: issues: write pull-requests: write +concurrency: + group: contribution-gate-approval + cancel-in-progress: false + jobs: approve: runs-on: ubuntu-latest @@ -56,12 +60,14 @@ jobs: const targetLogin = issue.user.login; const normalizedLogin = targetLogin.toLowerCase(); const entry = `${scope}:${normalizedLogin}`; + const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor'; const defaultContent = [ '# Scoped contribution-gate allowlist.', '#', '# Maintainers and collaborators bypass the gate automatically. Use this file', '# for external contributors who are allowed through the automated front door.', + '# Seed active contributors here before switching the gate workflows to enforce mode.', '#', '# Supported entries:', '# pr:username', @@ -119,6 +125,29 @@ jobs: return; } + const { data: openPrs } = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + per_page: 100, + }); + const repoFullName = `${owner}/${repo}`.toLowerCase(); + const pendingPr = openPrs.find(openPr => { + const sameRepo = (openPr.head?.repo?.full_name || '').toLowerCase() === repoFullName; + const body = openPr.body || ''; + return sameRepo && body.includes(`Adds \`${entry}\` to \`${path}\`.`); + }); + + if (pendingPr) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `@${targetLogin} already has a pending allowlist update PR for ${scope} contributions: ${pendingPr.html_url}`, + }); + return; + } + const nextContent = `${content.trimEnd()}\n${entry}\n`; const { data: blob } = await github.rest.git.createBlob({ owner, @@ -140,7 +169,6 @@ jobs: ], }); - const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor'; const branchName = `contribution-gate/${scope}-${branchSlug}-${Date.now()}`; await github.rest.git.createRef({ owner, diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index e19921ecf..70bab864b 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -8,11 +8,16 @@ permissions: contents: read issues: write +env: + # Keep new gates observable first. Switch to "enforce" only after maintainers + # have seeded active contributors and reviewed the dry-run signal. + CONTRIBUTION_GATE_MODE: dry-run + jobs: gate: runs-on: ubuntu-latest steps: - - name: Close unapproved external issues + - name: Gate unapproved external issues uses: actions/github-script@v7 with: script: | @@ -20,6 +25,12 @@ jobs: const owner = context.repo.owner; const repo = context.repo.repo; const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase(); + const enforceGate = gateMode === 'enforce'; + + if (!['dry-run', 'enforce'].includes(gateMode)) { + core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`); + } if (privileged.has(issue.author_association)) return; if (issue.user.login === 'github-actions[bot]') return; @@ -60,6 +71,10 @@ jobs: return; } + const gateMessage = enforceGate + ? 'This repository currently uses a maintainer-managed contribution gate, so issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.' + : 'This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this issue is staying open. When enforcement is enabled, issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` will be closed automatically.'; + await github.rest.issues.createComment({ owner, repo, @@ -67,12 +82,14 @@ jobs: body: [ `Thanks @${issue.user.login} for the report.`, '', - 'This repository currently uses a maintainer-managed contribution gate, so issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.', + gateMessage, '', 'Please read `CONTRIBUTING.md` for the expected issue shape. A maintainer can grant issue access by commenting `/lgtmi` on an issue.', ].join('\n'), }); + if (!enforceGate) return; + await github.rest.issues.update({ owner, repo, diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 23fe1f957..3e4052dbd 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -9,11 +9,16 @@ permissions: issues: write pull-requests: write +env: + # Keep new gates observable first. Switch to "enforce" only after maintainers + # have seeded active contributors and reviewed the dry-run signal. + CONTRIBUTION_GATE_MODE: dry-run + jobs: gate: runs-on: ubuntu-latest steps: - - name: Close unapproved external pull requests + - name: Gate unapproved external pull requests uses: actions/github-script@v7 with: script: | @@ -21,6 +26,12 @@ jobs: const owner = context.repo.owner; const repo = context.repo.repo; const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase(); + const enforceGate = gateMode === 'enforce'; + + if (!['dry-run', 'enforce'].includes(gateMode)) { + core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`); + } if (privileged.has(pr.author_association)) return; if (pr.user.login === 'github-actions[bot]') return; @@ -61,6 +72,10 @@ jobs: return; } + const gateMessage = enforceGate + ? 'This repository currently uses a maintainer-managed contribution gate, so pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.' + : 'This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this pull request is staying open. When enforcement is enabled, pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` will be closed automatically.'; + await github.rest.issues.createComment({ owner, repo, @@ -68,12 +83,14 @@ jobs: body: [ `Thanks @${pr.user.login} for taking the time to contribute.`, '', - 'This repository currently uses a maintainer-managed contribution gate, so pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.', + gateMessage, '', 'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant PR access by commenting `/lgtm` on a pull request.', ].join('\n'), }); + if (!enforceGate) return; + await github.rest.pulls.update({ owner, repo, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75dec7311..7ed555b9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -170,9 +170,18 @@ Validation: ## Contribution Gate CodeWhale uses a maintainer-managed contribution gate for the community front -door. Maintainers and collaborators bypass this gate automatically. External -contributors must be listed in `.github/APPROVED_CONTRIBUTORS` before their -issues or pull requests remain open. +door. Maintainers and collaborators bypass this gate automatically. The gate +workflows default to dry-run / comment-only mode so maintainers can observe the +signal before closing contributor work. In dry-run mode, unapproved external +issues and pull requests receive a short thank-you / CONTRIBUTING pointer and +remain open. + +When maintainers are ready to enforce the gate, set +`CONTRIBUTION_GATE_MODE: enforce` in the PR and issue gate workflows. In enforce +mode, external contributors must be listed in +`.github/APPROVED_CONTRIBUTORS` before their issues or pull requests remain +open. Before enabling enforcement, seed the allowlist broadly enough for active +external contributors who should not be interrupted by the rollout. The allowlist is scoped: @@ -180,17 +189,21 @@ The allowlist is scoped: - `issue:username` allows issues. - `all:username` allows both. -When an unapproved external contributor opens an issue or pull request, the -matching gate workflow leaves a short thank-you / CONTRIBUTING pointer and -closes it. A maintainer can approve someone by commenting `/lgtm` on a pull -request for PR access, or `/lgtmi` on an issue for issue access. The exact bare -commands `lgtm` and `lgtmi` are also accepted for compatibility, but the -prefixed forms are preferred because they are harder to trigger accidentally in -ordinary review discussion. +A maintainer can approve someone by commenting `/lgtm` on a pull request for PR +access, or `/lgtmi` on an issue for issue access. The exact bare commands +`lgtm` and `lgtmi` are also accepted for compatibility, but the prefixed forms +are preferred because they are harder to trigger accidentally in ordinary review +discussion. Approvals do not edit `main` directly. The approval workflow opens a small allowlist update PR so the new entry is reviewable before it takes effect. +If the gate fires on a good contributor incorrectly, use the same approval flow +to restore them: comment `/lgtm` or `/lgtmi`, merge the generated allowlist PR, +then reopen the affected issue or pull request. If GitHub will not allow the +closed item to be reopened, ask the contributor to resubmit after the allowlist +PR is merged. + ## Agent-Assisted Improvements CodeWhale is allowed to help improve CodeWhale, but the contribution still has From ea7fc474a9668d135a498502452b6ecfdf125f25 Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:26:55 +0800 Subject: [PATCH 94/98] fix: paginate pending allowlist PR lookup --- .github/workflows/approve-contributor.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml index 5ded39657..bdd54e026 100644 --- a/.github/workflows/approve-contributor.yml +++ b/.github/workflows/approve-contributor.yml @@ -125,12 +125,18 @@ jobs: return; } - const { data: openPrs } = await github.rest.pulls.list({ - owner, - repo, - state: 'open', - per_page: 100, - }); + const openPrs = []; + for (let page = 1; ; page++) { + const { data: pagePrs } = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + per_page: 100, + page, + }); + openPrs.push(...pagePrs); + if (pagePrs.length < 100) break; + } const repoFullName = `${owner}/${repo}`.toLowerCase(); const pendingPr = openPrs.find(openPr => { const sameRepo = (openPr.head?.repo?.full_name || '').toLowerCase() === repoFullName; From b8194238d5fdf9ac735b0e93aa03c0ea5d9867c5 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 21:30:28 -0700 Subject: [PATCH 95/98] docs(changelog): credit i18n and CI workflow harvests (#2568, #2566, #2565) --- CHANGELOG.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9e83ac53..ea60aa85e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a plan-mode byte-stability invariant test verifying that the tool catalog head remains byte-identical across mode toggles, harvested from #2519. +- Localized all 15 `/queue` command messages across 7 shipped locales, + harvested from #2568. +- Added localized `FanoutCounts` MessageId for i18n of the aggregate worker + stats line in fanout cards, harvested from #2566. +- Added contribution gate CI workflows (PR gate, issue gate, contributor + approval) with a dry-run mode, harvested from #2565. ### Changed @@ -60,8 +66,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, #2562, #2563, #2564), **@HUQIANTAO** (#2527, #2519, #2503), **@lucaszhu-hue** -(#2569), **@idling11** (#2573), **@encyc** (#2514), and **@xyuai** (#2560) for -the work harvested into this release pass. Thanks +(#2569), **@idling11** (#2573), **@encyc** (#2514), **@xyuai** (#2560), +**@gordonlu** (#2568, #2566), and **@nightt5879** (#2565) for the work +harvested into this release pass. Thanks also to issue reporters and verification helpers including **@New2Niu** (#2561), **@buko** (#2533, #2369), **@wywsoor** (#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), **@caiyilian** (#2567), and **@chinaqy110** (#2571) for From 471a58ff08c6241da8742bfdc38e4c641c8714f6 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 21:31:04 -0700 Subject: [PATCH 96/98] chore(release): sync tui CHANGELOG after i18n/CI harvests --- crates/tui/CHANGELOG.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index c9e83ac53..ea60aa85e 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -32,6 +32,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a plan-mode byte-stability invariant test verifying that the tool catalog head remains byte-identical across mode toggles, harvested from #2519. +- Localized all 15 `/queue` command messages across 7 shipped locales, + harvested from #2568. +- Added localized `FanoutCounts` MessageId for i18n of the aggregate worker + stats line in fanout cards, harvested from #2566. +- Added contribution gate CI workflows (PR gate, issue gate, contributor + approval) with a dry-run mode, harvested from #2565. ### Changed @@ -60,8 +66,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, #2562, #2563, #2564), **@HUQIANTAO** (#2527, #2519, #2503), **@lucaszhu-hue** -(#2569), **@idling11** (#2573), **@encyc** (#2514), and **@xyuai** (#2560) for -the work harvested into this release pass. Thanks +(#2569), **@idling11** (#2573), **@encyc** (#2514), **@xyuai** (#2560), +**@gordonlu** (#2568, #2566), and **@nightt5879** (#2565) for the work +harvested into this release pass. Thanks also to issue reporters and verification helpers including **@New2Niu** (#2561), **@buko** (#2533, #2369), **@wywsoor** (#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), **@caiyilian** (#2567), and **@chinaqy110** (#2571) for From 9a3c545572a86ddad54fe7ffc35627d4c1d66d52 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 21:34:56 -0700 Subject: [PATCH 97/98] fix(tui): move Paste to first position in right-click context menu Users copying text from the output area and right-clicking in the composer expect Paste to be the first, most accessible action. Previously Paste appeared after all cell-specific actions (Open Details, Copy Message, etc.), requiring extra mouse travel or keyboard navigation. Reported by a WeChat/Chinese UX user during the v0.8.50 triage pass. --- crates/tui/src/tui/mouse_ui.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index d4a9b235c..1d0feca02 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -524,6 +524,14 @@ pub(crate) fn open_context_menu(app: &mut App, mouse: MouseEvent) { pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec { let mut entries = Vec::new(); + // Paste first — the most common action when right-clicking in the + // composer after copying text from the output area. + entries.push(ContextMenuEntry { + label: app.tr(MessageId::CtxMenuPaste).to_string(), + description: app.tr(MessageId::CtxMenuPasteDesc).to_string(), + action: ContextMenuAction::Paste, + }); + if selection_has_content(app) { entries.push(ContextMenuEntry { label: app.tr(MessageId::CtxMenuCopySelection).to_string(), @@ -597,11 +605,6 @@ pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec Date: Mon, 1 Jun 2026 21:35:11 -0700 Subject: [PATCH 98/98] docs(changelog): note paste-first context menu fix --- CHANGELOG.md | 3 +++ crates/tui/CHANGELOG.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea60aa85e..be0dba4f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `balance` so it is less likely to be mistaken for session spend. - Fixed truncated subagent tool calls and repeated truncated subagent responses so they return model-visible errors instead of silently failing. +- Moved Paste to the first position in the right-click context menu so users + copying text from the output area can paste with a single left-click instead + of navigating past cell-specific actions. ### Community diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index ea60aa85e..be0dba4f9 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -61,6 +61,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `balance` so it is less likely to be mistaken for session spend. - Fixed truncated subagent tool calls and repeated truncated subagent responses so they return model-visible errors instead of silently failing. +- Moved Paste to the first position in the right-click context menu so users + copying text from the output area can paste with a single left-click instead + of navigating past cell-specific actions. ### Community