From 6d8ab4c2b84a964adca6cf1409e9622a035c17c9 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 28 Apr 2026 23:09:19 -0500 Subject: [PATCH] fix: close v0.7.2 issue cleanup --- README.md | 15 +- config.example.toml | 27 +- crates/tools/src/lib.rs | 11 + crates/tui/src/client.rs | 10 +- crates/tui/src/commands/provider.rs | 32 +- crates/tui/src/config.rs | 87 +- crates/tui/src/tui/mod.rs | 2 + crates/tui/src/tui/subagent_routing.rs | 313 ++++++ crates/tui/src/tui/tool_routing.rs | 922 ++++++++++++++++++ crates/tui/src/tui/ui.rs | 1239 +----------------------- crates/tui/src/tui/ui/tests.rs | 24 + docs/CONFIGURATION.md | 36 +- 12 files changed, 1462 insertions(+), 1256 deletions(-) create mode 100644 crates/tui/src/tui/subagent_routing.rs create mode 100644 crates/tui/src/tui/tool_routing.rs diff --git a/README.md b/README.md index 59a90710..c4d8d2b2 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,16 @@ deepseek --provider nvidia-nim DEEPSEEK_PROVIDER=nvidia-nim NVIDIA_API_KEY="..." deepseek ``` +### Other DeepSeek V4 providers + +```bash +deepseek auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY" +deepseek --provider fireworks --model deepseek-v4-pro + +# SGLang is self-hosted; auth is optional for localhost deployments. +SGLANG_BASE_URL="http://localhost:30000/v1" deepseek --provider sglang --model deepseek-v4-flash +``` +
Install from source @@ -181,9 +191,12 @@ Key environment overrides: | `DEEPSEEK_API_KEY` | API key | | `DEEPSEEK_BASE_URL` | API base URL | | `DEEPSEEK_MODEL` | Default model | -| `DEEPSEEK_PROVIDER` | Provider: `deepseek` (default) or `nvidia-nim` | +| `DEEPSEEK_PROVIDER` | Provider: `deepseek` (default), `nvidia-nim`, `fireworks`, or `sglang` | | `DEEPSEEK_PROFILE` | Config profile name | | `NVIDIA_API_KEY` | NVIDIA NIM API key | +| `FIREWORKS_API_KEY` | Fireworks AI API key | +| `SGLANG_BASE_URL` | Self-hosted SGLang endpoint | +| `SGLANG_API_KEY` | Optional SGLang bearer token | Quick diagnostics: diff --git a/config.example.toml b/config.example.toml index 218bd19d..74e92e19 100644 --- a/config.example.toml +++ b/config.example.toml @@ -10,12 +10,12 @@ # Active provider + DeepSeek defaults # ───────────────────────────────────────────────────────────────────────────────── # Choose which provider to use by default. Per-provider credentials live in the -# `[providers.deepseek]` and `[providers.nvidia_nim]` sections near the bottom of +# `[providers.*]` sections near the bottom of # this file — keeping both stored at once means `/provider deepseek` and -# `/provider nvidia-nim` (or `--provider nvidia-nim`) toggle without having to +# `/provider nvidia-nim` (or `--provider fireworks`, `/provider sglang`) toggle without having to # re-enter keys. Top-level `api_key` / `base_url` are still read as DeepSeek # defaults when `[providers.deepseek]` is absent (backward compatibility). -provider = "deepseek" # deepseek | nvidia-nim +provider = "deepseek" # deepseek | nvidia-nim | openrouter | novita | fireworks | sglang api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty base_url = "https://api.deepseek.com" # base_url = "https://api.deepseeki.com" # China users @@ -29,6 +29,9 @@ base_url = "https://api.deepseek.com" # deepseek-v4-flash — fast, cost-efficient (legacy aliases: deepseek-chat, deepseek-reasoner) # deepseek-ai/deepseek-v4-pro — NVIDIA NIM-hosted Pro model ID # deepseek-ai/deepseek-v4-flash — NVIDIA NIM-hosted Flash model ID +# accounts/fireworks/models/deepseek-v4-pro — Fireworks AI Pro model ID +# deepseek-ai/DeepSeek-V4-Pro — SGLang self-hosted Pro model ID +# deepseek-ai/DeepSeek-V4-Flash — SGLang self-hosted Flash model ID default_text_model = "deepseek-v4-pro" # ───────────────────────────────────────────────────────────────────────────────── @@ -81,12 +84,14 @@ max_subagents = 5 # optional (1-20) # ───────────────────────────────────────────────────────────────────────────────── # Per-provider credentials (peer providers — NIM is first-class, not a flag) # ───────────────────────────────────────────────────────────────────────────────── -# Both providers can be stored at once; `provider = "..."` (top of file) or -# `/provider deepseek` / `/provider nvidia-nim` switches between them without +# Providers can be stored at once; `provider = "..."` (top of file) or +# `/provider deepseek` / `/provider nvidia-nim` / `/provider fireworks` switches between them without # having to re-enter keys. Env vars override anything set here: # DeepSeek: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL # NIM: NVIDIA_API_KEY (or NVIDIA_NIM_API_KEY), NIM_BASE_URL # (or NVIDIA_NIM_BASE_URL / NVIDIA_BASE_URL), NVIDIA_NIM_MODEL +# Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL +# SGLang: SGLANG_BASE_URL, SGLANG_MODEL, optional SGLANG_API_KEY # DeepSeek Platform (https://platform.deepseek.com) [providers.deepseek] @@ -100,6 +105,18 @@ max_subagents = 5 # optional (1-20) # base_url = "https://integrate.api.nvidia.com/v1" # model = "deepseek-ai/deepseek-v4-pro" # or deepseek-ai/deepseek-v4-flash +# Fireworks AI-hosted DeepSeek V4 (https://fireworks.ai) +[providers.fireworks] +# api_key = "YOUR_FIREWORKS_API_KEY" +# base_url = "https://api.fireworks.ai/inference/v1" +# model = "accounts/fireworks/models/deepseek-v4-pro" + +# Self-hosted SGLang OpenAI-compatible server +[providers.sglang] +# api_key = "OPTIONAL_SGLANG_TOKEN" +# base_url = "http://localhost:30000/v1" +# model = "deepseek-ai/DeepSeek-V4-Pro" # or deepseek-ai/DeepSeek-V4-Flash + # ───────────────────────────────────────────────────────────────────────────────── # Network Policy (#135) # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index d2344a49..9fc70b21 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -447,6 +447,17 @@ mod tests { )); } + #[test] + fn required_str_reports_provided_fields_on_missing_required_field() { + let input = json!({"path": "src/lib.rs", "content": "new body"}); + let err = required_str(&input, "replace").expect_err("replace is missing"); + let message = err.to_string(); + assert!(message.contains("missing required field 'replace'")); + assert!(message.contains("Input provided:")); + assert!(message.contains("path")); + assert!(message.contains("content")); + } + #[test] fn tool_error_display_matches_legacy_text() { let err = ToolError::missing_field("path"); diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 7d5d9ec9..da00c8d3 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -448,10 +448,12 @@ impl DeepSeekClient { fn build_http_client(api_key: &str) -> Result { let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - headers.insert( - AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {api_key}"))?, - ); + if !api_key.trim().is_empty() { + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {api_key}"))?, + ); + } let mut builder = reqwest::Client::builder() .default_headers(headers) .connect_timeout(Duration::from_secs(30)) diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index 10582076..f079eff9 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -1,5 +1,5 @@ -//! Provider switching: flip between DeepSeek, NVIDIA NIM, OpenRouter, and -//! Novita AI at runtime. +//! Provider switching: flip between DeepSeek, hosted providers, and self-hosted +//! OpenAI-compatible DeepSeek V4 servers at runtime. //! //! `/provider` with no args opens the picker modal (#52). `/provider ` //! keeps the v0.6.6 CLI form for muscle-memory + scripted use. @@ -27,7 +27,7 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { let Some(target) = ApiProvider::parse(name) else { return CommandResult::error(format!( - "Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openrouter, novita." + "Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openrouter, novita, fireworks, or sglang." )); }; @@ -135,6 +135,32 @@ mod tests { } } + #[test] + fn switch_to_fireworks_emits_action() { + let mut app = create_test_app(); + let result = provider(&mut app, Some("fireworks pro")); + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, ApiProvider::Fireworks); + assert_eq!(model.as_deref(), Some("deepseek-v4-pro")); + } + other => panic!("expected SwitchProvider, got {other:?}"), + } + } + + #[test] + fn switch_to_sglang_flash_emits_action() { + let mut app = create_test_app(); + let result = provider(&mut app, Some("sglang flash")); + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, ApiProvider::Sglang); + assert_eq!(model.as_deref(), Some("deepseek-v4-flash")); + } + other => panic!("expected SwitchProvider, got {other:?}"), + } + } + #[test] fn switching_to_active_provider_without_model_is_a_noop() { let mut app = create_test_app(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 7aacf589..7cb19100 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1020,11 +1020,10 @@ impl Config { "Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \ set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml." ), - ApiProvider::Sglang => anyhow::bail!( - "SGLang API key not found (optional for self-hosted). Run 'deepseek auth set --provider sglang', \ - set SGLANG_API_KEY, or add [providers.sglang] api_key in ~/.deepseek/config.toml. \ - If your SGLang deployment runs without authentication, set SGLANG_API_KEY to an empty string or any placeholder." - ), + // Self-hosted SGLang deployments commonly run without auth on + // localhost. Return an empty key and let the client omit the + // Authorization header. + ApiProvider::Sglang => Ok(String::new()), } } @@ -2715,6 +2714,60 @@ mod tests { Ok(()) } + #[test] + fn fireworks_provider_uses_canonical_defaults() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-fireworks-defaults-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config = Config { + provider: Some("fireworks".to_string()), + ..Default::default() + }; + config.validate()?; + assert_eq!(config.api_provider(), ApiProvider::Fireworks); + assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL); + assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL); + Ok(()) + } + + #[test] + fn sglang_provider_works_without_api_key() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-sglang-defaults-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config = Config { + provider: Some("sglang".to_string()), + ..Default::default() + }; + config.validate()?; + assert_eq!(config.api_provider(), ApiProvider::Sglang); + assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL); + assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL); + assert_eq!(config.deepseek_api_key()?, ""); + assert!(has_api_key_for(&config, ApiProvider::Sglang)); + Ok(()) + } + #[test] fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> { let _lock = lock_test_env(); @@ -2880,6 +2933,10 @@ api_key = "novita-table-key" let mut config = Config::default(); assert!(!has_api_key_for(&config, ApiProvider::Openrouter)); + assert!( + has_api_key_for(&config, ApiProvider::Sglang), + "SGLang is self-hosted and does not require a key by default" + ); // Safety: test-only environment mutation guarded by a global mutex. unsafe { @@ -2946,6 +3003,26 @@ api_key = "novita-table-key" .and_then(toml::Value::as_str), Some("novita-saved-key") ); + save_api_key_for(ApiProvider::Fireworks, "fireworks-saved-key")?; + save_api_key_for(ApiProvider::Sglang, "sglang-saved-key")?; + let contents = fs::read_to_string(&path)?; + let parsed: toml::Value = toml::from_str(&contents)?; + assert_eq!( + parsed + .get("providers") + .and_then(|p| p.get("fireworks")) + .and_then(|t| t.get("api_key")) + .and_then(toml::Value::as_str), + Some("fireworks-saved-key") + ); + assert_eq!( + parsed + .get("providers") + .and_then(|p| p.get("sglang")) + .and_then(|t| t.get("api_key")) + .and_then(toml::Value::as_str), + Some("sglang-saved-key") + ); Ok(()) } diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index b7c9b980..5fb0ff02 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -33,6 +33,8 @@ pub mod session_picker; pub mod sidebar; pub mod slash_menu; pub mod streaming; +mod subagent_routing; +mod tool_routing; pub mod transcript; pub mod transcript_cache; pub mod ui; diff --git a/crates/tui/src/tui/subagent_routing.rs b/crates/tui/src/tui/subagent_routing.rs new file mode 100644 index 00000000..94df9204 --- /dev/null +++ b/crates/tui/src/tui/subagent_routing.rs @@ -0,0 +1,313 @@ +//! Sub-agent and background-task routing helpers for the TUI loop. + +use std::time::Instant; + +use crate::task_manager::{TaskRecord, TaskStatus, TaskSummary}; +use crate::tools::subagent::{MailboxMessage, SubAgentResult, SubAgentStatus}; +use crate::tui::app::{App, AppMode, TaskPanelEntry}; +use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output}; +use crate::tui::pager::PagerView; +use crate::tui::widgets::agent_card::{ + AgentLifecycle, DelegateCard, FanoutCard, apply_to_delegate, apply_to_fanout, +}; + +pub(super) fn running_agent_count(app: &App) -> usize { + let mut ids: std::collections::HashSet<&str> = + app.agent_progress.keys().map(String::as_str).collect(); + for agent in app + .subagent_cache + .iter() + .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) + { + ids.insert(agent.agent_id.as_str()); + } + ids.len() +} + +pub(super) fn reconcile_subagent_activity_state(app: &mut App) { + let running_agents: Vec<(String, String)> = app + .subagent_cache + .iter() + .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) + .map(|agent| { + ( + agent.agent_id.clone(), + summarize_tool_output(&agent.assignment.objective), + ) + }) + .collect(); + + let running_ids: std::collections::HashSet = + running_agents.iter().map(|(id, _)| id.clone()).collect(); + app.agent_progress + .retain(|id, _| running_ids.contains(id.as_str())); + for (id, objective) in running_agents { + app.agent_progress.entry(id).or_insert(objective); + } + + if running_ids.is_empty() { + app.agent_activity_started_at = None; + } else if app.agent_activity_started_at.is_none() { + app.agent_activity_started_at = Some(Instant::now()); + } +} + +fn subagent_status_rank(status: &SubAgentStatus) -> u8 { + match status { + SubAgentStatus::Running => 0, + SubAgentStatus::Interrupted(_) => 1, + SubAgentStatus::Failed(_) => 2, + SubAgentStatus::Completed => 3, + SubAgentStatus::Cancelled => 4, + } +} + +pub(super) fn sort_subagents_in_place(agents: &mut [SubAgentResult]) { + agents.sort_by(|a, b| { + subagent_status_rank(&a.status) + .cmp(&subagent_status_rank(&b.status)) + .then_with(|| a.agent_type.as_str().cmp(b.agent_type.as_str())) + .then_with(|| a.agent_id.cmp(&b.agent_id)) + }); +} + +/// Route a `MailboxMessage` envelope to the matching in-transcript card, +/// allocating a `DelegateCard` or `FanoutCard` on first sight (issue #128). +pub(super) fn handle_subagent_mailbox(app: &mut App, _seq: u64, message: &MailboxMessage) { + // Accumulate sub-agent token costs for the real-time footer counter (#166). + if let MailboxMessage::TokenUsage { model, usage, .. } = message { + if let Some(cost) = crate::pricing::calculate_turn_cost_from_usage(model, usage) { + app.subagent_cost += cost; + } + return; // No card visual change needed; the footer handles display. + } + + // Resolve (or allocate) the target cell for this envelope. ChildSpawned + // is special — it always belongs to the active fanout card if one + // exists; otherwise it seeds a new one. + let agent_id = message.agent_id().to_string(); + + if matches!(message, MailboxMessage::ChildSpawned { .. }) + && let Some(idx) = app.last_fanout_card_index + && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx) + { + apply_to_fanout(card, message); + app.subagent_card_index.insert(agent_id, idx); + app.mark_history_updated(); + return; + } + + // Existing card for this agent_id? Mutate in place. + if let Some(&idx) = app.subagent_card_index.get(&agent_id) { + let updated = match app.history.get_mut(idx) { + Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card))) => { + apply_to_delegate(card, message) + } + Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) => { + apply_to_fanout(card, message) + } + _ => false, + }; + if updated { + app.mark_history_updated(); + } + return; + } + + // No existing card — only `Started` reasonably opens one. Anything else + // for an unknown agent_id is dropped (likely arrived after the cell was + // cleared, e.g. session-resume edge cases). + let MailboxMessage::Started { agent_type, .. } = message else { + return; + }; + + let dispatch_kind = app.pending_subagent_dispatch.as_deref(); + let is_fanout = matches!( + dispatch_kind, + Some("agent_swarm" | "spawn_agents_on_csv" | "rlm") + ); + + if is_fanout { + // Reuse the active fanout card for sibling spawns; otherwise create + // one anchored at this position so subsequent siblings join it. + if let Some(idx) = app.last_fanout_card_index + && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = + app.history.get_mut(idx) + { + card.upsert_worker(&agent_id, AgentLifecycle::Running); + app.subagent_card_index.insert(agent_id, idx); + } else { + let mut card = FanoutCard::new(dispatch_kind.unwrap_or("fanout").to_string()); + card.upsert_worker(&agent_id, AgentLifecycle::Running); + app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); + let idx = app.history.len().saturating_sub(1); + app.last_fanout_card_index = Some(idx); + app.subagent_card_index.insert(agent_id, idx); + } + } else { + let card = DelegateCard::new(agent_id.clone(), agent_type.clone()); + app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card))); + let idx = app.history.len().saturating_sub(1); + app.subagent_card_index.insert(agent_id, idx); + // Single delegate consumes the pending dispatch label so a follow-on + // tool call doesn't accidentally inherit it. + app.pending_subagent_dispatch = None; + } + + app.mark_history_updated(); +} + +pub(super) fn task_mode_label(mode: AppMode) -> &'static str { + mode.as_setting() +} + +pub(super) fn task_summary_to_panel_entry(summary: TaskSummary) -> TaskPanelEntry { + TaskPanelEntry { + id: summary.id, + status: task_status_label(summary.status).to_string(), + prompt_summary: summary.prompt_summary, + duration_ms: summary.duration_ms, + } +} + +fn task_status_label(status: TaskStatus) -> &'static str { + match status { + TaskStatus::Queued => "queued", + TaskStatus::Running => "running", + TaskStatus::Completed => "completed", + TaskStatus::Failed => "failed", + TaskStatus::Canceled => "canceled", + } +} + +pub(super) fn format_task_list(tasks: &[TaskSummary]) -> String { + if tasks.is_empty() { + return "No tasks found.".to_string(); + } + + let mut lines = vec![ + format!("Tasks ({})", tasks.len()), + "----------------------------------------".to_string(), + ]; + for task in tasks { + let duration = task + .duration_ms + .map(|ms| format!("{:.2}s", ms as f64 / 1000.0)) + .unwrap_or_else(|| "-".to_string()); + lines.push(format!( + "{} {:9} {} {}", + task.id, + task_status_label(task.status), + duration, + task.prompt_summary + )); + } + lines.push("Use /task show for timeline details.".to_string()); + lines.join("\n") +} + +pub(super) fn open_task_pager(app: &mut App, task: &TaskRecord) { + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(100) + .saturating_sub(4); + app.view_stack.push(PagerView::from_text( + format!("Task {}", task.id), + &format_task_detail(task), + width.max(60), + )); +} + +fn format_task_detail(task: &TaskRecord) -> String { + let mut lines = Vec::new(); + lines.push(format!("Task: {}", task.id)); + lines.push(format!("Status: {}", task_status_label(task.status))); + lines.push(format!("Mode: {}", task.mode)); + lines.push(format!("Model: {}", task.model)); + lines.push(format!("Workspace: {}", task.workspace.display())); + if let Some(thread_id) = task.thread_id.as_ref() { + lines.push(format!("Runtime Thread: {thread_id}")); + } + if let Some(turn_id) = task.turn_id.as_ref() { + lines.push(format!("Runtime Turn: {turn_id}")); + } + if task.runtime_event_count > 0 { + lines.push(format!("Runtime Events: {}", task.runtime_event_count)); + } + lines.push(format!("Created: {}", task.created_at)); + if let Some(started_at) = task.started_at { + lines.push(format!("Started: {}", started_at)); + } + if let Some(ended_at) = task.ended_at { + lines.push(format!("Ended: {}", ended_at)); + } + if let Some(duration) = task.duration_ms { + lines.push(format!("Duration: {:.2}s", duration as f64 / 1000.0)); + } + lines.push(String::new()); + lines.push("Prompt:".to_string()); + lines.push(task.prompt.clone()); + + if let Some(summary) = task.result_summary.as_ref() { + lines.push(String::new()); + lines.push("Result Summary:".to_string()); + lines.push(summary.clone()); + } + if let Some(path) = task.result_detail_path.as_ref() { + lines.push(format!("Result Artifact: {}", path.display())); + } + if let Some(error) = task.error.as_ref() { + lines.push(String::new()); + lines.push(format!("Error: {error}")); + } + + lines.push(String::new()); + lines.push("Tool Calls:".to_string()); + if task.tool_calls.is_empty() { + lines.push("- (none)".to_string()); + } else { + for tool in &task.tool_calls { + let status = match tool.status { + crate::task_manager::TaskToolStatus::Running => "running", + crate::task_manager::TaskToolStatus::Success => "success", + crate::task_manager::TaskToolStatus::Failed => "failed", + crate::task_manager::TaskToolStatus::Canceled => "canceled", + }; + let mut line = format!( + "- {} [{}] {}", + tool.name, + status, + tool.output_summary.as_deref().unwrap_or("(no summary)") + ); + if let Some(duration) = tool.duration_ms { + line.push_str(&format!(" ({:.2}s)", duration as f64 / 1000.0)); + } + lines.push(line); + if let Some(path) = tool.detail_path.as_ref() { + lines.push(format!(" detail: {}", path.display())); + } + if let Some(path) = tool.patch_ref.as_ref() { + lines.push(format!(" patch: {}", path.display())); + } + } + } + + lines.push(String::new()); + lines.push("Timeline:".to_string()); + if task.timeline.is_empty() { + lines.push("- (none)".to_string()); + } else { + for entry in &task.timeline { + lines.push(format!( + "- [{}] {}: {}", + entry.timestamp, entry.kind, entry.summary + )); + if let Some(path) = entry.detail_path.as_ref() { + lines.push(format!(" detail: {}", path.display())); + } + } + } + + lines.join("\n") +} diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs new file mode 100644 index 00000000..4b8000b4 --- /dev/null +++ b/crates/tui/src/tui/tool_routing.rs @@ -0,0 +1,922 @@ +//! Active tool-card routing helpers for the TUI loop. + +use std::path::PathBuf; +use std::time::Instant; + +use crate::tools::ReviewOutput; +use crate::tools::spec::{ToolError, ToolResult}; +use crate::tui::active_cell::ActiveCell; +use crate::tui::app::{App, ToolDetailRecord}; +use crate::tui::history::{ + DiffPreviewCell, ExecCell, ExecSource, ExploringEntry, GenericToolCell, HistoryCell, + McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus, + ViewImageCell, WebSearchCell, summarize_mcp_output, summarize_tool_args, summarize_tool_output, +}; + +#[allow(clippy::too_many_lines)] +pub(super) fn handle_tool_call_started( + app: &mut App, + id: &str, + name: &str, + input: &serde_json::Value, +) { + let id = id.to_string(); + + // All in-flight tool work for the current turn lives in `app.active_cell` + // until the turn completes. This mirrors Codex's contract: ONE active cell + // mutates in place; finalized history isn't touched until flush. This + // keeps the transcript stable while parallel completions arrive in any + // order. + if app.active_cell.is_none() { + app.active_cell = Some(ActiveCell::new()); + } + + if is_exploring_tool(name) { + let label = exploring_label(name, input); + // ensure_exploring + append_to_exploring keeps all parallel exploring + // starts in a single ExploringCell entry. + let active = app.active_cell.as_mut().expect("active_cell just ensured"); + let entry_idx = active.ensure_exploring(); + let inner = active + .append_to_exploring( + id.clone(), + ExploringEntry { + label, + status: ToolStatus::Running, + }, + ) + .map_or(0, |(_, inner)| inner); + app.exploring_cell = Some(entry_idx); + let virtual_index = app.history.len() + entry_idx; + app.exploring_entries + .insert(id.clone(), (virtual_index, inner)); + register_tool_cell(app, &id, name, input, virtual_index); + app.mark_history_updated(); + return; + } + + // Non-exploring tool: each is its own entry inside the active cell. We + // intentionally do NOT clear `exploring_cell` here — the active cell can + // hold both an exploring aggregate AND independent tool entries + // simultaneously, which is exactly the case CX#7 fixes. + + if is_exec_tool(name) { + let command = exec_command_from_input(input).unwrap_or_else(|| "".to_string()); + let source = exec_source_from_input(input); + let interaction = exec_interaction_summary(name, input); + let mut is_wait = false; + + if let Some((summary, wait)) = interaction.as_ref() { + is_wait = *wait; + if is_wait + && app + .last_exec_wait_command + .as_ref() + .is_some_and(|last| last == &command) + { + app.ignored_tool_calls.insert(id); + return; + } + if is_wait { + app.last_exec_wait_command = Some(command.clone()); + } + + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::Exec(ExecCell { + command, + status: ToolStatus::Running, + output: None, + started_at: Some(Instant::now()), + duration_ms: None, + source, + interaction: Some(summary.clone()), + })), + ); + return; + } + + if exec_is_background(input) + && app + .last_exec_wait_command + .as_ref() + .is_some_and(|last| last == &command) + { + app.ignored_tool_calls.insert(id); + return; + } + if exec_is_background(input) && !is_wait { + app.last_exec_wait_command = Some(command.clone()); + } + + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::Exec(ExecCell { + command, + status: ToolStatus::Running, + output: None, + started_at: Some(Instant::now()), + duration_ms: None, + source, + interaction: None, + })), + ); + return; + } + + if name == "update_plan" { + let (explanation, steps) = parse_plan_input(input); + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell { + explanation, + steps, + status: ToolStatus::Running, + })), + ); + return; + } + + if name == "apply_patch" { + let (path, summary) = parse_patch_summary(input); + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::PatchSummary(PatchSummaryCell { + path, + summary, + status: ToolStatus::Running, + error: None, + })), + ); + return; + } + + if name == "review" { + let target = review_target_label(input); + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::Review(ReviewCell { + target, + status: ToolStatus::Running, + output: None, + error: None, + })), + ); + return; + } + + if is_mcp_tool(name) { + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::Mcp(McpToolCell { + tool: name.to_string(), + status: ToolStatus::Running, + content: None, + is_image: false, + })), + ); + return; + } + + if is_view_image_tool(name) { + if let Some(path) = input.get("path").and_then(|v| v.as_str()) { + let raw_path = PathBuf::from(path); + let display_path = raw_path + .strip_prefix(&app.workspace) + .unwrap_or(&raw_path) + .to_path_buf(); + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::ViewImage(ViewImageCell { path: display_path })), + ); + } + return; + } + + if is_web_search_tool(name) { + let query = web_search_query(input); + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::WebSearch(WebSearchCell { + query, + status: ToolStatus::Running, + summary: None, + })), + ); + return; + } + + let input_summary = summarize_tool_args(input); + let prompts = extract_fanout_prompts(name, input); + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status: ToolStatus::Running, + input_summary, + output: None, + prompts, + })), + ); +} + +/// Extract per-child prompts from a fan-out tool's input. Currently no +/// top-level tool exposes a prompt list — fan-out lives inside the RLM +/// REPL via `llm_query_batched`. Kept as a stable hook for any future +/// fan-out tool we add. +fn extract_fanout_prompts(_name: &str, _input: &serde_json::Value) -> Option> { + None +} + +/// Push a tool cell as a new entry in `active_cell`, register the tool id, +/// and write a stub detail record so the pager / Ctrl+O can find it. +fn push_active_tool_cell( + app: &mut App, + tool_id: &str, + tool_name: &str, + input: &serde_json::Value, + cell: HistoryCell, +) { + if app.active_cell.is_none() { + app.active_cell = Some(ActiveCell::new()); + } + let active = app.active_cell.as_mut().expect("active_cell just ensured"); + let entry_idx = active.push_tool(tool_id.to_string(), cell); + let virtual_index = app.history.len() + entry_idx; + register_tool_cell(app, tool_id, tool_name, input, virtual_index); + app.mark_history_updated(); +} + +fn register_tool_cell( + app: &mut App, + tool_id: &str, + tool_name: &str, + input: &serde_json::Value, + cell_index: usize, +) { + app.tool_cells.insert(tool_id.to_string(), cell_index); + let record = ToolDetailRecord { + tool_id: tool_id.to_string(), + tool_name: tool_name.to_string(), + input: input.clone(), + output: None, + }; + if cell_index < app.history.len() { + app.tool_details_by_cell.insert(cell_index, record); + } else { + // Active-cell entry: keep the detail record in `active_tool_details` + // until the active cell flushes. `flush_active_cell` migrates these + // records into `tool_details_by_cell` keyed by the eventual real + // cell index. + app.active_tool_details.insert(tool_id.to_string(), record); + } +} + +fn store_tool_detail_output( + app: &mut App, + tool_id: &str, + cell_index: usize, + result: &Result, +) { + let payload = Some(match result { + Ok(tool_result) => tool_result.content.clone(), + Err(err) => err.to_string(), + }); + if cell_index < app.history.len() + && let Some(detail) = app.tool_details_by_cell.get_mut(&cell_index) + { + detail.output = payload.clone(); + } + // Also write to the active table while the entry might still live there; + // some callsites pre-rewrite cell_index but the active_tool_details map is + // the canonical source for in-flight outputs. + if let Some(detail) = app.active_tool_details.get_mut(tool_id) { + detail.output = payload; + } +} + +#[allow(clippy::too_many_lines)] +pub(super) fn handle_tool_call_complete( + app: &mut App, + id: &str, + name: &str, + result: &Result, +) { + if app.ignored_tool_calls.remove(id) { + return; + } + + // Exploring entries land in the per-tool map regardless of whether they + // live in the active cell or in finalized history; the path is the same. + if let Some((cell_index, entry_index)) = app.exploring_entries.remove(id) { + app.tool_cells.remove(id); + store_tool_detail_output(app, id, cell_index, result); + if let Some(HistoryCell::Tool(ToolCell::Exploring(cell))) = + app.cell_at_virtual_index_mut(cell_index) + && let Some(entry) = cell.entries.get_mut(entry_index) + { + entry.status = match result.as_ref() { + Ok(tool_result) if tool_result.success => ToolStatus::Success, + Ok(_) | Err(_) => ToolStatus::Failed, + }; + app.mark_history_updated(); + // Mutating the in-flight exploring cell needs an active-cell + // revision bump so the transcript cache invalidates the synthetic + // tail row. + if cell_index >= app.history.len() { + app.active_cell_revision = app.active_cell_revision.wrapping_add(1); + if let Some(active) = app.active_cell.as_mut() { + active.bump_revision(); + } + } + } + return; + } + + // Look up the cell by tool id. If the id isn't registered, that's an + // orphan completion (race condition where the started event was lost or + // a tool result arrived after the active cell was already flushed). Build + // a finalized standalone cell from the result so the user can still see + // the output, but DO NOT touch the active cell. + let Some(cell_index) = app.tool_cells.remove(id) else { + push_orphan_tool_completion(app, id, name, result); + return; + }; + + store_tool_detail_output(app, id, cell_index, result); + let in_active = cell_index >= app.history.len(); + + let status = match result.as_ref() { + Ok(tool_result) => match tool_result.metadata.as_ref() { + Some(meta) + if meta + .get("status") + .and_then(|v| v.as_str()) + .is_some_and(|s| s == "Running") => + { + ToolStatus::Running + } + _ => { + if tool_result.success { + ToolStatus::Success + } else { + ToolStatus::Failed + } + } + }, + Err(_) => ToolStatus::Failed, + }; + + if let Some(cell) = app.cell_at_virtual_index_mut(cell_index) { + match cell { + HistoryCell::Tool(ToolCell::Exec(exec)) => { + exec.status = status; + if let Ok(tool_result) = result.as_ref() { + exec.duration_ms = tool_result + .metadata + .as_ref() + .and_then(|m| m.get("duration_ms")) + .and_then(serde_json::Value::as_u64); + if status != ToolStatus::Running && exec.interaction.is_none() { + exec.output = Some(tool_result.content.clone()); + } + } else if let Err(err) = result.as_ref() + && exec.interaction.is_none() + { + exec.output = Some(err.to_string()); + } + app.mark_history_updated(); + } + HistoryCell::Tool(ToolCell::PlanUpdate(plan)) => { + plan.status = status; + app.mark_history_updated(); + } + HistoryCell::Tool(ToolCell::PatchSummary(patch)) => { + patch.status = status; + match result.as_ref() { + Ok(tool_result) => { + if let Ok(json) = + serde_json::from_str::(&tool_result.content) + && let Some(message) = json.get("message").and_then(|v| v.as_str()) + { + patch.summary = message.to_string(); + } + } + Err(err) => { + patch.error = Some(err.to_string()); + } + } + app.mark_history_updated(); + } + HistoryCell::Tool(ToolCell::Review(review)) => { + review.status = status; + match result.as_ref() { + Ok(tool_result) => { + if tool_result.success { + review.output = Some(ReviewOutput::from_str(&tool_result.content)); + } else { + review.error = Some(tool_result.content.clone()); + } + } + Err(err) => { + review.error = Some(err.to_string()); + } + } + app.mark_history_updated(); + } + HistoryCell::Tool(ToolCell::Mcp(mcp)) => { + match result.as_ref() { + Ok(tool_result) => { + let summary = summarize_mcp_output(&tool_result.content); + if summary.is_error == Some(true) { + mcp.status = ToolStatus::Failed; + } else { + mcp.status = status; + } + mcp.is_image = summary.is_image; + mcp.content = summary.content; + } + Err(err) => { + mcp.status = status; + mcp.content = Some(err.to_string()); + } + } + app.mark_history_updated(); + } + HistoryCell::Tool(ToolCell::WebSearch(search)) => { + search.status = status; + match result.as_ref() { + Ok(tool_result) => { + search.summary = Some(summarize_tool_output(&tool_result.content)); + } + Err(err) => { + search.summary = Some(err.to_string()); + } + } + app.mark_history_updated(); + } + HistoryCell::Tool(ToolCell::Generic(generic)) => { + generic.status = status; + match result.as_ref() { + Ok(tool_result) => { + generic.output = Some(summarize_tool_output(&tool_result.content)); + } + Err(err) => { + generic.output = Some(err.to_string()); + } + } + app.mark_history_updated(); + } + _ => {} + } + } + + // If the mutated cell lived inside the active group, bump the active-cell + // revision so the transcript cache re-renders the synthetic tail row. + if in_active { + app.active_cell_revision = app.active_cell_revision.wrapping_add(1); + if let Some(active) = app.active_cell.as_mut() { + active.bump_revision(); + } + } +} + +/// Build a finalized standalone history cell for a tool completion whose +/// start was never registered (orphan). This preserves the contract that +/// every tool result is visible somewhere; the alternative (silently +/// dropping it) hides errors and breaks debuggability. +/// +/// Choice of cell type: we use `GenericToolCell` because we have no input +/// payload to reconstruct a more specific cell. The pager remains usable — +/// `tool_details_by_cell` is populated with the result text. +/// +/// ## Index drift +/// +/// If an active cell is in flight when the orphan arrives, pushing the +/// orphan into `app.history` shifts every active-cell virtual index forward +/// by 1. We must rewrite `tool_cells` / `exploring_entries` accordingly so +/// later completion lookups still find the right entries. +fn push_orphan_tool_completion( + app: &mut App, + tool_id: &str, + name: &str, + result: &Result, +) { + let status = match result.as_ref() { + Ok(tool_result) => { + if tool_result.success { + ToolStatus::Success + } else { + ToolStatus::Failed + } + } + Err(_) => ToolStatus::Failed, + }; + let output = match result.as_ref() { + Ok(tool_result) => Some(summarize_tool_output(&tool_result.content)), + Err(err) => Some(err.to_string()), + }; + let history_threshold_before_push = app.history.len(); + let active_in_flight = app.active_cell.is_some(); + app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status, + input_summary: None, + output, + prompts: None, + }))); + let cell_index = app.history.len().saturating_sub(1); + app.tool_details_by_cell.insert( + cell_index, + ToolDetailRecord { + tool_id: tool_id.to_string(), + tool_name: name.to_string(), + input: serde_json::Value::Null, + output: match result.as_ref() { + Ok(tool_result) => Some(tool_result.content.clone()), + Err(err) => Some(err.to_string()), + }, + }, + ); + + // Shift active-cell virtual indices forward by 1 to absorb the new + // history cell. Without this, the next completion would address the + // wrong entry. + if active_in_flight { + let threshold = history_threshold_before_push; + for idx in app.tool_cells.values_mut() { + if *idx >= threshold { + *idx = idx.wrapping_add(1); + } + } + for (cell_idx, _) in app.exploring_entries.values_mut() { + if *cell_idx >= threshold { + *cell_idx = cell_idx.wrapping_add(1); + } + } + if let Some(idx) = app.exploring_cell.as_mut() + && *idx >= threshold + { + *idx = idx.wrapping_add(1); + } + } +} + +fn is_exploring_tool(name: &str) -> bool { + matches!(name, "read_file" | "list_dir" | "grep_files" | "list_files") +} + +fn is_exec_tool(name: &str) -> bool { + matches!( + name, + "exec_shell" | "exec_shell_wait" | "exec_shell_interact" | "exec_wait" | "exec_interact" + ) +} + +pub(super) fn exploring_label(name: &str, input: &serde_json::Value) -> String { + let fallback = format!("{name} tool"); + let obj = input.as_object(); + match name { + "read_file" => obj + .and_then(|o| o.get("path")) + .and_then(|v| v.as_str()) + .map_or(fallback, |path| format!("Reading {path}")), + "list_dir" => obj + .and_then(|o| o.get("path")) + .and_then(|v| v.as_str()) + .map_or("Listing directory".to_string(), |path| { + format!("Listing {path}") + }), + "grep_files" => { + let pattern = obj + .and_then(|o| o.get("pattern")) + .and_then(|v| v.as_str()) + .unwrap_or("pattern"); + format!("Searching for `{pattern}`") + } + "list_files" => "Listing files".to_string(), + _ => fallback, + } +} + +fn is_mcp_tool(name: &str) -> bool { + name.starts_with("mcp_") +} + +fn is_view_image_tool(name: &str) -> bool { + matches!(name, "view_image" | "view_image_file" | "view_image_tool") +} + +fn is_web_search_tool(name: &str) -> bool { + matches!(name, "web_search" | "search_web" | "search" | "web.run") + || name.ends_with("_web_search") +} + +fn web_search_query(input: &serde_json::Value) -> String { + if let Some(searches) = input.get("search_query").and_then(|v| v.as_array()) + && let Some(first) = searches.first() + && let Some(q) = first.get("q").and_then(|v| v.as_str()) + { + return q.to_string(); + } + + input + .get("query") + .or_else(|| input.get("q")) + .or_else(|| input.get("search")) + .and_then(|v| v.as_str()) + .unwrap_or("Web search") + .to_string() +} + +fn review_target_label(input: &serde_json::Value) -> String { + let target = input + .get("target") + .and_then(|v| v.as_str()) + .unwrap_or("review") + .trim(); + let kind = input + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + let staged = input + .get("staged") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let target_lower = target.to_ascii_lowercase(); + + if kind == "diff" + || target_lower == "diff" + || target_lower == "git diff" + || target_lower == "staged" + || target_lower == "cached" + { + if staged || target_lower == "staged" || target_lower == "cached" { + return "git diff --cached".to_string(); + } + return "git diff".to_string(); + } + + target.to_string() +} + +fn parse_plan_input(input: &serde_json::Value) -> (Option, Vec) { + let explanation = input + .get("explanation") + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string); + let mut steps = Vec::new(); + if let Some(items) = input.get("plan").and_then(|v| v.as_array()) { + for item in items { + let step = item.get("step").and_then(|v| v.as_str()).unwrap_or(""); + let status = item + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("pending"); + if !step.is_empty() { + steps.push(PlanStep { + step: step.to_string(), + status: status.to_string(), + }); + } + } + } + (explanation, steps) +} + +fn parse_patch_summary(input: &serde_json::Value) -> (String, String) { + if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) { + let count = changes.len(); + let path = changes + .first() + .and_then(|c| c.get("path")) + .and_then(|v| v.as_str()) + .map(str::to_string) + .unwrap_or_else(|| "".to_string()); + let label = if count <= 1 { + path + } else { + format!("{count} files") + }; + let summary = format!("Changes: {count} file(s)"); + return (label, summary); + } + + let patch_text = input.get("patch").and_then(|v| v.as_str()).unwrap_or(""); + let paths = extract_patch_paths(patch_text); + let path = input + .get("path") + .and_then(|v| v.as_str()) + .map(str::to_string) + .or_else(|| { + if paths.len() == 1 { + paths.first().cloned() + } else if paths.is_empty() { + None + } else { + Some(format!("{} files", paths.len())) + } + }) + .unwrap_or_else(|| "".to_string()); + + let (adds, removes) = count_patch_changes(patch_text); + let summary = if adds == 0 && removes == 0 { + "Patch applied".to_string() + } else { + format!("Changes: +{adds} / -{removes}") + }; + (path, summary) +} + +fn extract_patch_paths(patch: &str) -> Vec { + let mut paths = Vec::new(); + for line in patch.lines() { + if let Some(rest) = line.strip_prefix("+++ ") { + let raw = rest.trim(); + if raw == "/dev/null" || raw == "dev/null" { + continue; + } + let raw = raw.strip_prefix("b/").unwrap_or(raw); + if !paths.contains(&raw.to_string()) { + paths.push(raw.to_string()); + } + } else if let Some(rest) = line.strip_prefix("diff --git ") { + let parts: Vec<&str> = rest.split_whitespace().collect(); + if let Some(path) = parts.get(1).or_else(|| parts.first()) { + let raw = path.trim(); + let raw = raw + .strip_prefix("b/") + .or_else(|| raw.strip_prefix("a/")) + .unwrap_or(raw); + if !paths.contains(&raw.to_string()) { + paths.push(raw.to_string()); + } + } + } + } + paths +} + +pub(super) fn maybe_add_patch_preview(app: &mut App, input: &serde_json::Value) { + if let Some(patch) = input.get("patch").and_then(|v| v.as_str()) { + app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell { + title: "Patch Preview".to_string(), + diff: patch.to_string(), + }))); + app.mark_history_updated(); + return; + } + + if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) { + let preview = format_changes_preview(changes); + if !preview.trim().is_empty() { + app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell { + title: "Changes Preview".to_string(), + diff: preview, + }))); + app.mark_history_updated(); + } + } +} + +fn format_changes_preview(changes: &[serde_json::Value]) -> String { + let mut out = String::new(); + for change in changes { + let path = change + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let content = change.get("content").and_then(|v| v.as_str()).unwrap_or(""); + + out.push_str(&format!("diff --git a/{path} b/{path}\n")); + out.push_str(&format!("--- a/{path}\n+++ b/{path}\n")); + out.push_str("@@ -0,0 +1,1 @@\n"); + + let mut count = 0usize; + for line in content.lines() { + out.push('+'); + out.push_str(line); + out.push('\n'); + count += 1; + if count >= 20 { + out.push_str("+... (truncated)\n"); + break; + } + } + if content.is_empty() { + out.push_str("+\n"); + } + } + out +} + +fn count_patch_changes(patch: &str) -> (usize, usize) { + let mut adds = 0; + let mut removes = 0; + for line in patch.lines() { + if line.starts_with("+++") || line.starts_with("---") { + continue; + } + if line.starts_with('+') { + adds += 1; + } else if line.starts_with('-') { + removes += 1; + } + } + (adds, removes) +} + +fn exec_command_from_input(input: &serde_json::Value) -> Option { + input + .get("command") + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string) +} + +fn exec_source_from_input(input: &serde_json::Value) -> ExecSource { + match input.get("source").and_then(|v| v.as_str()) { + Some(source) if source.eq_ignore_ascii_case("user") => ExecSource::User, + _ => ExecSource::Assistant, + } +} + +fn exec_interaction_summary(name: &str, input: &serde_json::Value) -> Option<(String, bool)> { + let command = exec_command_from_input(input).unwrap_or_else(|| "".to_string()); + let command_display = format!("\"{command}\""); + let interaction_input = input + .get("input") + .or_else(|| input.get("stdin")) + .or_else(|| input.get("data")) + .and_then(|v| v.as_str()); + + let is_wait_tool = matches!(name, "exec_shell_wait" | "exec_wait"); + let is_interact_tool = matches!(name, "exec_shell_interact" | "exec_interact"); + + if is_interact_tool || interaction_input.is_some() { + let preview = interaction_input.map(summarize_interaction_input); + let summary = if let Some(preview) = preview { + format!("Interacted with {command_display}, sent {preview}") + } else { + format!("Interacted with {command_display}") + }; + return Some((summary, false)); + } + + if is_wait_tool || input.get("wait").and_then(serde_json::Value::as_bool) == Some(true) { + return Some((format!("Waited for {command_display}"), true)); + } + + None +} + +fn summarize_interaction_input(input: &str) -> String { + let mut single_line = input.replace('\r', ""); + single_line = single_line.replace('\n', "\\n"); + single_line = single_line.replace('\"', "'"); + let max_len = 80; + if single_line.chars().count() <= max_len { + return format!("\"{single_line}\""); + } + let mut out = String::new(); + for ch in single_line.chars().take(max_len.saturating_sub(3)) { + out.push(ch); + } + out.push_str("..."); + format!("\"{out}\"") +} + +fn exec_is_background(input: &serde_json::Value) -> bool { + input + .get("background") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 36283eb9..23616819 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -44,13 +44,7 @@ use crate::session_manager::{ OfflineQueueState, QueuedSessionMessage, SavedSession, SessionManager, create_saved_session_with_mode, update_session, }; -use crate::task_manager::{ - NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskRecord, TaskStatus, - TaskSummary, -}; -use crate::tools::ReviewOutput; -use crate::tools::spec::{ToolError, ToolResult}; -use crate::tools::subagent::{MailboxMessage, SubAgentResult, SubAgentStatus}; +use crate::task_manager::{NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig}; use crate::tui::command_palette::{ CommandPaletteView, build_entries as build_command_palette_entries, }; @@ -63,22 +57,28 @@ use crate::tui::plan_prompt::PlanPromptView; use crate::tui::scrolling::{ScrollDirection, TranscriptScroll}; use crate::tui::selection::TranscriptSelectionPoint; use crate::tui::session_picker::SessionPickerView; +use crate::tui::subagent_routing::{ + format_task_list, handle_subagent_mailbox, open_task_pager, reconcile_subagent_activity_state, + running_agent_count, sort_subagents_in_place, task_mode_label, task_summary_to_panel_entry, +}; +#[cfg(test)] +use crate::tui::tool_routing::exploring_label; +use crate::tui::tool_routing::{ + handle_tool_call_complete, handle_tool_call_started, maybe_add_patch_preview, +}; use crate::tui::ui_text::{history_cell_to_text, line_to_plain, slice_text, text_display_width}; use crate::tui::user_input::UserInputView; use super::active_cell::ActiveCell; use super::app::{ App, AppAction, AppMode, OnboardingState, QueuedMessage, SidebarFocus, StatusToastLevel, - SubmitDisposition, TaskPanelEntry, ToolDetailRecord, TuiOptions, + SubmitDisposition, ToolDetailRecord, TuiOptions, }; use super::approval::{ ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision, }; use super::history::{ - DiffPreviewCell, ExecCell, ExecSource, ExploringEntry, GenericToolCell, HistoryCell, - McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus, - ViewImageCell, WebSearchCell, history_cells_from_message, summarize_mcp_output, - summarize_tool_args, summarize_tool_output, + HistoryCell, ToolCell, ToolStatus, history_cells_from_message, summarize_tool_output, }; use super::slash_menu::{ apply_slash_menu_selection, try_autocomplete_slash_command, visible_slash_menu_entries, @@ -3254,47 +3254,6 @@ async fn handle_plan_choice( Ok(true) } -fn running_agent_count(app: &App) -> usize { - let mut ids: std::collections::HashSet<&str> = - app.agent_progress.keys().map(String::as_str).collect(); - for agent in app - .subagent_cache - .iter() - .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) - { - ids.insert(agent.agent_id.as_str()); - } - ids.len() -} - -fn reconcile_subagent_activity_state(app: &mut App) { - let running_agents: Vec<(String, String)> = app - .subagent_cache - .iter() - .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) - .map(|agent| { - ( - agent.agent_id.clone(), - summarize_tool_output(&agent.assignment.objective), - ) - }) - .collect(); - - let running_ids: std::collections::HashSet = - running_agents.iter().map(|(id, _)| id.clone()).collect(); - app.agent_progress - .retain(|id, _| running_ids.contains(id.as_str())); - for (id, objective) in running_agents { - app.agent_progress.entry(id).or_insert(objective); - } - - if running_ids.is_empty() { - app.agent_activity_started_at = None; - } else if app.agent_activity_started_at.is_none() { - app.agent_activity_started_at = Some(Instant::now()); - } -} - /// Build the pending-input preview widget from current `App` state. /// /// v0.6.6 (#122) wires all three buckets: @@ -5474,1179 +5433,5 @@ fn extract_reasoning_header(text: &str) -> Option { } } -fn subagent_status_rank(status: &SubAgentStatus) -> u8 { - match status { - SubAgentStatus::Running => 0, - SubAgentStatus::Interrupted(_) => 1, - SubAgentStatus::Failed(_) => 2, - SubAgentStatus::Completed => 3, - SubAgentStatus::Cancelled => 4, - } -} - -fn sort_subagents_in_place(agents: &mut [SubAgentResult]) { - agents.sort_by(|a, b| { - subagent_status_rank(&a.status) - .cmp(&subagent_status_rank(&b.status)) - .then_with(|| a.agent_type.as_str().cmp(b.agent_type.as_str())) - .then_with(|| a.agent_id.cmp(&b.agent_id)) - }); -} - -/// Route a `MailboxMessage` envelope to the matching in-transcript card, -/// allocating a `DelegateCard` or `FanoutCard` on first sight (issue #128). -fn handle_subagent_mailbox(app: &mut App, _seq: u64, message: &MailboxMessage) { - use crate::tui::history::{HistoryCell, SubAgentCell}; - use crate::tui::widgets::agent_card::{ - DelegateCard, FanoutCard, apply_to_delegate, apply_to_fanout, - }; - - // Accumulate sub-agent token costs for the real-time footer counter (#166). - if let MailboxMessage::TokenUsage { model, usage, .. } = message { - if let Some(cost) = crate::pricing::calculate_turn_cost_from_usage(model, usage) { - app.subagent_cost += cost; - } - return; // No card visual change needed; the footer handles display. - } - - // Resolve (or allocate) the target cell for this envelope. ChildSpawned - // is special — it always belongs to the active fanout card if one - // exists; otherwise it seeds a new one. - let agent_id = message.agent_id().to_string(); - - if matches!(message, MailboxMessage::ChildSpawned { .. }) - && let Some(idx) = app.last_fanout_card_index - && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx) - { - apply_to_fanout(card, message); - app.subagent_card_index.insert(agent_id, idx); - app.mark_history_updated(); - return; - } - - // Existing card for this agent_id? Mutate in place. - if let Some(&idx) = app.subagent_card_index.get(&agent_id) { - let updated = match app.history.get_mut(idx) { - Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card))) => { - apply_to_delegate(card, message) - } - Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) => { - apply_to_fanout(card, message) - } - _ => false, - }; - if updated { - app.mark_history_updated(); - } - return; - } - - // No existing card — only `Started` reasonably opens one. Anything else - // for an unknown agent_id is dropped (likely arrived after the cell was - // cleared, e.g. session-resume edge cases). - let MailboxMessage::Started { agent_type, .. } = message else { - return; - }; - - let dispatch_kind = app.pending_subagent_dispatch.as_deref(); - let is_fanout = matches!( - dispatch_kind, - Some("agent_swarm" | "spawn_agents_on_csv" | "rlm") - ); - - if is_fanout { - // Reuse the active fanout card for sibling spawns; otherwise create - // one anchored at this position so subsequent siblings join it. - if let Some(idx) = app.last_fanout_card_index - && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = - app.history.get_mut(idx) - { - card.upsert_worker( - &agent_id, - crate::tui::widgets::agent_card::AgentLifecycle::Running, - ); - app.subagent_card_index.insert(agent_id, idx); - } else { - let mut card = FanoutCard::new(dispatch_kind.unwrap_or("fanout").to_string()); - card.upsert_worker( - &agent_id, - crate::tui::widgets::agent_card::AgentLifecycle::Running, - ); - app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); - let idx = app.history.len().saturating_sub(1); - app.last_fanout_card_index = Some(idx); - app.subagent_card_index.insert(agent_id, idx); - } - } else { - let card = DelegateCard::new(agent_id.clone(), agent_type.clone()); - app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card))); - let idx = app.history.len().saturating_sub(1); - app.subagent_card_index.insert(agent_id, idx); - // Single delegate consumes the pending dispatch label so a follow-on - // tool call doesn't accidentally inherit it. - app.pending_subagent_dispatch = None; - } - - app.mark_history_updated(); -} - -fn task_mode_label(mode: AppMode) -> &'static str { - mode.as_setting() -} - -fn task_summary_to_panel_entry(summary: TaskSummary) -> TaskPanelEntry { - TaskPanelEntry { - id: summary.id, - status: task_status_label(summary.status).to_string(), - prompt_summary: summary.prompt_summary, - duration_ms: summary.duration_ms, - } -} - -fn task_status_label(status: TaskStatus) -> &'static str { - match status { - TaskStatus::Queued => "queued", - TaskStatus::Running => "running", - TaskStatus::Completed => "completed", - TaskStatus::Failed => "failed", - TaskStatus::Canceled => "canceled", - } -} - -fn format_task_list(tasks: &[TaskSummary]) -> String { - if tasks.is_empty() { - return "No tasks found.".to_string(); - } - - let mut lines = vec![ - format!("Tasks ({})", tasks.len()), - "----------------------------------------".to_string(), - ]; - for task in tasks { - let duration = task - .duration_ms - .map(|ms| format!("{:.2}s", ms as f64 / 1000.0)) - .unwrap_or_else(|| "-".to_string()); - lines.push(format!( - "{} {:9} {} {}", - task.id, - task_status_label(task.status), - duration, - task.prompt_summary - )); - } - lines.push("Use /task show for timeline details.".to_string()); - lines.join("\n") -} - -fn open_task_pager(app: &mut App, task: &TaskRecord) { - let width = app - .last_transcript_area - .map(|area| area.width) - .unwrap_or(100) - .saturating_sub(4); - app.view_stack.push(PagerView::from_text( - format!("Task {}", task.id), - &format_task_detail(task), - width.max(60), - )); -} - -fn format_task_detail(task: &TaskRecord) -> String { - let mut lines = Vec::new(); - lines.push(format!("Task: {}", task.id)); - lines.push(format!("Status: {}", task_status_label(task.status))); - lines.push(format!("Mode: {}", task.mode)); - lines.push(format!("Model: {}", task.model)); - lines.push(format!("Workspace: {}", task.workspace.display())); - if let Some(thread_id) = task.thread_id.as_ref() { - lines.push(format!("Runtime Thread: {thread_id}")); - } - if let Some(turn_id) = task.turn_id.as_ref() { - lines.push(format!("Runtime Turn: {turn_id}")); - } - if task.runtime_event_count > 0 { - lines.push(format!("Runtime Events: {}", task.runtime_event_count)); - } - lines.push(format!("Created: {}", task.created_at)); - if let Some(started_at) = task.started_at { - lines.push(format!("Started: {}", started_at)); - } - if let Some(ended_at) = task.ended_at { - lines.push(format!("Ended: {}", ended_at)); - } - if let Some(duration) = task.duration_ms { - lines.push(format!("Duration: {:.2}s", duration as f64 / 1000.0)); - } - lines.push(String::new()); - lines.push("Prompt:".to_string()); - lines.push(task.prompt.clone()); - - if let Some(summary) = task.result_summary.as_ref() { - lines.push(String::new()); - lines.push("Result Summary:".to_string()); - lines.push(summary.clone()); - } - if let Some(path) = task.result_detail_path.as_ref() { - lines.push(format!("Result Artifact: {}", path.display())); - } - if let Some(error) = task.error.as_ref() { - lines.push(String::new()); - lines.push(format!("Error: {error}")); - } - - lines.push(String::new()); - lines.push("Tool Calls:".to_string()); - if task.tool_calls.is_empty() { - lines.push("- (none)".to_string()); - } else { - for tool in &task.tool_calls { - let status = match tool.status { - crate::task_manager::TaskToolStatus::Running => "running", - crate::task_manager::TaskToolStatus::Success => "success", - crate::task_manager::TaskToolStatus::Failed => "failed", - crate::task_manager::TaskToolStatus::Canceled => "canceled", - }; - let mut line = format!( - "- {} [{}] {}", - tool.name, - status, - tool.output_summary.as_deref().unwrap_or("(no summary)") - ); - if let Some(duration) = tool.duration_ms { - line.push_str(&format!(" ({:.2}s)", duration as f64 / 1000.0)); - } - lines.push(line); - if let Some(path) = tool.detail_path.as_ref() { - lines.push(format!(" detail: {}", path.display())); - } - if let Some(path) = tool.patch_ref.as_ref() { - lines.push(format!(" patch: {}", path.display())); - } - } - } - - lines.push(String::new()); - lines.push("Timeline:".to_string()); - if task.timeline.is_empty() { - lines.push("- (none)".to_string()); - } else { - for entry in &task.timeline { - lines.push(format!( - "- [{}] {}: {}", - entry.timestamp, entry.kind, entry.summary - )); - if let Some(path) = entry.detail_path.as_ref() { - lines.push(format!(" detail: {}", path.display())); - } - } - } - - lines.join("\n") -} - -#[allow(clippy::too_many_lines)] -fn handle_tool_call_started(app: &mut App, id: &str, name: &str, input: &serde_json::Value) { - let id = id.to_string(); - - // All in-flight tool work for the current turn lives in `app.active_cell` - // until the turn completes. This mirrors Codex's contract: ONE active cell - // mutates in place; finalized history isn't touched until flush. This - // keeps the transcript stable while parallel completions arrive in any - // order. - if app.active_cell.is_none() { - app.active_cell = Some(ActiveCell::new()); - } - - if is_exploring_tool(name) { - let label = exploring_label(name, input); - // ensure_exploring + append_to_exploring keeps all parallel exploring - // starts in a single ExploringCell entry. - let active = app.active_cell.as_mut().expect("active_cell just ensured"); - let entry_idx = active.ensure_exploring(); - let inner = active - .append_to_exploring( - id.clone(), - ExploringEntry { - label, - status: ToolStatus::Running, - }, - ) - .map_or(0, |(_, inner)| inner); - app.exploring_cell = Some(entry_idx); - let virtual_index = app.history.len() + entry_idx; - app.exploring_entries - .insert(id.clone(), (virtual_index, inner)); - register_tool_cell(app, &id, name, input, virtual_index); - app.mark_history_updated(); - return; - } - - // Non-exploring tool: each is its own entry inside the active cell. We - // intentionally do NOT clear `exploring_cell` here — the active cell can - // hold both an exploring aggregate AND independent tool entries - // simultaneously, which is exactly the case CX#7 fixes. - - if is_exec_tool(name) { - let command = exec_command_from_input(input).unwrap_or_else(|| "".to_string()); - let source = exec_source_from_input(input); - let interaction = exec_interaction_summary(name, input); - let mut is_wait = false; - - if let Some((summary, wait)) = interaction.as_ref() { - is_wait = *wait; - if is_wait - && app - .last_exec_wait_command - .as_ref() - .is_some_and(|last| last == &command) - { - app.ignored_tool_calls.insert(id); - return; - } - if is_wait { - app.last_exec_wait_command = Some(command.clone()); - } - - push_active_tool_cell( - app, - &id, - name, - input, - HistoryCell::Tool(ToolCell::Exec(ExecCell { - command, - status: ToolStatus::Running, - output: None, - started_at: Some(Instant::now()), - duration_ms: None, - source, - interaction: Some(summary.clone()), - })), - ); - return; - } - - if exec_is_background(input) - && app - .last_exec_wait_command - .as_ref() - .is_some_and(|last| last == &command) - { - app.ignored_tool_calls.insert(id); - return; - } - if exec_is_background(input) && !is_wait { - app.last_exec_wait_command = Some(command.clone()); - } - - push_active_tool_cell( - app, - &id, - name, - input, - HistoryCell::Tool(ToolCell::Exec(ExecCell { - command, - status: ToolStatus::Running, - output: None, - started_at: Some(Instant::now()), - duration_ms: None, - source, - interaction: None, - })), - ); - return; - } - - if name == "update_plan" { - let (explanation, steps) = parse_plan_input(input); - push_active_tool_cell( - app, - &id, - name, - input, - HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell { - explanation, - steps, - status: ToolStatus::Running, - })), - ); - return; - } - - if name == "apply_patch" { - let (path, summary) = parse_patch_summary(input); - push_active_tool_cell( - app, - &id, - name, - input, - HistoryCell::Tool(ToolCell::PatchSummary(PatchSummaryCell { - path, - summary, - status: ToolStatus::Running, - error: None, - })), - ); - return; - } - - if name == "review" { - let target = review_target_label(input); - push_active_tool_cell( - app, - &id, - name, - input, - HistoryCell::Tool(ToolCell::Review(ReviewCell { - target, - status: ToolStatus::Running, - output: None, - error: None, - })), - ); - return; - } - - if is_mcp_tool(name) { - push_active_tool_cell( - app, - &id, - name, - input, - HistoryCell::Tool(ToolCell::Mcp(McpToolCell { - tool: name.to_string(), - status: ToolStatus::Running, - content: None, - is_image: false, - })), - ); - return; - } - - if is_view_image_tool(name) { - if let Some(path) = input.get("path").and_then(|v| v.as_str()) { - let raw_path = PathBuf::from(path); - let display_path = raw_path - .strip_prefix(&app.workspace) - .unwrap_or(&raw_path) - .to_path_buf(); - push_active_tool_cell( - app, - &id, - name, - input, - HistoryCell::Tool(ToolCell::ViewImage(ViewImageCell { path: display_path })), - ); - } - return; - } - - if is_web_search_tool(name) { - let query = web_search_query(input); - push_active_tool_cell( - app, - &id, - name, - input, - HistoryCell::Tool(ToolCell::WebSearch(WebSearchCell { - query, - status: ToolStatus::Running, - summary: None, - })), - ); - return; - } - - let input_summary = summarize_tool_args(input); - let prompts = extract_fanout_prompts(name, input); - push_active_tool_cell( - app, - &id, - name, - input, - HistoryCell::Tool(ToolCell::Generic(GenericToolCell { - name: name.to_string(), - status: ToolStatus::Running, - input_summary, - output: None, - prompts, - })), - ); -} - -/// Extract per-child prompts from a fan-out tool's input. Currently no -/// top-level tool exposes a prompt list — fan-out lives inside the RLM -/// REPL via `llm_query_batched`. Kept as a stable hook for any future -/// fan-out tool we add. -fn extract_fanout_prompts(_name: &str, _input: &serde_json::Value) -> Option> { - None -} - -/// Push a tool cell as a new entry in `active_cell`, register the tool id, -/// and write a stub detail record so the pager / Ctrl+O can find it. -fn push_active_tool_cell( - app: &mut App, - tool_id: &str, - tool_name: &str, - input: &serde_json::Value, - cell: HistoryCell, -) { - if app.active_cell.is_none() { - app.active_cell = Some(ActiveCell::new()); - } - let active = app.active_cell.as_mut().expect("active_cell just ensured"); - let entry_idx = active.push_tool(tool_id.to_string(), cell); - let virtual_index = app.history.len() + entry_idx; - register_tool_cell(app, tool_id, tool_name, input, virtual_index); - app.mark_history_updated(); -} - -fn register_tool_cell( - app: &mut App, - tool_id: &str, - tool_name: &str, - input: &serde_json::Value, - cell_index: usize, -) { - app.tool_cells.insert(tool_id.to_string(), cell_index); - let record = ToolDetailRecord { - tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), - input: input.clone(), - output: None, - }; - if cell_index < app.history.len() { - app.tool_details_by_cell.insert(cell_index, record); - } else { - // Active-cell entry: keep the detail record in `active_tool_details` - // until the active cell flushes. `flush_active_cell` migrates these - // records into `tool_details_by_cell` keyed by the eventual real - // cell index. - app.active_tool_details.insert(tool_id.to_string(), record); - } -} - -fn store_tool_detail_output( - app: &mut App, - tool_id: &str, - cell_index: usize, - result: &Result, -) { - let payload = Some(match result { - Ok(tool_result) => tool_result.content.clone(), - Err(err) => err.to_string(), - }); - if cell_index < app.history.len() - && let Some(detail) = app.tool_details_by_cell.get_mut(&cell_index) - { - detail.output = payload.clone(); - } - // Also write to the active table while the entry might still live there; - // some callsites pre-rewrite cell_index but the active_tool_details map is - // the canonical source for in-flight outputs. - if let Some(detail) = app.active_tool_details.get_mut(tool_id) { - detail.output = payload; - } -} - -#[allow(clippy::too_many_lines)] -fn handle_tool_call_complete( - app: &mut App, - id: &str, - name: &str, - result: &Result, -) { - if app.ignored_tool_calls.remove(id) { - return; - } - - // Exploring entries land in the per-tool map regardless of whether they - // live in the active cell or in finalized history; the path is the same. - if let Some((cell_index, entry_index)) = app.exploring_entries.remove(id) { - app.tool_cells.remove(id); - store_tool_detail_output(app, id, cell_index, result); - if let Some(HistoryCell::Tool(ToolCell::Exploring(cell))) = - app.cell_at_virtual_index_mut(cell_index) - && let Some(entry) = cell.entries.get_mut(entry_index) - { - entry.status = match result.as_ref() { - Ok(tool_result) if tool_result.success => ToolStatus::Success, - Ok(_) | Err(_) => ToolStatus::Failed, - }; - app.mark_history_updated(); - // Mutating the in-flight exploring cell needs an active-cell - // revision bump so the transcript cache invalidates the synthetic - // tail row. - if cell_index >= app.history.len() { - app.active_cell_revision = app.active_cell_revision.wrapping_add(1); - if let Some(active) = app.active_cell.as_mut() { - active.bump_revision(); - } - } - } - return; - } - - // Look up the cell by tool id. If the id isn't registered, that's an - // orphan completion (race condition where the started event was lost or - // a tool result arrived after the active cell was already flushed). Build - // a finalized standalone cell from the result so the user can still see - // the output, but DO NOT touch the active cell. - let Some(cell_index) = app.tool_cells.remove(id) else { - push_orphan_tool_completion(app, id, name, result); - return; - }; - - store_tool_detail_output(app, id, cell_index, result); - let in_active = cell_index >= app.history.len(); - - let status = match result.as_ref() { - Ok(tool_result) => match tool_result.metadata.as_ref() { - Some(meta) - if meta - .get("status") - .and_then(|v| v.as_str()) - .is_some_and(|s| s == "Running") => - { - ToolStatus::Running - } - _ => { - if tool_result.success { - ToolStatus::Success - } else { - ToolStatus::Failed - } - } - }, - Err(_) => ToolStatus::Failed, - }; - - if let Some(cell) = app.cell_at_virtual_index_mut(cell_index) { - match cell { - HistoryCell::Tool(ToolCell::Exec(exec)) => { - exec.status = status; - if let Ok(tool_result) = result.as_ref() { - exec.duration_ms = tool_result - .metadata - .as_ref() - .and_then(|m| m.get("duration_ms")) - .and_then(serde_json::Value::as_u64); - if status != ToolStatus::Running && exec.interaction.is_none() { - exec.output = Some(tool_result.content.clone()); - } - } else if let Err(err) = result.as_ref() - && exec.interaction.is_none() - { - exec.output = Some(err.to_string()); - } - app.mark_history_updated(); - } - HistoryCell::Tool(ToolCell::PlanUpdate(plan)) => { - plan.status = status; - app.mark_history_updated(); - } - HistoryCell::Tool(ToolCell::PatchSummary(patch)) => { - patch.status = status; - match result.as_ref() { - Ok(tool_result) => { - if let Ok(json) = - serde_json::from_str::(&tool_result.content) - && let Some(message) = json.get("message").and_then(|v| v.as_str()) - { - patch.summary = message.to_string(); - } - } - Err(err) => { - patch.error = Some(err.to_string()); - } - } - app.mark_history_updated(); - } - HistoryCell::Tool(ToolCell::Review(review)) => { - review.status = status; - match result.as_ref() { - Ok(tool_result) => { - if tool_result.success { - review.output = Some(ReviewOutput::from_str(&tool_result.content)); - } else { - review.error = Some(tool_result.content.clone()); - } - } - Err(err) => { - review.error = Some(err.to_string()); - } - } - app.mark_history_updated(); - } - HistoryCell::Tool(ToolCell::Mcp(mcp)) => { - match result.as_ref() { - Ok(tool_result) => { - let summary = summarize_mcp_output(&tool_result.content); - if summary.is_error == Some(true) { - mcp.status = ToolStatus::Failed; - } else { - mcp.status = status; - } - mcp.is_image = summary.is_image; - mcp.content = summary.content; - } - Err(err) => { - mcp.status = status; - mcp.content = Some(err.to_string()); - } - } - app.mark_history_updated(); - } - HistoryCell::Tool(ToolCell::WebSearch(search)) => { - search.status = status; - match result.as_ref() { - Ok(tool_result) => { - search.summary = Some(summarize_tool_output(&tool_result.content)); - } - Err(err) => { - search.summary = Some(err.to_string()); - } - } - app.mark_history_updated(); - } - HistoryCell::Tool(ToolCell::Generic(generic)) => { - generic.status = status; - match result.as_ref() { - Ok(tool_result) => { - generic.output = Some(summarize_tool_output(&tool_result.content)); - } - Err(err) => { - generic.output = Some(err.to_string()); - } - } - app.mark_history_updated(); - } - _ => {} - } - } - - // If the mutated cell lived inside the active group, bump the active-cell - // revision so the transcript cache re-renders the synthetic tail row. - if in_active { - app.active_cell_revision = app.active_cell_revision.wrapping_add(1); - if let Some(active) = app.active_cell.as_mut() { - active.bump_revision(); - } - } -} - -/// Build a finalized standalone history cell for a tool completion whose -/// start was never registered (orphan). This preserves the contract that -/// every tool result is visible somewhere; the alternative (silently -/// dropping it) hides errors and breaks debuggability. -/// -/// Choice of cell type: we use `GenericToolCell` because we have no input -/// payload to reconstruct a more specific cell. The pager remains usable — -/// `tool_details_by_cell` is populated with the result text. -/// -/// ## Index drift -/// -/// If an active cell is in flight when the orphan arrives, pushing the -/// orphan into `app.history` shifts every active-cell virtual index forward -/// by 1. We must rewrite `tool_cells` / `exploring_entries` accordingly so -/// later completion lookups still find the right entries. -fn push_orphan_tool_completion( - app: &mut App, - tool_id: &str, - name: &str, - result: &Result, -) { - let status = match result.as_ref() { - Ok(tool_result) => { - if tool_result.success { - ToolStatus::Success - } else { - ToolStatus::Failed - } - } - Err(_) => ToolStatus::Failed, - }; - let output = match result.as_ref() { - Ok(tool_result) => Some(summarize_tool_output(&tool_result.content)), - Err(err) => Some(err.to_string()), - }; - let history_threshold_before_push = app.history.len(); - let active_in_flight = app.active_cell.is_some(); - app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { - name: name.to_string(), - status, - input_summary: None, - output, - prompts: None, - }))); - let cell_index = app.history.len().saturating_sub(1); - app.tool_details_by_cell.insert( - cell_index, - ToolDetailRecord { - tool_id: tool_id.to_string(), - tool_name: name.to_string(), - input: serde_json::Value::Null, - output: match result.as_ref() { - Ok(tool_result) => Some(tool_result.content.clone()), - Err(err) => Some(err.to_string()), - }, - }, - ); - - // Shift active-cell virtual indices forward by 1 to absorb the new - // history cell. Without this, the next completion would address the - // wrong entry. - if active_in_flight { - let threshold = history_threshold_before_push; - for idx in app.tool_cells.values_mut() { - if *idx >= threshold { - *idx = idx.wrapping_add(1); - } - } - for (cell_idx, _) in app.exploring_entries.values_mut() { - if *cell_idx >= threshold { - *cell_idx = cell_idx.wrapping_add(1); - } - } - if let Some(idx) = app.exploring_cell.as_mut() - && *idx >= threshold - { - *idx = idx.wrapping_add(1); - } - } -} - -fn is_exploring_tool(name: &str) -> bool { - matches!(name, "read_file" | "list_dir" | "grep_files" | "list_files") -} - -fn is_exec_tool(name: &str) -> bool { - matches!( - name, - "exec_shell" | "exec_shell_wait" | "exec_shell_interact" | "exec_wait" | "exec_interact" - ) -} - -fn exploring_label(name: &str, input: &serde_json::Value) -> String { - let fallback = format!("{name} tool"); - let obj = input.as_object(); - match name { - "read_file" => obj - .and_then(|o| o.get("path")) - .and_then(|v| v.as_str()) - .map_or(fallback, |path| format!("Reading {path}")), - "list_dir" => obj - .and_then(|o| o.get("path")) - .and_then(|v| v.as_str()) - .map_or("Listing directory".to_string(), |path| { - format!("Listing {path}") - }), - "grep_files" => { - let pattern = obj - .and_then(|o| o.get("pattern")) - .and_then(|v| v.as_str()) - .unwrap_or("pattern"); - format!("Searching for `{pattern}`") - } - "list_files" => "Listing files".to_string(), - _ => fallback, - } -} - -fn is_mcp_tool(name: &str) -> bool { - name.starts_with("mcp_") -} - -fn is_view_image_tool(name: &str) -> bool { - matches!(name, "view_image" | "view_image_file" | "view_image_tool") -} - -fn is_web_search_tool(name: &str) -> bool { - matches!(name, "web_search" | "search_web" | "search" | "web.run") - || name.ends_with("_web_search") -} - -fn web_search_query(input: &serde_json::Value) -> String { - if let Some(searches) = input.get("search_query").and_then(|v| v.as_array()) - && let Some(first) = searches.first() - && let Some(q) = first.get("q").and_then(|v| v.as_str()) - { - return q.to_string(); - } - - input - .get("query") - .or_else(|| input.get("q")) - .or_else(|| input.get("search")) - .and_then(|v| v.as_str()) - .unwrap_or("Web search") - .to_string() -} - -fn review_target_label(input: &serde_json::Value) -> String { - let target = input - .get("target") - .and_then(|v| v.as_str()) - .unwrap_or("review") - .trim(); - let kind = input - .get("kind") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_ascii_lowercase(); - let staged = input - .get("staged") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let target_lower = target.to_ascii_lowercase(); - - if kind == "diff" - || target_lower == "diff" - || target_lower == "git diff" - || target_lower == "staged" - || target_lower == "cached" - { - if staged || target_lower == "staged" || target_lower == "cached" { - return "git diff --cached".to_string(); - } - return "git diff".to_string(); - } - - target.to_string() -} - -fn parse_plan_input(input: &serde_json::Value) -> (Option, Vec) { - let explanation = input - .get("explanation") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string); - let mut steps = Vec::new(); - if let Some(items) = input.get("plan").and_then(|v| v.as_array()) { - for item in items { - let step = item.get("step").and_then(|v| v.as_str()).unwrap_or(""); - let status = item - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("pending"); - if !step.is_empty() { - steps.push(PlanStep { - step: step.to_string(), - status: status.to_string(), - }); - } - } - } - (explanation, steps) -} - -fn parse_patch_summary(input: &serde_json::Value) -> (String, String) { - if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) { - let count = changes.len(); - let path = changes - .first() - .and_then(|c| c.get("path")) - .and_then(|v| v.as_str()) - .map(str::to_string) - .unwrap_or_else(|| "".to_string()); - let label = if count <= 1 { - path - } else { - format!("{count} files") - }; - let summary = format!("Changes: {count} file(s)"); - return (label, summary); - } - - let patch_text = input.get("patch").and_then(|v| v.as_str()).unwrap_or(""); - let paths = extract_patch_paths(patch_text); - let path = input - .get("path") - .and_then(|v| v.as_str()) - .map(str::to_string) - .or_else(|| { - if paths.len() == 1 { - paths.first().cloned() - } else if paths.is_empty() { - None - } else { - Some(format!("{} files", paths.len())) - } - }) - .unwrap_or_else(|| "".to_string()); - - let (adds, removes) = count_patch_changes(patch_text); - let summary = if adds == 0 && removes == 0 { - "Patch applied".to_string() - } else { - format!("Changes: +{adds} / -{removes}") - }; - (path, summary) -} - -fn extract_patch_paths(patch: &str) -> Vec { - let mut paths = Vec::new(); - for line in patch.lines() { - if let Some(rest) = line.strip_prefix("+++ ") { - let raw = rest.trim(); - if raw == "/dev/null" || raw == "dev/null" { - continue; - } - let raw = raw.strip_prefix("b/").unwrap_or(raw); - if !paths.contains(&raw.to_string()) { - paths.push(raw.to_string()); - } - } else if let Some(rest) = line.strip_prefix("diff --git ") { - let parts: Vec<&str> = rest.split_whitespace().collect(); - if let Some(path) = parts.get(1).or_else(|| parts.first()) { - let raw = path.trim(); - let raw = raw - .strip_prefix("b/") - .or_else(|| raw.strip_prefix("a/")) - .unwrap_or(raw); - if !paths.contains(&raw.to_string()) { - paths.push(raw.to_string()); - } - } - } - } - paths -} - -fn maybe_add_patch_preview(app: &mut App, input: &serde_json::Value) { - if let Some(patch) = input.get("patch").and_then(|v| v.as_str()) { - app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell { - title: "Patch Preview".to_string(), - diff: patch.to_string(), - }))); - app.mark_history_updated(); - return; - } - - if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) { - let preview = format_changes_preview(changes); - if !preview.trim().is_empty() { - app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell { - title: "Changes Preview".to_string(), - diff: preview, - }))); - app.mark_history_updated(); - } - } -} - -fn format_changes_preview(changes: &[serde_json::Value]) -> String { - let mut out = String::new(); - for change in changes { - let path = change - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let content = change.get("content").and_then(|v| v.as_str()).unwrap_or(""); - - out.push_str(&format!("diff --git a/{path} b/{path}\n")); - out.push_str(&format!("--- a/{path}\n+++ b/{path}\n")); - out.push_str("@@ -0,0 +1,1 @@\n"); - - let mut count = 0usize; - for line in content.lines() { - out.push('+'); - out.push_str(line); - out.push('\n'); - count += 1; - if count >= 20 { - out.push_str("+... (truncated)\n"); - break; - } - } - if content.is_empty() { - out.push_str("+\n"); - } - } - out -} - -fn count_patch_changes(patch: &str) -> (usize, usize) { - let mut adds = 0; - let mut removes = 0; - for line in patch.lines() { - if line.starts_with("+++") || line.starts_with("---") { - continue; - } - if line.starts_with('+') { - adds += 1; - } else if line.starts_with('-') { - removes += 1; - } - } - (adds, removes) -} - -fn exec_command_from_input(input: &serde_json::Value) -> Option { - input - .get("command") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string) -} - -fn exec_source_from_input(input: &serde_json::Value) -> ExecSource { - match input.get("source").and_then(|v| v.as_str()) { - Some(source) if source.eq_ignore_ascii_case("user") => ExecSource::User, - _ => ExecSource::Assistant, - } -} - -fn exec_interaction_summary(name: &str, input: &serde_json::Value) -> Option<(String, bool)> { - let command = exec_command_from_input(input).unwrap_or_else(|| "".to_string()); - let command_display = format!("\"{command}\""); - let interaction_input = input - .get("input") - .or_else(|| input.get("stdin")) - .or_else(|| input.get("data")) - .and_then(|v| v.as_str()); - - let is_wait_tool = matches!(name, "exec_shell_wait" | "exec_wait"); - let is_interact_tool = matches!(name, "exec_shell_interact" | "exec_interact"); - - if is_interact_tool || interaction_input.is_some() { - let preview = interaction_input.map(summarize_interaction_input); - let summary = if let Some(preview) = preview { - format!("Interacted with {command_display}, sent {preview}") - } else { - format!("Interacted with {command_display}") - }; - return Some((summary, false)); - } - - if is_wait_tool || input.get("wait").and_then(serde_json::Value::as_bool) == Some(true) { - return Some((format!("Waited for {command_display}"), true)); - } - - None -} - -fn summarize_interaction_input(input: &str) -> String { - let mut single_line = input.replace('\r', ""); - single_line = single_line.replace('\n', "\\n"); - single_line = single_line.replace('\"', "'"); - let max_len = 80; - if single_line.chars().count() <= max_len { - return format!("\"{single_line}\""); - } - let mut out = String::new(); - for ch in single_line.chars().take(max_len.saturating_sub(3)) { - out.push(ch); - } - out.push_str("..."); - format!("\"{out}\"") -} - -fn exec_is_background(input: &serde_json::Value) -> bool { - input - .get("background") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) -} - #[cfg(test)] mod tests; diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 0c662fcf..12726e09 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -434,6 +434,30 @@ fn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() { assert!(app.agent_activity_started_at.is_none()); } +#[test] +fn subagent_token_usage_updates_live_cost_counter_without_card_change() { + let mut app = create_test_app(); + handle_subagent_mailbox( + &mut app, + 1, + &crate::tools::subagent::MailboxMessage::TokenUsage { + agent_id: "agent-a".to_string(), + model: "deepseek-v4-flash".to_string(), + usage: crate::models::Usage { + input_tokens: 10_000, + output_tokens: 1_000, + ..Default::default() + }, + }, + ); + + assert!(app.subagent_cost > 0.0); + assert!( + app.history.is_empty(), + "usage-only mailbox messages should not allocate a sub-agent card" + ); +} + #[test] fn format_token_count_compact_formats_units() { assert_eq!(format_token_count_compact(999), "999"); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d673d029..b9806700 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -23,14 +23,14 @@ DeepSeek auth and model defaults. `deepseek login --api-key ...` writes the root `api_key` field that `deepseek-tui` reads directly, and `deepseek --model deepseek-v4-flash` is forwarded to the TUI as `DEEPSEEK_MODEL`. -For NVIDIA NIM-hosted DeepSeek V4 Pro, set `provider = "nvidia-nim"` or pass -`deepseek --provider nvidia-nim`. The facade stores NIM credentials under -`[providers.nvidia_nim]` and forwards the resolved key, base URL, provider, and -model to the TUI process. Use -`deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` to -save the NIM key through the facade. `DEEPSEEK_API_KEY` remains a compatibility -fallback when `DEEPSEEK_PROVIDER=nvidia-nim`, but `NVIDIA_API_KEY` or -`NVIDIA_NIM_API_KEY` is preferred. +For hosted or self-hosted DeepSeek V4 providers, set `provider = "nvidia-nim"`, +`"fireworks"`, or `"sglang"` or pass `deepseek --provider `. The facade +stores provider credentials under `[providers.]` and forwards the resolved +key, base URL, provider, and model to the TUI process. Use +`deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` or +`deepseek auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` to +save hosted-provider keys through the facade. SGLang is self-hosted and can run +without an API key by default. To bootstrap MCP and skills directories at their resolved paths, run `deepseek-tui setup`. To only scaffold MCP, run `deepseek-tui mcp init`. @@ -58,6 +58,15 @@ provider = "nvidia-nim" api_key = "NVIDIA_KEY" base_url = "https://integrate.api.nvidia.com/v1" default_text_model = "deepseek-ai/deepseek-v4-pro" + +[profiles.fireworks] +provider = "fireworks" +default_text_model = "accounts/fireworks/models/deepseek-v4-pro" + +[profiles.sglang] +provider = "sglang" +base_url = "http://localhost:30000/v1" +default_text_model = "deepseek-ai/DeepSeek-V4-Pro" ``` Select a profile with: @@ -73,11 +82,16 @@ These override config values: - `DEEPSEEK_API_KEY` - `DEEPSEEK_BASE_URL` -- `DEEPSEEK_PROVIDER` (`deepseek|nvidia-nim`) +- `DEEPSEEK_PROVIDER` (`deepseek|nvidia-nim|openrouter|novita|fireworks|sglang`) - `DEEPSEEK_MODEL` or `DEEPSEEK_DEFAULT_TEXT_MODEL` - `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (preferred when provider is `nvidia-nim`; falls back to `DEEPSEEK_API_KEY`) - `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL` - `NVIDIA_NIM_MODEL` +- `FIREWORKS_API_KEY` +- `FIREWORKS_BASE_URL` +- `SGLANG_BASE_URL` +- `SGLANG_MODEL` +- `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth) - `DEEPSEEK_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs) - `DEEPSEEK_SKILLS_DIR` - `DEEPSEEK_MCP_CONFIG` @@ -155,10 +169,10 @@ If you are upgrading from older releases: ### Core keys (used by the TUI/engine) -- `provider` (string, optional): `deepseek` (default) or `nvidia-nim`. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`. +- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openrouter`, `novita`, `fireworks`, or `sglang`. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`. - `api_key` (string, required): must be non-empty (or set `DEEPSEEK_API_KEY`). - `base_url` (string, optional): defaults to `https://api.deepseek.com` for DeepSeek's OpenAI-compatible Chat Completions API, or `https://integrate.api.nvidia.com/v1` for `provider = "nvidia-nim"`. `https://api.deepseek.com/v1` is also accepted for SDK compatibility; use `https://api.deepseek.com/beta` only for DeepSeek beta features such as strict tool mode, chat prefix completion, and FIM completion. -- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek or `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash`. With `provider = "nvidia-nim"`, `deepseek-v4-pro` maps to `deepseek-ai/deepseek-v4-pro` and `deepseek-v4-flash` maps to `deepseek-ai/deepseek-v4-flash`. Use `/models` or `deepseek models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process. +- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, and `deepseek-ai/DeepSeek-V4-Pro` for SGLang. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash`. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Use `/models` or `deepseek models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process. - `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). - `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.