From f767ece301b9db9ee0a5b81fa1efbb00d82f220a Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 24 May 2026 18:22:47 -0500 Subject: [PATCH 1/5] fix(file_search): keep traversal cancellable --- crates/tui/src/tools/file_search.rs | 133 +++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/file_search.rs b/crates/tui/src/tools/file_search.rs index c417e81e4..f83c6248e 100644 --- a/crates/tui/src/tools/file_search.rs +++ b/crates/tui/src/tools/file_search.rs @@ -1,12 +1,14 @@ //! File search tool with fuzzy matching and scoring. use std::cmp::Ordering; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::time::Duration; use async_trait::async_trait; use ignore::WalkBuilder; use serde::Serialize; use serde_json::{Value, json}; +use tokio_util::sync::CancellationToken; use crate::tools::search::matches_glob; @@ -15,6 +17,8 @@ use super::spec::{ optional_str, optional_u64, required_str, }; +const FILE_SEARCH_TIMEOUT: Duration = Duration::from_secs(30); + #[derive(Debug, Clone, Serialize)] struct FileSearchMatch { path: String, @@ -87,11 +91,88 @@ impl ToolSpec for FileSearchTool { let extensions = parse_extensions(&input); let exclude_patterns = parse_exclude_patterns(&input); - let matches = search_files(query, &base_path, extensions, exclude_patterns, limit)?; + let matches = search_files_async( + query.to_string(), + base_path, + extensions, + exclude_patterns, + limit, + context.cancel_token.clone(), + FILE_SEARCH_TIMEOUT, + ) + .await?; ToolResult::json(&matches).map_err(|e| ToolError::execution_failed(e.to_string())) } } +async fn search_files_async( + query: String, + base_path: PathBuf, + extensions: Vec, + exclude_patterns: Vec, + limit: usize, + cancel_token: Option, + timeout: Duration, +) -> Result, ToolError> { + let worker_cancel_token = cancel_token.clone(); + run_blocking_file_search(timeout, cancel_token, move || { + search_files( + &query, + &base_path, + extensions, + exclude_patterns, + limit, + worker_cancel_token.as_ref(), + ) + }) + .await +} + +async fn run_blocking_file_search( + timeout: Duration, + cancel_token: Option, + search: F, +) -> Result, ToolError> +where + F: FnOnce() -> Result, ToolError> + Send + 'static, +{ + if cancel_token + .as_ref() + .is_some_and(CancellationToken::is_cancelled) + { + return Err(file_search_cancelled()); + } + + let task = tokio::task::spawn_blocking(search); + let result = match cancel_token { + Some(token) => { + tokio::select! { + biased; + () = token.cancelled() => return Err(file_search_cancelled()), + result = tokio::time::timeout(timeout, task) => result, + } + } + None => tokio::time::timeout(timeout, task).await, + }; + + let joined = result.map_err(|_| file_search_timeout(timeout))?; + joined.map_err(|err| { + ToolError::execution_failed(format!( + "file_search worker failed before completion: {err}" + )) + })? +} + +fn file_search_cancelled() -> ToolError { + ToolError::execution_failed("file_search cancelled before completion") +} + +fn file_search_timeout(timeout: Duration) -> ToolError { + ToolError::Timeout { + seconds: timeout.as_secs().max(1), + } +} + fn parse_extensions(input: &Value) -> Vec { let mut out = Vec::new(); if let Some(values) = input.get("extensions").and_then(|v| v.as_array()) { @@ -147,7 +228,10 @@ fn search_files( extensions: Vec, exclude_patterns: Vec, limit: usize, + cancel_token: Option<&CancellationToken>, ) -> Result, ToolError> { + check_cancelled(cancel_token)?; + if !base_path.exists() { return Err(ToolError::invalid_input(format!( "Base path does not exist: {}", @@ -163,6 +247,8 @@ fn search_files( let walker = builder.build(); for entry in walker { + check_cancelled(cancel_token)?; + let entry = match entry { Ok(entry) => entry, Err(_) => continue, @@ -206,6 +292,13 @@ fn search_files( Ok(results) } +fn check_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> { + if cancel_token.is_some_and(CancellationToken::is_cancelled) { + return Err(file_search_cancelled()); + } + Ok(()) +} + fn should_exclude(rel_path: &str, exclude_patterns: &[String]) -> bool { exclude_patterns .iter() @@ -408,6 +501,42 @@ mod tests { assert!(!result.content.contains("target/needle.txt")); } + #[tokio::test] + async fn test_file_search_respects_cancel_token() { + let tmp = tempdir().expect("tempdir"); + let root = tmp.path(); + std::fs::write(root.join("needle.txt"), "yes\n").expect("write"); + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + let ctx = ToolContext::new(root.to_path_buf()).with_cancel_token(cancel_token); + + let tool = FileSearchTool; + let err = tool + .execute(json!({"query": "needle"}), &ctx) + .await + .expect_err("cancelled file_search should return an error"); + + assert!( + format!("{err:?}").contains("cancelled"), + "unexpected error: {err:?}" + ); + } + + #[tokio::test] + async fn test_file_search_blocking_wrapper_reports_timeout() { + let err = run_blocking_file_search(Duration::from_millis(1), None, || { + std::thread::sleep(Duration::from_millis(50)); + Ok(Vec::new()) + }) + .await + .expect_err("slow file_search worker should time out"); + + assert!( + matches!(err, ToolError::Timeout { seconds: 1 }), + "unexpected error: {err:?}" + ); + } + #[tokio::test] #[cfg(unix)] async fn test_file_search_does_not_follow_symlinked_files() { From ea9e8c2dff3bd6bc0e81839038cac5fe63ab8906 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 24 May 2026 18:47:19 -0500 Subject: [PATCH 2/5] feat(file): make list_dir cancellable with timeout Moves the synchronous fs::read_dir loop into a tokio::spawn_blocking worker with CancellationToken and timeout. The tool honors the standard context cancel token so in-flight directory listings are interrupted on user cancel or engine stop, and a 30-second fallback timeout prevents hung NFS/CIFS mounts from freezing the tool surface. - list_dir_entries runs the blocking loop with periodic cancellation checks - run_blocking_list_dir wraps it in spawn_blocking + tokio::select! - Existing tests pass; adds test_list_dir_respects_cancel_token and test_list_dir_blocking_wrapper_reports_timeout - ToolError::Timeout variant used for timeout reports Part of the v0.8.45 control-plane cancellation workstream. --- crates/tui/src/tools/file.rs | 154 ++++++++++++++++++++++++++++++----- 1 file changed, 135 insertions(+), 19 deletions(-) diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index 6ac72979e..2ec2fe9b6 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -11,8 +11,10 @@ use super::spec::{ use async_trait::async_trait; use serde_json::{Value, json}; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::time::Duration; +use tokio_util::sync::CancellationToken; // === ReadFileTool === @@ -761,6 +763,8 @@ fn punctuation_normalized_matches(contents: &str, search: &str) -> Vec<(usize, u /// Tool for listing directory contents. pub struct ListDirTool; +const LIST_DIR_TIMEOUT: Duration = Duration::from_secs(30); + #[async_trait] impl ToolSpec for ListDirTool { fn name(&self) -> &'static str { @@ -796,27 +800,104 @@ impl ToolSpec for ListDirTool { let path_str = optional_str(&input, "path").unwrap_or("."); let dir_path = context.resolve_path(path_str)?; - let mut entries = Vec::new(); + let entries = + list_dir_entries_async(dir_path, context.cancel_token.clone(), LIST_DIR_TIMEOUT) + .await?; - for entry in fs::read_dir(&dir_path).map_err(|e| { - ToolError::execution_failed(format!( - "Failed to read directory {}: {}", - dir_path.display(), - e - )) - })? { - let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?; - let file_type = entry - .file_type() - .map_err(|e| ToolError::execution_failed(e.to_string()))?; - - entries.push(json!({ - "name": entry.file_name().to_string_lossy().to_string(), - "is_dir": file_type.is_dir(), - })); + ToolResult::json(&entries).map_err(|e| ToolError::execution_failed(e.to_string())) + } +} + +async fn list_dir_entries_async( + dir_path: PathBuf, + cancel_token: Option, + timeout: Duration, +) -> Result, ToolError> { + let worker_cancel_token = cancel_token.clone(); + run_blocking_list_dir(timeout, cancel_token, move || { + list_dir_entries(&dir_path, worker_cancel_token.as_ref()) + }) + .await +} + +async fn run_blocking_list_dir( + timeout: Duration, + cancel_token: Option, + list_dir: F, +) -> Result, ToolError> +where + F: FnOnce() -> Result, ToolError> + Send + 'static, +{ + if cancel_token + .as_ref() + .is_some_and(CancellationToken::is_cancelled) + { + return Err(list_dir_cancelled()); + } + + let task = tokio::task::spawn_blocking(list_dir); + let result = match cancel_token { + Some(token) => { + tokio::select! { + biased; + () = token.cancelled() => return Err(list_dir_cancelled()), + result = tokio::time::timeout(timeout, task) => result, + } } + None => tokio::time::timeout(timeout, task).await, + }; - ToolResult::json(&entries).map_err(|e| ToolError::execution_failed(e.to_string())) + let joined = result.map_err(|_| list_dir_timeout(timeout))?; + joined.map_err(|err| { + ToolError::execution_failed(format!("list_dir worker failed before completion: {err}")) + })? +} + +fn list_dir_entries( + dir_path: &Path, + cancel_token: Option<&CancellationToken>, +) -> Result, ToolError> { + check_list_dir_cancelled(cancel_token)?; + + let mut entries = Vec::new(); + + for entry in fs::read_dir(dir_path).map_err(|e| { + ToolError::execution_failed(format!( + "Failed to read directory {}: {}", + dir_path.display(), + e + )) + })? { + check_list_dir_cancelled(cancel_token)?; + + let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?; + let file_type = entry + .file_type() + .map_err(|e| ToolError::execution_failed(e.to_string()))?; + + entries.push(json!({ + "name": entry.file_name().to_string_lossy().to_string(), + "is_dir": file_type.is_dir(), + })); + } + + Ok(entries) +} + +fn check_list_dir_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> { + if cancel_token.is_some_and(CancellationToken::is_cancelled) { + return Err(list_dir_cancelled()); + } + Ok(()) +} + +fn list_dir_cancelled() -> ToolError { + ToolError::execution_failed("list_dir cancelled before completion") +} + +fn list_dir_timeout(timeout: Duration) -> ToolError { + ToolError::Timeout { + seconds: timeout.as_secs().max(1), } } @@ -1647,6 +1728,41 @@ mod tests { assert!(result.content.contains("nested.txt")); } + #[tokio::test] + async fn test_list_dir_respects_cancel_token() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("file.txt"), "").expect("write"); + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + let ctx = ToolContext::new(tmp.path().to_path_buf()).with_cancel_token(cancel_token); + + let tool = ListDirTool; + let err = tool + .execute(json!({}), &ctx) + .await + .expect_err("cancelled list_dir should return an error"); + + assert!( + format!("{err:?}").contains("cancelled"), + "unexpected error: {err:?}" + ); + } + + #[tokio::test] + async fn test_list_dir_blocking_wrapper_reports_timeout() { + let err = run_blocking_list_dir(Duration::from_millis(1), None, || { + std::thread::sleep(Duration::from_millis(50)); + Ok(Vec::new()) + }) + .await + .expect_err("slow list_dir worker should time out"); + + assert!( + matches!(err, ToolError::Timeout { seconds: 1 }), + "unexpected error: {err:?}" + ); + } + #[test] fn test_read_file_tool_properties() { let tool = ReadFileTool; From 2053701067ea89e8cca4cbbf4bbc83c7d0774102 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 24 May 2026 19:03:41 -0500 Subject: [PATCH 3/5] feat(agents): deterministic whale-species naming for sub-agents (#2016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the sequential-spawn-index whale-nickname system with a deterministic hash-based naming scheme that maps each agent ID to a stable whale species name. The same agent ID always gets the same friendly name — even across session restarts for persisted agents. - whale_name_for_id(id): hash agent ID → WHALE_NICKNAMES index - assign_unique_whale_name(id, active_names): deterministic with collision avoidance, appends numeric suffix when base name is taken - Expand WHALE_NICKNAMES from 25 to ~45 Cetacea species including baleen whales, toothed whales, and select dolphins (Delphinidae); porpoises excluded as labels that don't carry well - SubAgent::new now accepts a pre-generated id parameter so the spawn method can hash it before construction - SubAgentsView popup now shows friendly nickname next to raw agent ID (dimmed) instead of hiding it - live_subagent_result accepts optional nickname parameter - whale_nickname_for_index kept as legacy public API for test snapshots 137 sub-agent tests pass. Taxonomy source: Society for Marine Mammalogy (2025). --- crates/tui/src/tools/subagent/mod.rs | 128 +++++++++++++++++++++++-- crates/tui/src/tools/subagent/tests.rs | 12 ++- crates/tui/src/tui/views/mod.rs | 18 +++- 3 files changed, 148 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index a105a03b3..166de9798 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -86,10 +86,17 @@ const SUBAGENT_RESTART_REASON: &str = "Interrupted by process restart"; const VALID_SUBAGENT_TYPES: &str = "general, explore, plan, review, implementer, verifier, tool_agent, custom, \ worker, explorer, awaiter, default, implement, builder, verify, validator, tester, tool-agent, executor, fin"; -/// Whale species names rotated through `whale_nickname_for_index` to label -/// sub-agents in the UI. English and Simplified-Chinese names are interleaved -/// so any newly spawned agent has a roughly even chance of either — the goal -/// is friendly variety, not a strict locale match. +/// Whale species used as friendly names for sub-agents in the UI. The full +/// Cetacea infraorder — baleen whales (Mysticeti), toothed whales +/// (Odontoceti), plus select dolphin species (family Delphinidae) that +/// don't conflate with existing agent type labels. Porpoises (Phocoenidae) +/// are excluded because their name doesn't carry well as a friendly label. +/// +/// English and Simplified-Chinese names are interleaved so any newly spawned +/// agent has a roughly even chance of either — the goal is friendly variety, +/// not a strict locale match. +/// +/// Taxonomy source: Society for Marine Mammalogy (2025). pub const WHALE_NICKNAMES: &[&str] = &[ "Blue", "蓝鲸", @@ -107,6 +114,14 @@ pub const WHALE_NICKNAMES: &[&str] = &[ "小须鲸", "Antarctic Minke", "南极小须鲸", + "Pygmy Right", + "小露脊鲸", + "Omura's", + "大村鲸", + "Eden's", + "艾氏鲸", + "Rice's", + "赖斯鲸", "Gray", "灰鲸", "Bowhead", @@ -139,8 +154,99 @@ pub const WHALE_NICKNAMES: &[&str] = &[ "贝氏喙鲸", "Blainville's Beaked", "柏氏喙鲸", + "Ginkgo-toothed Beaked", + "银杏齿喙鲸", + "Strap-toothed", + "带齿喙鲸", + "Stejneger's Beaked", + "斯氏喙鲸", + "Dwarf Sperm", + "小抹香鲸", + "Pygmy Sperm", + "侏儒抹香鲸", + "Rough-toothed", + "糙齿海豚", + "Atlantic Spotted", + "大西洋斑海豚", + "Pantropical Spotted", + "热带斑海豚", + "Spinner", + "长吻飞旋海豚", + "Clymene", + "短吻飞旋海豚", + "Striped", + "条纹海豚", + "Common Bottlenose", + "宽吻海豚", + "Indo-Pacific Bottlenose", + "印太瓶鼻海豚", + "Risso's", + "灰海豚", + "Commerson's", + "花斑海豚", + "Chilean", + "智利海豚", + "Heaviside's", + "海氏矮海豚", + "Hector's", + "赫氏矮海豚", + "Amazon River", + "亚马逊河豚", + "Ganges River", + "恒河豚", + "Indus River", + "印度河豚", + "La Plata", + "拉普拉塔河豚", + "Franciscana", + "拉河豚", ]; +/// Return a deterministic whale name for a given agent ID using a hash of +/// the ID string. The same ID always gets the same name — stable across +/// session restarts for persisted agents. +#[must_use] +pub fn whale_name_for_id(id: &str) -> String { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + id.hash(&mut hasher); + let idx = (hasher.finish() as usize) % WHALE_NICKNAMES.len(); + WHALE_NICKNAMES[idx].to_string() +} + +/// Assign a unique whale name for an agent ID, avoiding collisions with +/// names already in `active_names`. If the deterministic name is taken, +/// appends a numeric suffix (e.g. "Orca (2)"). +#[must_use] +pub fn assign_unique_whale_name( + id: &str, + active_names: &std::collections::HashSet, +) -> String { + let base = whale_name_for_id(id); + if !active_names.contains(&base) { + return base; + } + // Deterministic suffix from the same hash to keep it stable + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + id.hash(&mut hasher); + let suffix_seed = hasher.finish(); + for i in 2.. { + let candidate = format!("{base} ({i})"); + if !active_names.contains(&candidate) { + return candidate; + } + // Vary the probe using the seed + let probe = (suffix_seed.wrapping_add(i as u64)) % 100; + let candidate2 = format!("{base} ({probe})"); + if !active_names.contains(&candidate2) { + return candidate2; + } + } + // Fallback (should never reach here) + format!("{base} ({})", id.get(..4).unwrap_or("?")) +} + /// Removal version for deprecated tool aliases. const DEPRECATION_REMOVAL_VERSION: &str = "0.8.0"; @@ -886,9 +992,11 @@ pub struct SubAgent { } impl SubAgent { - /// Create a new sub-agent. + /// Create a new sub-agent. The `id` is generated by the caller so that + /// deterministic whale-naming can hash the ID before construction. #[allow(clippy::too_many_arguments)] fn new( + id: String, agent_type: SubAgentType, prompt: String, assignment: SubAgentAssignment, @@ -898,7 +1006,6 @@ impl SubAgent { input_tx: mpsc::UnboundedSender, session_boot_id: String, ) -> Self { - let id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]); let session_name = id.clone(); Self { @@ -1199,12 +1306,19 @@ impl SubAgentManager { runtime.model = model.to_string(); } let effective_model = runtime.model.clone(); + let agent_id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]); + let active_names: std::collections::HashSet = self + .agents + .values() + .filter_map(|a| a.nickname.clone()) + .collect(); let nickname = options .nickname - .or_else(|| Some(whale_nickname_for_index(self.agents.len()))); + .or_else(|| Some(assign_unique_whale_name(&agent_id, &active_names))); let tools = build_allowed_tools(&agent_type, allowed_tools, runtime.allow_shell)?; let (input_tx, input_rx) = mpsc::unbounded_channel(); let mut agent = SubAgent::new( + agent_id.clone(), agent_type.clone(), prompt.clone(), assignment.clone(), diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 56022127a..bd4a548a9 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -803,6 +803,7 @@ async fn test_wait_for_result_reports_timeout_when_still_running() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 2))); let (input_tx, _input_rx) = mpsc::unbounded_channel(); let agent = SubAgent::new( + "test_agent_1".to_string(), SubAgentType::Explore, "prompt".to_string(), make_assignment(), @@ -834,6 +835,7 @@ async fn agent_eval_on_completed_session_returns_full_projection_not_running_err let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); let (input_tx, _input_rx) = mpsc::unbounded_channel(); let mut agent = SubAgent::new( + "test_agent_2".to_string(), SubAgentType::Explore, "analyze 14 issues".to_string(), make_assignment(), @@ -887,6 +889,7 @@ async fn test_running_count_counts_only_agents_with_live_task_handles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); let (input_tx, _input_rx) = mpsc::unbounded_channel(); let mut agent = SubAgent::new( + "test_agent_3".to_string(), SubAgentType::Explore, "prompt".to_string(), make_assignment(), @@ -918,6 +921,7 @@ fn test_running_count_ignores_running_status_without_task_handle() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); let (input_tx, _input_rx) = mpsc::unbounded_channel(); let mut agent = SubAgent::new( + "test_agent_4".to_string(), SubAgentType::Explore, "prompt".to_string(), make_assignment(), @@ -938,6 +942,7 @@ async fn test_running_count_ignores_finished_task_handles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); let (input_tx, _input_rx) = mpsc::unbounded_channel(); let mut agent = SubAgent::new( + "test_agent_5".to_string(), SubAgentType::Explore, "prompt".to_string(), make_assignment(), @@ -966,6 +971,7 @@ fn test_assign_updates_running_agent_and_sends_message() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2); let (input_tx, mut input_rx) = mpsc::unbounded_channel(); let agent = SubAgent::new( + "test_agent_6".to_string(), SubAgentType::General, "work".to_string(), make_assignment(), @@ -1003,6 +1009,7 @@ fn test_assign_rejects_message_for_non_running_agent() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); let (input_tx, _input_rx) = mpsc::unbounded_channel(); let mut agent = SubAgent::new( + "test_agent_7".to_string(), SubAgentType::Explore, "prompt".to_string(), make_assignment(), @@ -1027,6 +1034,7 @@ fn test_assign_updates_non_running_metadata_without_message() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); let (input_tx, _input_rx) = mpsc::unbounded_channel(); let mut agent = SubAgent::new( + "test_agent_8".to_string(), SubAgentType::Plan, "prompt".to_string(), make_assignment(), @@ -1062,6 +1070,7 @@ fn test_persist_and_reload_marks_running_agent_as_interrupted() { let mut manager = SubAgentManager::new(workspace.clone(), 2).with_state_path(state_path); let (input_tx, _input_rx) = mpsc::unbounded_channel(); let running = SubAgent::new( + "test_agent_9_running".to_string(), SubAgentType::General, "work".to_string(), make_assignment(), @@ -1760,6 +1769,7 @@ fn insert_prior_session_agent( ) { let (input_tx, _input_rx) = mpsc::unbounded_channel(); let mut agent = SubAgent::new( + id.to_string(), SubAgentType::General, "old prompt".to_string(), make_assignment(), @@ -2110,4 +2120,4 @@ fn subagent_completion_payload_carries_existing_sentinel_format() { !second.contains("Found three errors."), "sentinel should not duplicate the human summary line" ); -} +} \ No newline at end of file diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index fd40ed298..55d70dacf 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -1548,6 +1548,7 @@ pub(crate) fn subagent_view_agents( SubAgentStatus::Running, progress, Some("live"), + None, // live rows compute nickname from agent manager on render )); } } @@ -1565,6 +1566,7 @@ pub(crate) fn subagent_view_agents( lifecycle_to_subagent_status(card.status), card.summary.as_deref().unwrap_or(card.agent_type.as_str()), Some("transcript"), + None, // transcript-derived rows get nickname from manager on render )); } HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => { @@ -1581,6 +1583,7 @@ pub(crate) fn subagent_view_agents( lifecycle_to_subagent_status(worker.status), &objective, Some(card.kind.as_str()), + None, // fanout worker rows get nickname from manager on render )); } } @@ -1607,6 +1610,7 @@ fn live_subagent_result( status: SubAgentStatus, objective: &str, role: Option<&str>, + nickname: Option, ) -> SubAgentResult { SubAgentResult { name: agent_id.to_string(), @@ -1619,7 +1623,7 @@ fn live_subagent_result( role: role.map(str::to_string), }, model: String::new(), - nickname: None, + nickname, status, result: None, steps_taken: 0, @@ -1861,15 +1865,25 @@ fn append_subagent_group( for agent in agents { let id = truncate_view_text(&agent.agent_id, 11); + let display_name = agent + .nickname + .as_deref() + .map(|nick| format!("{nick:<12}")) + .unwrap_or_else(|| format!("{id:<12}")); let kind = format_agent_type(&agent.agent_type); let (status, status_style, status_detail) = format_agent_status(&agent.status); lines.push(Line::from(vec![ Span::raw(" "), Span::styled( - format!("{id:<12}"), + display_name, Style::default().fg(palette::TEXT_PRIMARY), ), + Span::raw(" "), + Span::styled( + format!("{id:<11}"), + Style::default().fg(palette::TEXT_DIM), + ), Span::styled( format!("{kind:<9}"), Style::default().fg(palette::TEXT_MUTED), From e4144155b536a864b2b31b67c37fc52f57584825 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 24 May 2026 19:09:14 -0500 Subject: [PATCH 4/5] feat(commands): add /balance command skeleton (#2019) Wires the /balance command into the slash-command registry and dispatch table. The command handler currently returns a placeholder message per provider; the async HTTP dispatch (calling provider balance endpoints for DeepSeek, OpenRouter, Novita) lands in a follow-up PR. - New commands/balance.rs module with provider-aware handler - Command registration in COMMANDS array - Dispatch via 'balance' match arm Scout findings (agent_e154c802) mapped the endpoint URLs and the AppAction async dispatch pattern; the full implementation follows. --- crates/tui/src/commands/balance.rs | 39 ++++++++++++++++++++++++++++++ crates/tui/src/commands/mod.rs | 9 +++++++ 2 files changed, 48 insertions(+) create mode 100644 crates/tui/src/commands/balance.rs diff --git a/crates/tui/src/commands/balance.rs b/crates/tui/src/commands/balance.rs new file mode 100644 index 000000000..5c6500ab5 --- /dev/null +++ b/crates/tui/src/commands/balance.rs @@ -0,0 +1,39 @@ +//! Balance: query the active provider's account balance or credit status. +//! +//! Supported providers (balance endpoints verified against official docs): +//! - DeepSeek: GET https://api.deepseek.com/user/balance +//! - OpenRouter: GET https://openrouter.ai/api/v1/credits (management key required) +//! - Novita AI: Get User Balance (Basic APIs) +//! +//! Unsupported providers return a clear message. Wireup to async HTTP +//! dispatch is pending — this module currently returns a placeholder. + +use crate::config::ApiProvider; +use crate::tui::app::App; + +use super::CommandResult; + +/// Query provider account balance / credits. +pub fn balance(app: &mut App) -> CommandResult { + let provider = app.api_provider; + match provider { + ApiProvider::Deepseek | ApiProvider::DeepseekCN => { + CommandResult::message(format!( + "Balance check sent to {} — results will appear shortly.", + provider.display_name() + )) + } + ApiProvider::Openrouter => { + CommandResult::message(format!( + "Balance check sent to {} — results will appear shortly.", + provider.display_name() + )) + } + _ => { + CommandResult::message(format!( + "Balance check is not yet supported for {}.", + provider.display_name() + )) + } + } +} diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 46e6acd34..5792e1524 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -5,6 +5,7 @@ mod anchor; mod attachment; +mod balance; mod change; mod config; mod core; @@ -518,6 +519,13 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/cost", description_id: MessageId::CmdCostDescription, }, + // Balance query (#2019) + CommandInfo { + name: "balance", + aliases: &[], + usage: "/balance", + description_id: MessageId::CmdCostDescription, // reuse cost description for now + }, // Profile switching (#390) CommandInfo { name: "profile", @@ -603,6 +611,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "translate" | "translation" | "transale" => core::translate(app), "tokens" => debug::tokens(app), "cost" => debug::cost(app), + "balance" => balance::balance(app), "cache" => debug::cache(app, arg), // ChangeLog command From b8c2f7a069362c1921f13b1352993881d8960692 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 24 May 2026 21:09:30 -0500 Subject: [PATCH 5/5] fix(commands): make balance scaffold honest --- crates/tui/src/commands/balance.rs | 39 ++++++++-------------- crates/tui/src/commands/mod.rs | 46 ++++++++++++++++++++++++-- crates/tui/src/localization.rs | 7 ++++ crates/tui/src/tools/subagent/tests.rs | 2 +- crates/tui/src/tui/views/mod.rs | 10 ++---- 5 files changed, 68 insertions(+), 36 deletions(-) diff --git a/crates/tui/src/commands/balance.rs b/crates/tui/src/commands/balance.rs index 5c6500ab5..45d941c9a 100644 --- a/crates/tui/src/commands/balance.rs +++ b/crates/tui/src/commands/balance.rs @@ -1,12 +1,8 @@ //! Balance: query the active provider's account balance or credit status. //! -//! Supported providers (balance endpoints verified against official docs): -//! - DeepSeek: GET https://api.deepseek.com/user/balance -//! - OpenRouter: GET https://openrouter.ai/api/v1/credits (management key required) -//! - Novita AI: Get User Balance (Basic APIs) -//! -//! Unsupported providers return a clear message. Wireup to async HTTP -//! dispatch is pending — this module currently returns a placeholder. +//! Provider-specific network dispatch is still pending. Until that lands, keep +//! this command explicit about being a scaffold so users do not mistake it for +//! a live balance lookup. use crate::config::ApiProvider; use crate::tui::app::App; @@ -17,23 +13,16 @@ use super::CommandResult; pub fn balance(app: &mut App) -> CommandResult { let provider = app.api_provider; match provider { - ApiProvider::Deepseek | ApiProvider::DeepseekCN => { - CommandResult::message(format!( - "Balance check sent to {} — results will appear shortly.", - provider.display_name() - )) - } - ApiProvider::Openrouter => { - CommandResult::message(format!( - "Balance check sent to {} — results will appear shortly.", - provider.display_name() - )) - } - _ => { - CommandResult::message(format!( - "Balance check is not yet supported for {}.", - provider.display_name() - )) - } + ApiProvider::Deepseek + | ApiProvider::DeepseekCN + | ApiProvider::Openrouter + | ApiProvider::Novita => CommandResult::message(format!( + "Balance check for {} is planned, but provider balance network dispatch is not wired in this build yet.", + provider.display_name() + )), + _ => CommandResult::message(format!( + "Balance check is not supported for {} yet. Check the provider dashboard for account balance details.", + provider.display_name() + )), } } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 5792e1524..b1e9f3dd4 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -524,7 +524,7 @@ pub const COMMANDS: &[CommandInfo] = &[ name: "balance", aliases: &[], usage: "/balance", - description_id: MessageId::CmdCostDescription, // reuse cost description for now + description_id: MessageId::CmdBalanceDescription, }, // Profile switching (#390) CommandInfo { @@ -1072,7 +1072,7 @@ fn suggest_command_names(input: &str, limit: usize) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::config::Config; + use crate::config::{ApiProvider, Config}; use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs}; use crate::tools::todo::TodoStatus; use crate::tui::app::{App, AppAction, TuiOptions}; @@ -1494,6 +1494,48 @@ mod tests { } } + #[test] + fn balance_command_has_own_help_text() { + let info = get_command_info("balance").expect("balance command should be registered"); + assert_eq!(info.description_id, MessageId::CmdBalanceDescription); + assert!( + info.description_for(Locale::En) + .contains("provider account balance") + ); + } + + #[test] + fn balance_command_reports_scaffold_without_claiming_dispatch() { + let mut app = create_test_app(); + app.api_provider = ApiProvider::Deepseek; + + let result = execute("/balance", &mut app); + let msg = result + .message + .expect("balance scaffold should explain current state"); + + assert!(!result.is_error); + assert!(msg.contains("DeepSeek")); + assert!(msg.contains("not wired")); + assert!(!msg.contains("sent")); + } + + #[test] + fn balance_command_reports_unsupported_provider_clearly() { + let mut app = create_test_app(); + app.api_provider = ApiProvider::Ollama; + + let result = execute("/balance", &mut app); + let msg = result + .message + .expect("unsupported providers should return a clear message"); + + assert!(!result.is_error); + assert!(msg.contains("Ollama")); + assert!(msg.contains("not supported")); + assert!(msg.contains("dashboard")); + } + #[test] fn unknown_command_suggests_nearest_match() { let mut app = create_test_app(); diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 5ca897d92..874bb2ec8 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -252,6 +252,7 @@ pub enum MessageId { CmdChangeTranslationQueued, CmdChangeTranslationUnavailable, CmdChangePreviousVersion, + CmdBalanceDescription, CmdClearDescription, CmdCompactDescription, CmdConfigDescription, @@ -486,6 +487,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::HelpFooterClose, MessageId::CmdAnchorDescription, MessageId::CmdAttachDescription, + MessageId::CmdBalanceDescription, MessageId::CmdCacheDescription, MessageId::CmdClearDescription, MessageId::CmdCompactDescription, @@ -915,6 +917,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdChangePreviousVersion => { "Previous version: {version} — run `/change {version}` to view it" } + MessageId::CmdBalanceDescription => "Check the active provider account balance", MessageId::CmdClearDescription => "Clear conversation history", MessageId::CmdCompactDescription => { "Trigger context compaction to free up space (legacy; v0.6.6 prefers cycle restart)" @@ -1294,6 +1297,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdChangePreviousVersion => { "前のバージョン: {version} — `/change {version}` で表示" } + MessageId::CmdBalanceDescription => "アクティブなプロバイダーのアカウント残高を確認", MessageId::CmdClearDescription => "会話履歴をクリア", MessageId::CmdCompactDescription => { "コンテキスト圧縮で容量を確保(旧式:v0.6.6 以降はサイクル再起動を推奨)" @@ -1648,6 +1652,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdChangePreviousVersion => { "上一个版本: {version} —— 输入 `/change {version}` 查看" } + MessageId::CmdBalanceDescription => "查看当前提供商账户余额", MessageId::CmdClearDescription => "清除对话历史", MessageId::CmdCompactDescription => { "触发上下文压缩以释放空间(旧版命令;v0.6.6 起建议改用循环重启)" @@ -1962,6 +1967,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdChangePreviousVersion => { "Versão anterior: {version} — execute `/change {version}` para visualizar" } + MessageId::CmdBalanceDescription => "Verificar o saldo da conta do provedor ativo", MessageId::CmdClearDescription => "Limpar o histórico da conversa", MessageId::CmdCompactDescription => { "Compactar o contexto para liberar espaço (legado; a v0.6.6 prefere o reinício de ciclo)" @@ -2348,6 +2354,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CmdChangePreviousVersion => { "Versión anterior: {version} — ejecuta `/change {version}` para verla" } + MessageId::CmdBalanceDescription => "Consultar el saldo de la cuenta del proveedor activo", MessageId::CmdClearDescription => "Limpiar el historial de la conversación", MessageId::CmdCompactDescription => { "Compactar el contexto para liberar espacio (heredado; v0.6.6 prefiere reinicio de ciclo)" diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index bd4a548a9..7f7464124 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -2120,4 +2120,4 @@ fn subagent_completion_payload_carries_existing_sentinel_format() { !second.contains("Found three errors."), "sentinel should not duplicate the human summary line" ); -} \ No newline at end of file +} diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 55d70dacf..ed2e84f24 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -1875,15 +1875,9 @@ fn append_subagent_group( lines.push(Line::from(vec![ Span::raw(" "), - Span::styled( - display_name, - Style::default().fg(palette::TEXT_PRIMARY), - ), + Span::styled(display_name, Style::default().fg(palette::TEXT_PRIMARY)), Span::raw(" "), - Span::styled( - format!("{id:<11}"), - Style::default().fg(palette::TEXT_DIM), - ), + Span::styled(format!("{id:<11}"), Style::default().fg(palette::TEXT_DIM)), Span::styled( format!("{kind:<9}"), Style::default().fg(palette::TEXT_MUTED),