From 0f8c363012eb8e69bfe2b0c58d2561cc6ba3e2fe Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 28 Apr 2026 21:54:43 -0500 Subject: [PATCH] fix: clean v0.7.2 release prep --- assets/Screenshot 2026-04-28 at 21.12.34.png | Bin 85993 -> 0 bytes crates/agent/Cargo.toml | 2 +- crates/app-server/Cargo.toml | 18 +- crates/cli/Cargo.toml | 14 +- crates/config/Cargo.toml | 2 +- crates/core/Cargo.toml | 16 +- crates/execpolicy/Cargo.toml | 2 +- crates/hooks/Cargo.toml | 2 +- crates/mcp/Cargo.toml | 2 +- crates/tools/Cargo.toml | 2 +- crates/tools/src/lib.rs | 37 +- crates/tui/Cargo.toml | 4 +- crates/tui/src/client.rs | 18 +- crates/tui/src/client.rs.bak2 | 2213 ------ crates/tui/src/config.rs | 21 +- crates/tui/src/core/engine.rs | 37 +- crates/tui/src/core/engine.rs.bak | 2853 -------- crates/tui/src/cycle_manager.rs.bak3 | 1014 --- crates/tui/src/models.rs | 4 +- crates/tui/src/seam_manager.rs | 2 +- crates/tui/src/tools/subagent/mailbox.rs | 26 +- crates/tui/src/tools/subagent/mod.rs | 4 +- crates/tui/src/tui/history.rs | 170 +- crates/tui/src/tui/provider_picker.rs | 9 +- crates/tui/src/tui/ui.rs | 11 +- crates/tui/src/tui/ui.rs.bak3 | 6635 ------------------ crates/tui/src/tui/widgets/footer.rs | 5 +- npm/deepseek-tui/package.json | 4 +- 28 files changed, 225 insertions(+), 12902 deletions(-) delete mode 100644 assets/Screenshot 2026-04-28 at 21.12.34.png delete mode 100644 crates/tui/src/client.rs.bak2 delete mode 100644 crates/tui/src/core/engine.rs.bak delete mode 100644 crates/tui/src/cycle_manager.rs.bak3 delete mode 100644 crates/tui/src/tui/ui.rs.bak3 diff --git a/assets/Screenshot 2026-04-28 at 21.12.34.png b/assets/Screenshot 2026-04-28 at 21.12.34.png deleted file mode 100644 index 11d64e53cb94fe55f39e3cac9d5b8996f1ef2559..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 85993 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�{EO#@~Vf|;n(@f*uZUGJ`EH)Ye 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 DS#%K^ diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 76475c2c..a879f4ea 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -deepseek-config = { path = "../config", version = "0.7.1" } +deepseek-config = { path = "../config", version = "0.7.2" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index ff7daedb..9a266a90 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -deepseek-agent = { path = "../agent", version = "0.7.1" } -deepseek-config = { path = "../config", version = "0.7.1" } -deepseek-core = { path = "../core", version = "0.7.1" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.7.1" } -deepseek-hooks = { path = "../hooks", version = "0.7.1" } -deepseek-mcp = { path = "../mcp", version = "0.7.1" } -deepseek-protocol = { path = "../protocol", version = "0.7.1" } -deepseek-state = { path = "../state", version = "0.7.1" } -deepseek-tools = { path = "../tools", version = "0.7.1" } +deepseek-agent = { path = "../agent", version = "0.7.2" } +deepseek-config = { path = "../config", version = "0.7.2" } +deepseek-core = { path = "../core", version = "0.7.2" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.7.2" } +deepseek-hooks = { path = "../hooks", version = "0.7.2" } +deepseek-mcp = { path = "../mcp", version = "0.7.2" } +deepseek-protocol = { path = "../protocol", version = "0.7.2" } +deepseek-state = { path = "../state", version = "0.7.2" } +deepseek-tools = { path = "../tools", version = "0.7.2" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 9a571337..c50a18cf 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,13 +14,13 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -deepseek-agent = { path = "../agent", version = "0.7.1" } -deepseek-app-server = { path = "../app-server", version = "0.7.1" } -deepseek-config = { path = "../config", version = "0.7.1" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.7.1" } -deepseek-mcp = { path = "../mcp", version = "0.7.1" } -deepseek-secrets = { path = "../secrets", version = "0.7.1" } -deepseek-state = { path = "../state", version = "0.7.1" } +deepseek-agent = { path = "../agent", version = "0.7.2" } +deepseek-app-server = { path = "../app-server", version = "0.7.2" } +deepseek-config = { path = "../config", version = "0.7.2" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.7.2" } +deepseek-mcp = { path = "../mcp", version = "0.7.2" } +deepseek-secrets = { path = "../secrets", version = "0.7.2" } +deepseek-state = { path = "../state", version = "0.7.2" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index a4efcda5..fe82ca35 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,7 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -deepseek-secrets = { path = "../secrets", version = "0.7.1" } +deepseek-secrets = { path = "../secrets", version = "0.7.2" } dirs.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index a6234cf9..b02f71ed 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,14 +9,14 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -deepseek-agent = { path = "../agent", version = "0.7.1" } -deepseek-config = { path = "../config", version = "0.7.1" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.7.1" } -deepseek-hooks = { path = "../hooks", version = "0.7.1" } -deepseek-mcp = { path = "../mcp", version = "0.7.1" } -deepseek-protocol = { path = "../protocol", version = "0.7.1" } -deepseek-state = { path = "../state", version = "0.7.1" } -deepseek-tools = { path = "../tools", version = "0.7.1" } +deepseek-agent = { path = "../agent", version = "0.7.2" } +deepseek-config = { path = "../config", version = "0.7.2" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.7.2" } +deepseek-hooks = { path = "../hooks", version = "0.7.2" } +deepseek-mcp = { path = "../mcp", version = "0.7.2" } +deepseek-protocol = { path = "../protocol", version = "0.7.2" } +deepseek-state = { path = "../state", version = "0.7.2" } +deepseek-tools = { path = "../tools", version = "0.7.2" } serde_json.workspace = true tokio.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index ac03c686..adf629bf 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.7.1" } +deepseek-protocol = { path = "../protocol", version = "0.7.2" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 14b80c0e..6d37003b 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.7.1" } +deepseek-protocol = { path = "../protocol", version = "0.7.2" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index f5e6a062..e7b70474 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -8,6 +8,6 @@ description = "MCP server lifecycle and tool proxy compatibility for DeepSeek wo [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.7.1" } +deepseek-protocol = { path = "../protocol", version = "0.7.2" } serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index fe5f51b6..7db2206e 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.7.1" } +deepseek-protocol = { path = "../protocol", version = "0.7.2" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 47a9fea5..d2344a49 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -185,26 +185,23 @@ impl ToolResult { /// Helper to extract a required string field from JSON input. pub fn required_str<'a>(input: &'a Value, field: &str) -> std::result::Result<&'a str, ToolError> { - input - .get(field) - .and_then(Value::as_str) - .ok_or_else(|| { - // When the field is missing, list the fields the caller *did* - // supply so the model can spot the mismatch without a retry. - let provided: Vec<&str> = input - .as_object() - .map(|obj| obj.keys().map(|k| k.as_str()).collect()) - .unwrap_or_default(); - if provided.is_empty() { - ToolError::missing_field(field) - } else { - let hint = format!( - "missing required field '{field}'. Input provided: {}", - provided.join(", ") - ); - ToolError::invalid_input(hint) - } - }) + input.get(field).and_then(Value::as_str).ok_or_else(|| { + // When the field is missing, list the fields the caller *did* + // supply so the model can spot the mismatch without a retry. + let provided: Vec<&str> = input + .as_object() + .map(|obj| obj.keys().map(|k| k.as_str()).collect()) + .unwrap_or_default(); + if provided.is_empty() { + ToolError::missing_field(field) + } else { + let hint = format!( + "missing required field '{field}'. Input provided: {}", + provided.join(", ") + ); + ToolError::invalid_input(hint) + } + }) } /// Helper to extract an optional string field from JSON input. diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 9e3fa655..8687fd25 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -13,8 +13,8 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -deepseek-secrets = { path = "../secrets", version = "0.7.1" } -deepseek-tools = { path = "../tools", version = "0.7.1" } +deepseek-secrets = { path = "../secrets", version = "0.7.2" } +deepseek-tools = { path = "../tools", version = "0.7.2" } async-stream = "0.3.6" async-trait = "0.1" bytes = "1.11.0" diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index e31f182e..7d5d9ec9 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -750,7 +750,11 @@ pub(super) fn apply_reasoning_effort( "off" | "disabled" | "none" | "false" => match provider { // OpenRouter / Novita relay the same DeepSeek V4 payload shape // as DeepSeek native; they pass through `thinking` / `reasoning_effort`. - ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Fireworks | ApiProvider::Sglang => { + ApiProvider::Deepseek + | ApiProvider::Openrouter + | ApiProvider::Novita + | ApiProvider::Fireworks + | ApiProvider::Sglang => { body["thinking"] = json!({ "type": "disabled" }); } ApiProvider::NvidiaNim => { @@ -760,7 +764,11 @@ pub(super) fn apply_reasoning_effort( } }, "low" | "minimal" | "medium" | "mid" | "high" | "" => match provider { - ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Fireworks | ApiProvider::Sglang => { + ApiProvider::Deepseek + | ApiProvider::Openrouter + | ApiProvider::Novita + | ApiProvider::Fireworks + | ApiProvider::Sglang => { body["reasoning_effort"] = json!("high"); body["thinking"] = json!({ "type": "enabled" }); } @@ -772,7 +780,11 @@ pub(super) fn apply_reasoning_effort( } }, "xhigh" | "max" | "highest" => match provider { - ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Fireworks | ApiProvider::Sglang => { + ApiProvider::Deepseek + | ApiProvider::Openrouter + | ApiProvider::Novita + | ApiProvider::Fireworks + | ApiProvider::Sglang => { body["reasoning_effort"] = json!("max"); body["thinking"] = json!({ "type": "enabled" }); } diff --git a/crates/tui/src/client.rs.bak2 b/crates/tui/src/client.rs.bak2 deleted file mode 100644 index c5cead59..00000000 --- a/crates/tui/src/client.rs.bak2 +++ /dev/null @@ -1,2213 +0,0 @@ -//! HTTP client for DeepSeek's OpenAI-compatible Chat Completions API. -//! -//! DeepSeek documents `/chat/completions` as the primary endpoint. A legacy -//! Responses probe remains available behind `DEEPSEEK_EXPERIMENTAL_RESPONSES_API` -//! for local compatibility experiments, but normal traffic uses chat completions. - -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::{Arc, Mutex as StdMutex, OnceLock}; -use std::time::{Duration, Instant}; - -use anyhow::{Context, Result}; -use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use tokio::sync::Mutex as AsyncMutex; - -use crate::config::{ApiProvider, Config, RetryPolicy}; -use crate::llm_client::{ - LlmClient, LlmError, RetryConfig as LlmRetryConfig, StreamEventBox, extract_retry_after, - with_retry, -}; -use crate::logging; -use crate::models::{MessageRequest, MessageResponse, ServerToolUsage, SystemPrompt, Usage}; - -pub(super) fn to_api_tool_name(name: &str) -> String { - let mut out = String::new(); - for ch in name.chars() { - if ch.is_ascii_alphanumeric() || ch == '_' { - out.push(ch); - } else if ch == '-' { - out.push_str("--"); - } else { - out.push_str("-x"); - out.push_str(&format!("{:06X}", ch as u32)); - out.push('-'); - } - } - out -} - -pub(super) fn from_api_tool_name(name: &str) -> String { - let mut out = String::new(); - let mut iter = name.chars().peekable(); - while let Some(ch) = iter.next() { - if ch != '-' { - out.push(ch); - continue; - } - if let Some('-') = iter.peek().copied() { - iter.next(); - out.push('-'); - continue; - } - if iter.peek().copied() == Some('x') { - iter.next(); - let mut hex = String::new(); - for _ in 0..6 { - if let Some(h) = iter.next() { - hex.push(h); - } else { - break; - } - } - if let Ok(code) = u32::from_str_radix(&hex, 16) - && let Some(decoded) = std::char::from_u32(code) - { - if let Some('-') = iter.peek().copied() { - iter.next(); - } - out.push(decoded); - continue; - } - out.push('-'); - out.push('x'); - out.push_str(&hex); - continue; - } - out.push('-'); - } - - // Second pass: decode bare hex escapes (e.g. `x00002E`) that the model - // may produce when it mangles the `-x00002E-` delimiter form. Only - // decode when the resulting character is one that `to_api_tool_name` - // would have encoded (not alphanumeric, not `_`, not `-`). - decode_bare_hex_escapes(&out) -} - -/// Decode bare `x[0-9A-Fa-f]{6}` sequences (optionally followed by `-`) -/// that survive the standard delimiter-based pass. This handles cases -/// where the model strips or replaces the leading `-` of `-x00002E-`. -pub(super) fn decode_bare_hex_escapes(input: &str) -> String { - use regex::Regex; - use std::sync::OnceLock; - - static RE: OnceLock = OnceLock::new(); - let re = RE.get_or_init(|| Regex::new(r"x([0-9A-Fa-f]{6})-?").unwrap()); - - let result = re.replace_all(input, |caps: ®ex::Captures| { - let hex = &caps[1]; - if let Ok(code) = u32::from_str_radix(hex, 16) - && let Some(decoded) = std::char::from_u32(code) - { - // Only decode characters that to_api_tool_name would have encoded - if !decoded.is_ascii_alphanumeric() && decoded != '_' && decoded != '-' { - return decoded.to_string(); - } - } - // Not a character we'd encode — leave as-is - caps[0].to_string() - }); - result.into_owned() -} - -// === Types === - -/// Model descriptor returned by the provider's `/v1/models` endpoint. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct AvailableModel { - pub id: String, - pub owned_by: Option, - pub created: Option, -} - -/// Client for DeepSeek's OpenAI-compatible APIs. -#[must_use] -pub struct DeepSeekClient { - pub(super) http_client: reqwest::Client, - api_key: String, - pub(super) base_url: String, - pub(super) api_provider: ApiProvider, - retry: RetryPolicy, - default_model: String, - use_chat_completions: AtomicBool, - /// Counter of chat-completions requests since last experimental Responses API probe. - /// After RESPONSES_RECOVERY_INTERVAL requests, we retry the Responses API when - /// `DEEPSEEK_EXPERIMENTAL_RESPONSES_API` is set. - chat_fallback_counter: AtomicU32, - connection_health: Arc>, - rate_limiter: Arc>, -} - -/// After this many chat-completions requests, retry the experimental Responses -/// API to see if it has recovered. -const RESPONSES_RECOVERY_INTERVAL: u32 = 20; -const CONNECTION_FAILURE_THRESHOLD: u32 = 2; -const RECOVERY_PROBE_COOLDOWN: Duration = Duration::from_secs(15); - -const DEFAULT_CLIENT_RATE_LIMIT_RPS: f64 = 8.0; -const DEFAULT_CLIENT_RATE_LIMIT_BURST: f64 = 16.0; -const ALLOW_INSECURE_HTTP_ENV: &str = "DEEPSEEK_ALLOW_INSECURE_HTTP"; -const EXPERIMENTAL_RESPONSES_API_ENV: &str = "DEEPSEEK_EXPERIMENTAL_RESPONSES_API"; - -pub(super) const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 8 * 1024 * 1024; // 8 MB -pub(super) const SSE_BACKPRESSURE_SLEEP_MS: u64 = 10; -pub(super) const SSE_MAX_LINES_PER_CHUNK: usize = 256; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ConnectionState { - Healthy, - Degraded, - Recovering, -} - -#[derive(Debug)] -struct ConnectionHealth { - state: ConnectionState, - consecutive_failures: u32, - last_failure: Option, - last_success: Option, - last_probe: Option, -} - -impl Default for ConnectionHealth { - fn default() -> Self { - Self { - state: ConnectionState::Healthy, - consecutive_failures: 0, - last_failure: None, - last_success: None, - last_probe: None, - } - } -} - -#[derive(Debug)] -struct TokenBucket { - enabled: bool, - capacity: f64, - tokens: f64, - refill_per_sec: f64, - last_refill: Instant, -} - -impl TokenBucket { - fn from_env() -> Self { - let rps = std::env::var("DEEPSEEK_RATE_LIMIT_RPS") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_CLIENT_RATE_LIMIT_RPS) - .max(0.0); - let burst = std::env::var("DEEPSEEK_RATE_LIMIT_BURST") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_CLIENT_RATE_LIMIT_BURST) - .max(1.0); - let enabled = rps > 0.0; - Self { - enabled, - capacity: burst, - tokens: burst, - refill_per_sec: rps, - last_refill: Instant::now(), - } - } - - fn refill(&mut self, now: Instant) { - if !self.enabled { - return; - } - let elapsed = now.duration_since(self.last_refill).as_secs_f64(); - self.last_refill = now; - self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity); - } - - fn delay_until_available(&mut self, tokens: f64) -> Option { - if !self.enabled { - return None; - } - let now = Instant::now(); - self.refill(now); - if self.tokens >= tokens { - self.tokens -= tokens; - return None; - } - let needed = tokens - self.tokens; - self.tokens = 0.0; - if self.refill_per_sec <= 0.0 { - return Some(Duration::from_secs(1)); - } - Some(Duration::from_secs_f64(needed / self.refill_per_sec)) - } -} - -fn apply_request_success(health: &mut ConnectionHealth, now: Instant) -> bool { - let recovered = health.state != ConnectionState::Healthy; - health.state = ConnectionState::Healthy; - health.consecutive_failures = 0; - health.last_success = Some(now); - recovered -} - -fn apply_request_failure(health: &mut ConnectionHealth, now: Instant) { - health.consecutive_failures = health.consecutive_failures.saturating_add(1); - health.last_failure = Some(now); - if health.consecutive_failures >= CONNECTION_FAILURE_THRESHOLD { - health.state = ConnectionState::Degraded; - } -} - -fn mark_recovery_probe_if_due(health: &mut ConnectionHealth, now: Instant) -> bool { - if health.state == ConnectionState::Healthy { - return false; - } - if health - .last_probe - .is_some_and(|last| now.duration_since(last) < RECOVERY_PROBE_COOLDOWN) - { - return false; - } - health.last_probe = Some(now); - health.state = ConnectionState::Recovering; - true -} - -fn buffer_pool() -> &'static StdMutex>> { - static POOL: OnceLock>>> = OnceLock::new(); - POOL.get_or_init(|| StdMutex::new(Vec::new())) -} - -fn acquire_stream_buffer() -> Vec { - if let Ok(mut pool) = buffer_pool().lock() { - pool.pop().unwrap_or_else(|| Vec::with_capacity(8192)) - } else { - Vec::with_capacity(8192) - } -} - -fn release_stream_buffer(mut buf: Vec) { - buf.clear(); - if buf.capacity() > 256 * 1024 { - buf.shrink_to(256 * 1024); - } - if let Ok(mut pool) = buffer_pool().lock() - && pool.len() < 8 - { - pool.push(buf); - } -} - -impl Clone for DeepSeekClient { - fn clone(&self) -> Self { - Self { - http_client: self.http_client.clone(), - api_key: self.api_key.clone(), - base_url: self.base_url.clone(), - api_provider: self.api_provider, - retry: self.retry.clone(), - default_model: self.default_model.clone(), - use_chat_completions: AtomicBool::new( - self.use_chat_completions.load(Ordering::Relaxed), - ), - chat_fallback_counter: AtomicU32::new( - self.chat_fallback_counter.load(Ordering::Relaxed), - ), - connection_health: self.connection_health.clone(), - rate_limiter: self.rate_limiter.clone(), - } - } -} - -// === Helpers === - -/// Maximum bytes to read from an error response body (64 KB). -pub(super) const ERROR_BODY_MAX_BYTES: usize = 64 * 1024; - -/// Read an error response body with a size limit to prevent unbounded allocation. -pub(super) async fn bounded_error_text(response: reqwest::Response, max_bytes: usize) -> String { - use futures_util::StreamExt; - let mut stream = response.bytes_stream(); - let mut buf = Vec::with_capacity(max_bytes.min(8192)); - while let Some(chunk) = stream.next().await { - let Ok(chunk) = chunk else { break }; - let remaining = max_bytes.saturating_sub(buf.len()); - if remaining == 0 { - break; - } - buf.extend_from_slice(&chunk[..chunk.len().min(remaining)]); - } - String::from_utf8_lossy(&buf).into_owned() -} - -fn validate_base_url_security(base_url: &str) -> Result<()> { - if base_url.starts_with("https://") - || base_url.starts_with("http://localhost") - || base_url.starts_with("http://127.0.0.1") - || base_url.starts_with("http://[::1]") - { - return Ok(()); - } - - if base_url.starts_with("http://") - && std::env::var(ALLOW_INSECURE_HTTP_ENV) - .ok() - .as_deref() - .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) - { - logging::warn(format!( - "Using insecure HTTP base URL because {} is set", - ALLOW_INSECURE_HTTP_ENV - )); - return Ok(()); - } - - if base_url.starts_with("http://") { - anyhow::bail!( - "Refusing insecure base URL '{}'. Use HTTPS or set {}=1 to override for trusted environments.", - base_url, - ALLOW_INSECURE_HTTP_ENV - ); - } - - anyhow::bail!( - "Refusing base URL '{}': only HTTPS (or explicitly allowed HTTP) URLs are supported.", - base_url, - ) -} - -fn experimental_responses_api_enabled() -> bool { - std::env::var(EXPERIMENTAL_RESPONSES_API_ENV) - .ok() - .as_deref() - .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) -} - -pub(super) fn versioned_base_url(base_url: &str) -> String { - let trimmed = base_url.trim_end_matches('/'); - if trimmed.ends_with("/v1") || trimmed.ends_with("/beta") { - trimmed.to_string() - } else { - format!("{trimmed}/v1") - } -} - -pub(super) fn api_url(base_url: &str, path: &str) -> String { - format!( - "{}/{}", - versioned_base_url(base_url).trim_end_matches('/'), - path.trim_start_matches('/') - ) -} - -// === DeepSeekClient === - -/// Returns true when DEEPSEEK_FORCE_HTTP1 is set to a truthy value -/// (`1`, `true`, `yes`, `on`, case-insensitive). Used by `build_http_client` -/// to opt out of HTTP/2 entirely when DeepSeek's edge mishandles long-lived H2 -/// streams (#103). Anything else (unset, `0`, `false`, ...) leaves HTTP/2 on. -fn force_http1_from_env() -> bool { - std::env::var("DEEPSEEK_FORCE_HTTP1") - .ok() - .map(|v| v.trim().to_ascii_lowercase()) - .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on")) -} - -impl DeepSeekClient { - /// Create a DeepSeek client from CLI configuration. - pub fn new(config: &Config) -> Result { - let api_key = config.deepseek_api_key()?; - let base_url = config.deepseek_base_url(); - let api_provider = config.api_provider(); - validate_base_url_security(&base_url)?; - let retry = config.retry_policy(); - let default_model = config.default_model(); - - logging::info(format!("API provider: {}", api_provider.as_str())); - logging::info(format!("API base URL: {base_url}")); - logging::info(format!( - "Retry policy: enabled={}, max_retries={}, initial_delay={}s, max_delay={}s", - retry.enabled, retry.max_retries, retry.initial_delay, retry.max_delay - )); - - let http_client = Self::build_http_client(&api_key)?; - - Ok(Self { - http_client, - api_key, - base_url, - api_provider, - retry, - default_model, - use_chat_completions: AtomicBool::new(false), - chat_fallback_counter: AtomicU32::new(0), - connection_health: Arc::new(AsyncMutex::new(ConnectionHealth::default())), - rate_limiter: Arc::new(AsyncMutex::new(TokenBucket::from_env())), - }) - } - - 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}"))?, - ); - let mut builder = reqwest::Client::builder() - .default_headers(headers) - .connect_timeout(Duration::from_secs(30)) - // The blanket 300s request timeout was incompatible with V4-pro - // thinking turns that legitimately exceed that wall-clock window - // (see #103). Drop it; per-chunk and per-stream guards in - // engine.rs already bound how long we'll wait without progress. - .tcp_keepalive(Some(Duration::from_secs(30))) - .http2_keep_alive_interval(Some(Duration::from_secs(15))) - .http2_keep_alive_timeout(Duration::from_secs(20)) - .min_tls_version(reqwest::tls::Version::TLS_1_2); - // Escape hatch (#103): some DeepSeek edge nodes mishandle long-lived - // HTTP/2 streams. Setting DEEPSEEK_FORCE_HTTP1=1 pins the client to - // HTTP/1.1 so users can experiment without us committing to that - // path as the default. - if force_http1_from_env() { - logging::info("DEEPSEEK_FORCE_HTTP1=1 — pinning HTTP client to HTTP/1.1"); - builder = builder.http1_only(); - } - builder.build().map_err(Into::into) - } - - /// List available models from the provider. - pub async fn list_models(&self) -> Result> { - let url = api_url(&self.base_url, "models"); - let response = self.send_with_retry(|| self.http_client.get(&url)).await?; - - let status = response.status(); - if !status.is_success() { - let error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await; - anyhow::bail!("Failed to list models: HTTP {status}: {error_text}"); - } - let response_text = response.text().await.unwrap_or_default(); - - parse_models_response(&response_text) - } - - async fn wait_for_rate_limit(&self) { - let maybe_delay = { - let mut limiter = self.rate_limiter.lock().await; - limiter.delay_until_available(1.0) - }; - if let Some(delay) = maybe_delay { - tokio::time::sleep(delay).await; - } - } - - async fn mark_request_success(&self) { - let mut health = self.connection_health.lock().await; - if apply_request_success(&mut health, Instant::now()) { - logging::info("Connection recovered"); - } - } - - async fn mark_request_failure(&self, reason: &str) { - let mut health = self.connection_health.lock().await; - apply_request_failure(&mut health, Instant::now()); - logging::warn(format!( - "Connection degraded (failures={}): {}", - health.consecutive_failures, reason - )); - } - - async fn maybe_probe_recovery(&self) { - let should_probe = { - let mut health = self.connection_health.lock().await; - mark_recovery_probe_if_due(&mut health, Instant::now()) - }; - if !should_probe { - return; - } - let health_url = api_url(&self.base_url, "models"); - let probe = self.http_client.get(health_url).send().await; - match probe { - Ok(resp) if resp.status().is_success() => { - self.mark_request_success().await; - logging::info("Recovery probe succeeded"); - } - Ok(resp) => { - self.mark_request_failure(&format!("probe status={}", resp.status())) - .await; - } - Err(err) => { - self.mark_request_failure(&format!("probe error={err}")) - .await; - } - } - } - - pub(super) async fn send_with_retry(&self, mut build: F) -> Result - where - F: FnMut() -> reqwest::RequestBuilder, - { - let retry_cfg: LlmRetryConfig = self.retry.clone().into(); - let request_result = with_retry( - &retry_cfg, - || { - let request = build(); - async move { - self.wait_for_rate_limit().await; - let response = request - .send() - .await - .map_err(|err| LlmError::from_reqwest(&err))?; - let status = response.status(); - if status.is_success() { - return Ok(response); - } - let retryable = status.as_u16() == 429 || status.is_server_error(); - if !retryable { - return Ok(response); - } - let retry_after = extract_retry_after(response.headers()); - let body = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await; - Err(LlmError::from_http_response_with_retry_after( - status.as_u16(), - &body, - retry_after, - )) - } - }, - Some(Box::new(|err, attempt, delay| { - logging::warn(format!( - "HTTP retry reason={} attempt={} delay={:.2}s", - match err { - LlmError::RateLimited { .. } => "rate_limited", - LlmError::ServerError { .. } => "server_error", - LlmError::NetworkError(_) => "network_error", - LlmError::Timeout(_) => "timeout", - _ => "other", - }, - attempt + 1, - delay.as_secs_f64(), - )); - })), - ) - .await; - - match request_result { - Ok(response) => { - self.mark_request_success().await; - Ok(response) - } - Err(err) => { - self.mark_request_failure(&err.to_string()).await; - self.maybe_probe_recovery().await; - Err(anyhow::anyhow!(err.to_string())) - } - } - } -} - -impl LlmClient for DeepSeekClient { - fn provider_name(&self) -> &'static str { - self.api_provider.as_str() - } - - fn model(&self) -> &str { - &self.default_model - } - - async fn health_check(&self) -> Result { - let health_url = api_url(&self.base_url, "models"); - self.wait_for_rate_limit().await; - let response = self.http_client.get(health_url).send().await; - match response { - Ok(resp) if resp.status().is_success() => { - self.mark_request_success().await; - Ok(true) - } - Ok(resp) => { - self.mark_request_failure(&format!("health status={}", resp.status())) - .await; - Ok(false) - } - Err(err) => { - self.mark_request_failure(&format!("health error={err}")) - .await; - Ok(false) - } - } - } - - async fn create_message(&self, request: MessageRequest) -> Result { - if !experimental_responses_api_enabled() { - return self.create_message_chat(&request).await; - } - - // Check if it's time to probe Responses API recovery - if self.use_chat_completions.load(Ordering::Relaxed) { - let count = self.chat_fallback_counter.fetch_add(1, Ordering::Relaxed); - if count > 0 && count.is_multiple_of(RESPONSES_RECOVERY_INTERVAL) { - logging::info("Probing Responses API recovery..."); - let request_clone = request.clone(); - match self.create_message_responses(&request).await? { - Ok(message) => { - logging::info("Responses API recovered! Switching back."); - self.use_chat_completions.store(false, Ordering::Relaxed); - self.chat_fallback_counter.store(0, Ordering::Relaxed); - return Ok(message); - } - Err(_) => { - logging::info("Responses API still unavailable, continuing with chat."); - } - } - return self.create_message_chat(&request_clone).await; - } - return self.create_message_chat(&request).await; - } - - let request_clone = request.clone(); - match self.create_message_responses(&request).await? { - Ok(message) => Ok(message), - Err(fallback) => { - logging::warn(format!( - "Responses API unavailable (HTTP {}). Falling back to chat completions.", - fallback.status - )); - logging::info(format!( - "Responses fallback body: {}", - crate::utils::truncate_with_ellipsis(&fallback.body, 500, "...") - )); - self.use_chat_completions.store(true, Ordering::Relaxed); - self.chat_fallback_counter.store(0, Ordering::Relaxed); - self.create_message_chat(&request_clone).await - } - } - } - - async fn create_message_stream(&self, request: MessageRequest) -> Result { - self.handle_chat_completion_stream(request).await - } -} - -#[derive(Debug, Deserialize)] -struct ModelsListResponse { - data: Vec, -} - -#[derive(Debug, Deserialize)] -struct ModelListItem { - id: String, - #[serde(default)] - owned_by: Option, - #[serde(default)] - created: Option, -} - -pub(super) fn parse_models_response(payload: &str) -> Result> { - let parsed: ModelsListResponse = - serde_json::from_str(payload).context("Failed to parse model list JSON")?; - - let mut models = parsed - .data - .into_iter() - .map(|item| AvailableModel { - id: item.id, - owned_by: item.owned_by, - created: item.created, - }) - .collect::>(); - models.sort_by(|a, b| a.id.cmp(&b.id)); - models.dedup_by(|a, b| a.id == b.id); - Ok(models) -} - -pub(super) fn system_to_instructions(system: Option) -> Option { - match system { - Some(SystemPrompt::Text(text)) => Some(text), - Some(SystemPrompt::Blocks(blocks)) => { - let joined = blocks - .into_iter() - .map(|b| b.text) - .collect::>() - .join("\n\n---\n\n"); - if joined.trim().is_empty() { - None - } else { - Some(joined) - } - } - None => None, - } -} - -pub(super) fn apply_reasoning_effort( - body: &mut Value, - effort: Option<&str>, - provider: ApiProvider, -) { - let Some(effort) = effort else { - return; - }; - let normalized = effort.trim().to_ascii_lowercase(); - match normalized.as_str() { - "off" | "disabled" | "none" | "false" => match provider { - // OpenRouter / Novita relay the same DeepSeek V4 payload shape - // as DeepSeek native; they pass through `thinking` / `reasoning_effort`. - ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita => { - body["thinking"] = json!({ "type": "disabled" }); - } - ApiProvider::NvidiaNim => { - body["chat_template_kwargs"] = json!({ - "thinking": false, - }); - } - }, - "low" | "minimal" | "medium" | "mid" | "high" | "" => match provider { - ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita => { - body["reasoning_effort"] = json!("high"); - body["thinking"] = json!({ "type": "enabled" }); - } - ApiProvider::NvidiaNim => { - body["chat_template_kwargs"] = json!({ - "thinking": true, - "reasoning_effort": "high", - }); - } - }, - "xhigh" | "max" | "highest" => match provider { - ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita => { - body["reasoning_effort"] = json!("max"); - body["thinking"] = json!({ "type": "enabled" }); - } - ApiProvider::NvidiaNim => { - body["chat_template_kwargs"] = json!({ - "thinking": true, - "reasoning_effort": "max", - }); - } - }, - _ => { - // Unknown value — do not mutate the request, let the provider - // apply its own defaults. - } - } -} - -pub(super) fn parse_usage(usage: Option<&Value>) -> Usage { - let input_tokens = usage - .and_then(|u| u.get("input_tokens").or_else(|| u.get("prompt_tokens"))) - .and_then(Value::as_u64) - .unwrap_or(0); - let output_tokens = usage - .and_then(|u| { - u.get("output_tokens") - .or_else(|| u.get("completion_tokens")) - }) - .and_then(Value::as_u64) - .unwrap_or(0); - let prompt_cache_hit_tokens = usage - .and_then(|u| u.get("prompt_cache_hit_tokens")) - .and_then(Value::as_u64) - .map(|v| v as u32); - let prompt_cache_miss_tokens = usage - .and_then(|u| u.get("prompt_cache_miss_tokens")) - .and_then(Value::as_u64) - .map(|v| v as u32); - let reasoning_tokens = usage - .and_then(|u| u.get("completion_tokens_details")) - .and_then(|details| details.get("reasoning_tokens")) - .and_then(Value::as_u64) - .map(|v| v as u32); - - let server_tool_use = usage.and_then(|u| u.get("server_tool_use")).map(|server| { - let code_execution_requests = server - .get("code_execution_requests") - .and_then(Value::as_u64) - .map(|v| v as u32); - let tool_search_requests = server - .get("tool_search_requests") - .and_then(Value::as_u64) - .map(|v| v as u32); - ServerToolUsage { - code_execution_requests, - tool_search_requests, - } - }); - - Usage { - input_tokens: input_tokens as u32, - output_tokens: output_tokens as u32, - prompt_cache_hit_tokens, - prompt_cache_miss_tokens, - reasoning_tokens, - reasoning_replay_tokens: None, - server_tool_use, - } -} - -mod chat; -mod responses; - -#[cfg(test)] -mod tests { - use super::*; - use crate::client::chat::{ - build_chat_messages, build_chat_messages_for_request, count_reasoning_replay_chars, - parse_chat_message, parse_sse_chunk, sanitize_thinking_mode_messages, tool_to_chat, - }; - use crate::models::{ContentBlock, ContentBlockStart, Delta, Message, StreamEvent, Tool}; - use serde_json::json; - - #[test] - fn tool_name_roundtrip_dot() { - let original = "multi_tool_use.parallel"; - let encoded = to_api_tool_name(original); - assert_eq!(encoded, "multi_tool_use-x00002E-parallel"); - let decoded = from_api_tool_name(&encoded); - assert_eq!(decoded, original); - } - - #[test] - fn tool_name_decode_mangled_dot_prefix() { - // Model replaces leading `-` with `.` in `-x00002E-` - let mangled = "multi_tool_use.x00002E-parallel"; - let decoded = from_api_tool_name(mangled); - assert_eq!(decoded, "multi_tool_use..parallel"); - } - - #[test] - fn tool_name_decode_bare_hex_no_trailing_dash() { - // Bare hex without trailing dash - let mangled = "foo_x00002Ebar"; - let decoded = from_api_tool_name(mangled); - assert_eq!(decoded, "foo_.bar"); - } - - #[test] - fn tool_name_bare_hex_preserves_alnum() { - // x000041 = 'A' — should NOT be decoded (alphanumeric) - let input = "foox000041bar"; - let decoded = from_api_tool_name(input); - assert_eq!(decoded, input); - } - - #[test] - fn tool_name_bare_hex_preserves_underscore() { - // x00005F = '_' — should NOT be decoded - let input = "foox00005Fbar"; - let decoded = from_api_tool_name(input); - assert_eq!(decoded, input); - } - - #[test] - fn tool_name_roundtrip_colon() { - let original = "mcp__server:tool_name"; - let encoded = to_api_tool_name(original); - let decoded = from_api_tool_name(&encoded); - assert_eq!(decoded, original); - } - - #[test] - fn api_url_handles_default_v1_and_beta_base_urls() { - assert_eq!( - api_url("https://api.deepseek.com", "chat/completions"), - "https://api.deepseek.com/v1/chat/completions" - ); - assert_eq!( - api_url("https://api.deepseek.com/v1", "chat/completions"), - "https://api.deepseek.com/v1/chat/completions" - ); - assert_eq!( - api_url("https://api.deepseek.com/beta", "chat/completions"), - "https://api.deepseek.com/beta/chat/completions" - ); - } - - #[test] - fn chat_messages_keep_reasoning_content_on_all_assistant_messages() { - let message = Message { - role: "assistant".to_string(), - content: vec![ - ContentBlock::Thinking { - thinking: "plan".to_string(), - }, - ContentBlock::Text { - text: "done".to_string(), - cache_control: None, - }, - ], - }; - let out = build_chat_messages(None, &[message], "deepseek-v4-pro"); - let assistant = out - .iter() - .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) - .expect("assistant message"); - assert_eq!( - assistant.get("content").and_then(Value::as_str), - Some("done") - ); - assert_eq!( - assistant.get("reasoning_content").and_then(Value::as_str), - Some("plan"), - "thinking-mode models must keep reasoning_content on ALL assistant messages" - ); - } - - #[test] - fn chat_messages_keep_thinking_only_assistant_for_v4_flash() { - let message = Message { - role: "assistant".to_string(), - content: vec![ContentBlock::Thinking { - thinking: "plan".to_string(), - }], - }; - let out = build_chat_messages(None, &[message], "deepseek-v4-flash"); - let assistant = out - .iter() - .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) - .expect("thinking-only assistant kept for V4 model"); - assert_eq!( - assistant.get("reasoning_content").and_then(Value::as_str), - Some("plan") - ); - } - - #[test] - fn chat_messages_keep_thinking_only_assistant_for_v4_pro() { - let message = Message { - role: "assistant".to_string(), - content: vec![ContentBlock::Thinking { - thinking: "plan".to_string(), - }], - }; - let out = build_chat_messages(None, &[message], "deepseek-v4-pro"); - let assistant = out - .iter() - .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) - .expect("thinking-only assistant kept for V4 model"); - assert_eq!( - assistant.get("reasoning_content").and_then(Value::as_str), - Some("plan") - ); - } - - #[test] - fn chat_messages_keep_thinking_only_assistant_for_r_series_model() { - let message = Message { - role: "assistant".to_string(), - content: vec![ContentBlock::Thinking { - thinking: "plan".to_string(), - }], - }; - let out = build_chat_messages(None, &[message], "deepseek-r2-lite-preview"); - let assistant = out - .iter() - .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) - .expect("thinking-only assistant kept for R-series model"); - assert_eq!( - assistant.get("reasoning_content").and_then(Value::as_str), - Some("plan") - ); - } - - #[test] - fn chat_messages_preserve_current_tool_round_reasoning_for_reasoner_model() { - let messages = vec![ - Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: "Need the date".to_string(), - cache_control: None, - }], - }, - Message { - role: "assistant".to_string(), - content: vec![ - ContentBlock::Thinking { - thinking: "Need to call a tool".to_string(), - }, - ContentBlock::ToolUse { - id: "tool-1".to_string(), - name: "get_date".to_string(), - input: json!({}), - caller: None, - }, - ], - }, - Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: "tool-1".to_string(), - content: "2026-04-23".to_string(), - is_error: None, - content_blocks: None, - }], - }, - ]; - let out = build_chat_messages(None, &messages, "deepseek-v4-pro"); - let assistant = out - .iter() - .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) - .expect("assistant message"); - assert_eq!(assistant.get("content").and_then(Value::as_str), Some("")); - assert_eq!( - assistant.get("reasoning_content").and_then(Value::as_str), - Some("Need to call a tool") - ); - } - - #[test] - fn chat_messages_replay_prior_tool_round_reasoning_after_new_user_turn() { - let messages = vec![ - Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: "Need the date".to_string(), - cache_control: None, - }], - }, - Message { - role: "assistant".to_string(), - content: vec![ - ContentBlock::Thinking { - thinking: "Need to call a tool".to_string(), - }, - ContentBlock::ToolUse { - id: "tool-1".to_string(), - name: "get_date".to_string(), - input: json!({}), - caller: None, - }, - ], - }, - Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: "tool-1".to_string(), - content: "2026-04-23".to_string(), - is_error: None, - content_blocks: None, - }], - }, - Message { - role: "assistant".to_string(), - content: vec![ContentBlock::Text { - text: "It is 2026-04-23.".to_string(), - cache_control: None, - }], - }, - Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: "Thanks. Next question.".to_string(), - cache_control: None, - }], - }, - ]; - let out = build_chat_messages(None, &messages, "deepseek-v4-pro"); - let tool_assistant = out - .iter() - .find(|value| { - value.get("role").and_then(Value::as_str) == Some("assistant") - && value.get("tool_calls").is_some() - }) - .expect("tool-call assistant message"); - assert_eq!( - tool_assistant - .get("reasoning_content") - .and_then(Value::as_str), - Some("Need to call a tool"), - "DeepSeek thinking mode requires reasoning_content to be replayed for tool-call rounds across all subsequent user turns" - ); - } - - #[test] - fn chat_messages_replay_completed_tool_round_reasoning_after_final_answer() { - let messages = vec![ - Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: "Need the date".to_string(), - cache_control: None, - }], - }, - Message { - role: "assistant".to_string(), - content: vec![ - ContentBlock::Thinking { - thinking: "Need to call a tool".to_string(), - }, - ContentBlock::ToolUse { - id: "tool-1".to_string(), - name: "get_date".to_string(), - input: json!({}), - caller: None, - }, - ], - }, - Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: "tool-1".to_string(), - content: "2026-04-23".to_string(), - is_error: None, - content_blocks: None, - }], - }, - Message { - role: "assistant".to_string(), - content: vec![ContentBlock::Text { - text: "It is 2026-04-23.".to_string(), - cache_control: None, - }], - }, - ]; - let out = build_chat_messages(None, &messages, "deepseek-v4-pro"); - let tool_assistant = out - .iter() - .find(|value| { - value.get("role").and_then(Value::as_str) == Some("assistant") - && value.get("tool_calls").is_some() - }) - .expect("tool-call assistant message"); - assert_eq!( - tool_assistant - .get("reasoning_content") - .and_then(Value::as_str), - Some("Need to call a tool") - ); - let final_assistant = out - .iter() - .rfind(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) - .expect("final assistant message"); - assert!( - final_assistant - .get("reasoning_content") - .and_then(Value::as_str) - .is_some_and(|s| !s.trim().is_empty()), - "all assistant messages must carry reasoning_content in thinking mode" - ); - } - - #[test] - fn chat_messages_replay_v4_tool_round_reasoning_after_new_user_turn() { - let messages = vec![ - Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: "Use a tool".to_string(), - cache_control: None, - }], - }, - Message { - role: "assistant".to_string(), - content: vec![ - ContentBlock::Thinking { - thinking: "Need a tool for this".to_string(), - }, - ContentBlock::ToolUse { - id: "call-1".to_string(), - name: "read_file".to_string(), - input: json!({"path": "Cargo.toml"}), - caller: None, - }, - ], - }, - Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: "call-1".to_string(), - content: "workspace manifest".to_string(), - is_error: None, - content_blocks: None, - }], - }, - Message { - role: "assistant".to_string(), - content: vec![ContentBlock::Text { - text: "Read it.".to_string(), - cache_control: None, - }], - }, - Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: "Now continue.".to_string(), - cache_control: None, - }], - }, - ]; - - let out = build_chat_messages(None, &messages, "deepseek-v4-pro"); - let tool_assistant = out - .iter() - .find(|value| { - value.get("role").and_then(Value::as_str) == Some("assistant") - && value.get("tool_calls").is_some() - }) - .expect("tool-call assistant message"); - assert_eq!( - tool_assistant - .get("reasoning_content") - .and_then(Value::as_str), - Some("Need a tool for this") - ); - } - - #[test] - fn chat_messages_substitute_placeholder_when_v4_tool_round_missing_reasoning() { - let messages = vec![ - Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: "Use a tool".to_string(), - cache_control: None, - }], - }, - Message { - role: "assistant".to_string(), - content: vec![ContentBlock::ToolUse { - id: "call-without-reasoning".to_string(), - name: "read_file".to_string(), - input: json!({"path": "Cargo.toml"}), - caller: None, - }], - }, - Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: "call-without-reasoning".to_string(), - content: "workspace manifest".to_string(), - is_error: None, - content_blocks: None, - }], - }, - ]; - - let out = build_chat_messages(None, &messages, "deepseek-v4-pro"); - - let assistant = out - .iter() - .find(|value| { - value.get("role").and_then(Value::as_str) == Some("assistant") - && value.get("tool_calls").is_some() - }) - .expect("tool-call assistant message should be retained with placeholder"); - assert!( - assistant - .get("reasoning_content") - .and_then(Value::as_str) - .is_some_and(|value| !value.trim().is_empty()), - "missing reasoning_content should be substituted with a non-empty placeholder so the API accepts the request" - ); - assert!( - out.iter() - .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")), - "matching tool_result must remain so the conversation chain stays intact" - ); - } - - #[test] - fn chat_messages_allow_tool_round_without_reasoning_when_thinking_disabled() { - let request = MessageRequest { - model: "deepseek-v4-pro".to_string(), - messages: vec![ - Message { - role: "assistant".to_string(), - content: vec![ContentBlock::ToolUse { - id: "call-no-thinking".to_string(), - name: "read_file".to_string(), - input: json!({"path": "Cargo.toml"}), - caller: None, - }], - }, - Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: "call-no-thinking".to_string(), - content: "workspace manifest".to_string(), - is_error: None, - content_blocks: None, - }], - }, - ], - max_tokens: 1024, - system: None, - tools: None, - tool_choice: None, - metadata: None, - thinking: None, - reasoning_effort: Some("off".to_string()), - stream: None, - temperature: None, - top_p: None, - }; - - let out = build_chat_messages_for_request(&request); - assert!( - out.iter().any( - |value| value.get("role").and_then(Value::as_str) == Some("assistant") - && value.get("tool_calls").is_some() - ), - "tool calls remain valid when thinking mode is disabled" - ); - assert!( - out.iter() - .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")), - "matching tool result should remain" - ); - } - - #[test] - fn reasoning_effort_uses_deepseek_top_level_thinking_parameter() { - let mut body = json!({}); - apply_reasoning_effort(&mut body, Some("max"), ApiProvider::Deepseek); - - assert_eq!( - body.get("reasoning_effort").and_then(Value::as_str), - Some("max") - ); - assert_eq!( - body.pointer("/thinking/type").and_then(Value::as_str), - Some("enabled") - ); - assert!(body.get("extra_body").is_none()); - } - - #[test] - fn reasoning_effort_off_disables_top_level_thinking() { - let mut body = json!({}); - apply_reasoning_effort(&mut body, Some("off"), ApiProvider::Deepseek); - - assert_eq!( - body.pointer("/thinking/type").and_then(Value::as_str), - Some("disabled") - ); - assert!(body.get("reasoning_effort").is_none()); - assert!(body.get("extra_body").is_none()); - } - - #[test] - fn reasoning_effort_uses_nvidia_nim_chat_template_kwargs() { - let mut body = json!({}); - apply_reasoning_effort(&mut body, Some("max"), ApiProvider::NvidiaNim); - - assert_eq!( - body.pointer("/chat_template_kwargs/thinking") - .and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - body.pointer("/chat_template_kwargs/reasoning_effort") - .and_then(Value::as_str), - Some("max") - ); - assert!(body.get("thinking").is_none()); - assert!(body.get("reasoning_effort").is_none()); - } - - #[test] - fn reasoning_effort_off_disables_nvidia_nim_thinking() { - let mut body = json!({}); - apply_reasoning_effort(&mut body, Some("off"), ApiProvider::NvidiaNim); - - assert_eq!( - body.pointer("/chat_template_kwargs/thinking") - .and_then(Value::as_bool), - Some(false) - ); - assert!( - body.pointer("/chat_template_kwargs/reasoning_effort") - .is_none() - ); - } - - #[test] - fn chat_parser_accepts_nvidia_nim_reasoning_field() -> Result<()> { - let response = parse_chat_message(&json!({ - "id": "chatcmpl-test", - "model": "deepseek-ai/deepseek-v4-pro", - "choices": [{ - "message": { - "role": "assistant", - "reasoning": "thinking via NIM", - "content": "final answer" - }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 10, - "completion_tokens": 3 - } - }))?; - - assert!(matches!( - response.content.first(), - Some(ContentBlock::Thinking { thinking }) if thinking == "thinking via NIM" - )); - assert!(matches!( - response.content.get(1), - Some(ContentBlock::Text { text, .. }) if text == "final answer" - )); - Ok(()) - } - - #[test] - fn sse_parser_accepts_nvidia_nim_reasoning_delta() { - let mut content_index = 0; - let mut text_started = false; - let mut thinking_started = false; - let mut tool_indices = std::collections::HashMap::new(); - let events = parse_sse_chunk( - &json!({ - "choices": [{ - "delta": { - "reasoning": "nim thought" - } - }] - }), - &mut content_index, - &mut text_started, - &mut thinking_started, - &mut tool_indices, - true, - ); - - assert!(events.iter().any(|event| matches!( - event, - StreamEvent::ContentBlockDelta { - delta: Delta::ThinkingDelta { thinking }, - .. - } if thinking == "nim thought" - ))); - } - - #[test] - fn chat_tool_strict_flag_is_nested_under_function() { - let tool = Tool { - tool_type: Some("function".to_string()), - name: "emit_json".to_string(), - description: "Emit JSON".to_string(), - input_schema: json!({"type": "object", "properties": {}}), - allowed_callers: None, - defer_loading: None, - input_examples: None, - strict: Some(true), - cache_control: None, - }; - let encoded = tool_to_chat(&tool); - assert_eq!( - encoded - .get("function") - .and_then(|function| function.get("strict")) - .and_then(Value::as_bool), - Some(true) - ); - assert!(encoded.get("strict").is_none()); - } - - #[test] - fn chat_messages_drop_thinking_only_assistant_for_non_reasoning_model() { - let message = Message { - role: "assistant".to_string(), - content: vec![ContentBlock::Thinking { - thinking: "plan".to_string(), - }], - }; - let out = build_chat_messages(None, &[message], "some-non-deepseek-model"); - assert!( - !out.iter() - .any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")), - "non-reasoning model should drop thinking-only assistant" - ); - } - - #[test] - fn parse_sse_chunk_closes_each_tool_block_with_matching_index() { - let chunk = json!({ - "choices": [{ - "delta": { - "tool_calls": [ - { - "index": 0, - "id": "call_0", - "function": {"name": "read_file", "arguments": "{\"path\":\"a\"}"} - }, - { - "index": 1, - "id": "call_1", - "function": {"name": "read_file", "arguments": "{\"path\":\"b\"}"} - } - ] - }, - "finish_reason": "tool_calls" - }] - }); - - let mut content_index = 0; - let mut text_started = false; - let mut thinking_started = false; - let mut tool_indices: std::collections::HashMap = - std::collections::HashMap::new(); - let events = parse_sse_chunk( - &chunk, - &mut content_index, - &mut text_started, - &mut thinking_started, - &mut tool_indices, - false, - ); - - let starts: Vec = events - .iter() - .filter_map(|event| match event { - StreamEvent::ContentBlockStart { - index, - content_block: ContentBlockStart::ToolUse { .. }, - } => Some(*index), - _ => None, - }) - .collect(); - let stops: Vec = events - .iter() - .filter_map(|event| match event { - StreamEvent::ContentBlockStop { index } => Some(*index), - _ => None, - }) - .collect(); - let deltas: Vec = events - .iter() - .filter_map(|event| match event { - StreamEvent::ContentBlockDelta { - index, - delta: Delta::InputJsonDelta { .. }, - } => Some(*index), - _ => None, - }) - .collect(); - - assert_eq!(starts, vec![0, 1]); - assert_eq!(stops, vec![0, 1]); - assert_eq!(deltas, vec![0, 1]); - } - - #[test] - fn parse_sse_chunk_handles_empty_choices_usage_chunk() { - let chunk = json!({ - "choices": [], - "usage": { - "prompt_tokens": 100, - "completion_tokens": 20, - "prompt_cache_hit_tokens": 70, - "prompt_cache_miss_tokens": 30 - } - }); - - let mut content_index = 0; - let mut text_started = false; - let mut thinking_started = false; - let mut tool_indices: std::collections::HashMap = - std::collections::HashMap::new(); - let events = parse_sse_chunk( - &chunk, - &mut content_index, - &mut text_started, - &mut thinking_started, - &mut tool_indices, - false, - ); - - let StreamEvent::MessageDelta { - usage: Some(usage), .. - } = &events[0] - else { - panic!("expected usage delta"); - }; - assert_eq!(usage.input_tokens, 100); - assert_eq!(usage.prompt_cache_hit_tokens, Some(70)); - assert_eq!(usage.prompt_cache_miss_tokens, Some(30)); - } - - #[test] - fn chat_messages_drop_orphan_tool_results() { - let messages = vec![Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: "tool-1".to_string(), - content: "ok".to_string(), - is_error: None, - content_blocks: None, - }], - }]; - - let out = build_chat_messages(None, &messages, "deepseek-v4-flash"); - assert!( - !out.iter() - .any(|value| { value.get("role").and_then(Value::as_str) == Some("tool") }) - ); - } - - #[test] - fn chat_messages_include_tool_results_when_call_present() { - let messages = vec![ - Message { - role: "assistant".to_string(), - content: vec![ - ContentBlock::Thinking { - thinking: "Need to inspect the directory".to_string(), - }, - ContentBlock::ToolUse { - id: "tool-1".to_string(), - name: "list_dir".to_string(), - input: json!({}), - caller: None, - }, - ], - }, - Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: "tool-1".to_string(), - content: "ok".to_string(), - is_error: None, - content_blocks: None, - }], - }, - ]; - - let out = build_chat_messages(None, &messages, "deepseek-v4-flash"); - assert!( - out.iter() - .any(|value| { value.get("role").and_then(Value::as_str) == Some("tool") }) - ); - let assistant = out - .iter() - .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) - .expect("assistant message"); - assert!(assistant.get("tool_calls").is_some()); - } - - #[test] - fn chat_messages_encode_tool_call_names() { - let messages = vec![ - Message { - role: "assistant".to_string(), - content: vec![ - ContentBlock::Thinking { - thinking: "Need to search".to_string(), - }, - ContentBlock::ToolUse { - id: "tool-1".to_string(), - name: "web.run".to_string(), - input: json!({}), - caller: None, - }, - ], - }, - Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: "tool-1".to_string(), - content: "ok".to_string(), - is_error: None, - content_blocks: None, - }], - }, - ]; - - let out = build_chat_messages(None, &messages, "deepseek-v4-flash"); - let assistant = out - .iter() - .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) - .expect("assistant message"); - let tool_calls = assistant - .get("tool_calls") - .and_then(Value::as_array) - .expect("tool_calls array"); - let function_name = tool_calls - .first() - .and_then(|call| call.get("function")) - .and_then(|func| func.get("name")) - .and_then(Value::as_str) - .expect("tool call function name"); - - assert_eq!(function_name, to_api_tool_name("web.run")); - } - - #[test] - fn chat_messages_strips_orphaned_tool_calls_after_compaction() { - // Simulates post-compaction state: assistant has tool_calls but the - // tool result messages were summarized away. - let messages = vec![ - Message { - role: "assistant".to_string(), - content: vec![ContentBlock::ToolUse { - id: "tool-orphan".to_string(), - name: "read_file".to_string(), - input: json!({"path": "src/main.rs"}), - caller: None, - }], - }, - // No tool result follows — it was removed by compaction. - Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: "continue".to_string(), - cache_control: None, - }], - }, - ]; - - let out = build_chat_messages(None, &messages, "deepseek-v4-flash"); - let assistant = out - .iter() - .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")); - // The safety net may drop the assistant message entirely if it only - // contained orphaned tool_calls and no text content. - assert!( - assistant.is_none(), - "assistant without content/tool_calls should be removed" - ); - assert!( - !out.iter() - .any(|v| v.get("role").and_then(Value::as_str) == Some("tool")), - "orphaned tool results should also be removed" - ); - } - - #[test] - fn chat_messages_keeps_valid_tool_calls_intact() { - // Complete call+result pair should NOT be stripped. - let messages = vec![ - Message { - role: "assistant".to_string(), - content: vec![ - ContentBlock::Thinking { - thinking: "Need to list files".to_string(), - }, - ContentBlock::ToolUse { - id: "tool-ok".to_string(), - name: "list_dir".to_string(), - input: json!({}), - caller: None, - }, - ], - }, - Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: "tool-ok".to_string(), - content: "files".to_string(), - is_error: None, - content_blocks: None, - }], - }, - ]; - - let out = build_chat_messages(None, &messages, "deepseek-v4-flash"); - let assistant = out - .iter() - .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) - .expect("assistant message"); - assert!( - assistant.get("tool_calls").is_some(), - "valid tool_calls should remain intact" - ); - assert!( - out.iter() - .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")), - "tool result should remain" - ); - } - - #[test] - fn chat_messages_strips_partial_tool_results() { - let messages = vec![ - Message { - role: "assistant".to_string(), - content: vec![ - ContentBlock::ToolUse { - id: "t1".to_string(), - name: "read_file".to_string(), - input: json!({"path": "a.rs"}), - caller: None, - }, - ContentBlock::ToolUse { - id: "t2".to_string(), - name: "read_file".to_string(), - input: json!({"path": "b.rs"}), - caller: None, - }, - ContentBlock::ToolUse { - id: "t3".to_string(), - name: "shell".to_string(), - input: json!({"cmd": "ls"}), - caller: None, - }, - ], - }, - Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: "t1".to_string(), - content: "content a".to_string(), - is_error: None, - content_blocks: None, - }], - }, - Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: "t2".to_string(), - content: "content b".to_string(), - is_error: None, - content_blocks: None, - }], - }, - // No result for t3 - Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: "continue".to_string(), - cache_control: None, - }], - }, - ]; - - let out = build_chat_messages(None, &messages, "deepseek-v4-flash"); - let assistant = out - .iter() - .find(|v| v.get("role").and_then(Value::as_str) == Some("assistant")); - assert!( - assistant.is_none(), - "assistant with only partial tool_calls should be removed" - ); - assert!( - !out.iter() - .any(|v| v.get("role").and_then(Value::as_str) == Some("tool")), - "all orphaned tool results should be removed" - ); - } - - #[test] - fn parse_models_response_parses_and_deduplicates() { - let payload = r#"{ - "object": "list", - "data": [ - {"id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek", "created": 1}, - {"id": "deepseek-v4-flash", "object": "model"}, - {"id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek", "created": 1} - ] - }"#; - - let models = parse_models_response(payload).expect("parse models"); - assert_eq!( - models, - vec![ - AvailableModel { - id: "deepseek-v4-flash".to_string(), - owned_by: None, - created: None - }, - AvailableModel { - id: "deepseek-v4-pro".to_string(), - owned_by: Some("deepseek".to_string()), - created: Some(1) - } - ] - ); - } - - #[test] - fn parse_usage_reads_deepseek_cache_and_reasoning_tokens() { - fn parse_usage(usage: Option<&Value>) -> Usage { - let usage = usage.expect("usage"); - let input_tokens = usage - .get("prompt_tokens") - .and_then(Value::as_u64) - .expect("prompt tokens") as u32; - let output_tokens = usage - .get("completion_tokens") - .and_then(Value::as_u64) - .expect("completion tokens") as u32; - let prompt_cache_hit_tokens = usage - .get("prompt_cache_hit_tokens") - .and_then(Value::as_u64) - .map(|v| v as u32); - let prompt_cache_miss_tokens = usage - .get("prompt_cache_miss_tokens") - .and_then(Value::as_u64) - .map(|v| v as u32); - let reasoning_tokens = usage - .get("completion_tokens_details") - .and_then(|d| d.get("reasoning_tokens")) - .and_then(Value::as_u64) - .map(|v| v as u32); - - Usage { - input_tokens, - output_tokens, - prompt_cache_hit_tokens, - prompt_cache_miss_tokens, - reasoning_tokens, - reasoning_replay_tokens: None, - server_tool_use: None, - } - } - - let usage = parse_usage(Some(&json!({ - "prompt_tokens": 100, - "completion_tokens": 20, - "prompt_cache_hit_tokens": 70, - "prompt_cache_miss_tokens": 30, - "completion_tokens_details": { - "reasoning_tokens": 12 - } - }))); - - assert_eq!(usage.input_tokens, 100); - assert_eq!(usage.output_tokens, 20); - assert_eq!(usage.prompt_cache_hit_tokens, Some(70)); - assert_eq!(usage.prompt_cache_miss_tokens, Some(30)); - assert_eq!(usage.reasoning_tokens, Some(12)); - } - - #[test] - fn sanitize_thinking_mode_counts_reasoning_replay_across_assistant_turns() { - // Multi-turn body that mimics two prior tool-calling rounds: each - // assistant message carries its `reasoning_content`. The sanitizer - // should keep all of them and the count helper should tally bytes - // across every assistant message. - let mut body = json!({ - "model": "deepseek-v4-pro", - "messages": [ - { "role": "system", "content": "you are helpful" }, - { "role": "user", "content": "step 1" }, - { - "role": "assistant", - "content": "", - "reasoning_content": "I need to call tool A first.", - "tool_calls": [{ "id": "1", "type": "function" }] - }, - { "role": "tool", "tool_call_id": "1", "content": "ok" }, - { - "role": "assistant", - "content": "", - "reasoning_content": "Now I call tool B.", - "tool_calls": [{ "id": "2", "type": "function" }] - }, - { "role": "tool", "tool_call_id": "2", "content": "ok" }, - { "role": "user", "content": "step 2" } - ] - }); - - let approx_tokens = - sanitize_thinking_mode_messages(&mut body, "deepseek-v4-pro", Some("max")) - .expect("multi-turn thinking-mode conversation should report replay tokens"); - // ~4 chars/token; 46 bytes of reasoning -> 11 tokens. - assert_eq!(approx_tokens, 11); - - let chars = count_reasoning_replay_chars(&body); - // "I need to call tool A first." (28) + "Now I call tool B." (18) = 46 - assert_eq!(chars, 46); - - // No assistant messages should have lost or had their reasoning_content blanked. - let messages = body["messages"].as_array().unwrap(); - let assistant_with_reasoning: usize = messages - .iter() - .filter(|m| m["role"] == "assistant") - .filter(|m| { - m["reasoning_content"] - .as_str() - .is_some_and(|s| !s.is_empty()) - }) - .count(); - assert_eq!(assistant_with_reasoning, 2); - } - - /// Issue #30: when no thinking-mode replay applies (non-thinking model or - /// empty conversation), the sanitizer returns `None` so the footer chip - /// stays hidden. - #[test] - fn sanitize_thinking_mode_returns_none_for_non_thinking_model() { - let mut body = json!({ - "model": "deepseek-chat", - "messages": [ - { "role": "user", "content": "hi" } - ] - }); - let result = sanitize_thinking_mode_messages(&mut body, "deepseek-chat", None); - assert!(result.is_none()); - } - - #[test] - fn sanitize_thinking_mode_counts_substituted_placeholder() { - // An assistant tool-call message is missing reasoning_content; the - // sanitizer must inject the placeholder, and the count helper must - // include the placeholder in the total (since it's in the wire - // payload that ships to DeepSeek). - let mut body = json!({ - "model": "deepseek-v4-pro", - "messages": [ - { "role": "user", "content": "hi" }, - { - "role": "assistant", - "content": "", - "tool_calls": [{ "id": "1", "type": "function" }] - } - ] - }); - - sanitize_thinking_mode_messages(&mut body, "deepseek-v4-pro", Some("max")); - - let chars = count_reasoning_replay_chars(&body); - // "(reasoning omitted)" is 19 bytes. - assert_eq!(chars, 19); - } - - #[test] - fn token_bucket_enforces_delay_when_empty() { - let now = Instant::now(); - let mut bucket = TokenBucket { - enabled: true, - capacity: 1.0, - tokens: 1.0, - refill_per_sec: 2.0, - last_refill: now, - }; - - assert!(bucket.delay_until_available(1.0).is_none()); - let delay = bucket - .delay_until_available(1.0) - .expect("bucket should require refill delay"); - assert!( - delay >= Duration::from_millis(400) && delay <= Duration::from_millis(600), - "unexpected refill delay: {delay:?}" - ); - } - - #[test] - fn stream_buffer_pool_reuses_released_buffers() { - let mut first = acquire_stream_buffer(); - first.extend_from_slice(b"hello"); - let released_capacity = first.capacity(); - release_stream_buffer(first); - - let second = acquire_stream_buffer(); - assert!(second.is_empty()); - assert!( - second.capacity() >= released_capacity, - "pooled buffer capacity should be reused" - ); - } - - #[test] - fn base_url_security_rejects_insecure_non_local_http() { - let err = validate_base_url_security("http://api.deepseek.com") - .expect_err("non-local insecure HTTP should be rejected"); - assert!(err.to_string().contains("Refusing insecure base URL")); - } - - #[test] - fn base_url_security_allows_localhost_http() { - assert!(validate_base_url_security("http://localhost:8080").is_ok()); - assert!(validate_base_url_security("http://127.0.0.1:8080").is_ok()); - } - - #[test] - fn connection_health_degrades_and_recovers() { - let now = Instant::now(); - let mut health = ConnectionHealth::default(); - assert_eq!(health.state, ConnectionState::Healthy); - - apply_request_failure(&mut health, now); - assert_eq!(health.state, ConnectionState::Healthy); - - apply_request_failure(&mut health, now + Duration::from_millis(1)); - assert_eq!(health.state, ConnectionState::Degraded); - assert_eq!(health.consecutive_failures, 2); - - let recovered = apply_request_success(&mut health, now + Duration::from_secs(1)); - assert!(recovered); - assert_eq!(health.state, ConnectionState::Healthy); - assert_eq!(health.consecutive_failures, 0); - } - - #[test] - fn recovery_probe_respects_cooldown() { - let now = Instant::now(); - let mut health = ConnectionHealth { - state: ConnectionState::Degraded, - ..ConnectionHealth::default() - }; - - assert!(mark_recovery_probe_if_due(&mut health, now)); - assert_eq!(health.state, ConnectionState::Recovering); - assert!(!mark_recovery_probe_if_due( - &mut health, - now + Duration::from_secs(1) - )); - assert!(mark_recovery_probe_if_due( - &mut health, - now + RECOVERY_PROBE_COOLDOWN + Duration::from_millis(1) - )); - } - - // === #103 Phase 2: HTTP/1 escape hatch =================================== - - /// Serialize tests that mutate `DEEPSEEK_FORCE_HTTP1` so they don't race - /// against each other — env vars are process-global. - static FORCE_HTTP1_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - struct ForceHttp1EnvGuard { - prior: Option, - } - impl ForceHttp1EnvGuard { - fn capture() -> Self { - Self { - prior: std::env::var_os("DEEPSEEK_FORCE_HTTP1"), - } - } - } - impl Drop for ForceHttp1EnvGuard { - fn drop(&mut self) { - // Safety: scoped to test process; reverts to the captured value. - match &self.prior { - Some(v) => unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", v) }, - None => unsafe { std::env::remove_var("DEEPSEEK_FORCE_HTTP1") }, - } - } - } - - #[test] - fn force_http1_unset_is_false() { - let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap(); - let _guard = ForceHttp1EnvGuard::capture(); - unsafe { std::env::remove_var("DEEPSEEK_FORCE_HTTP1") }; - assert!(!force_http1_from_env()); - } - - #[test] - fn force_http1_truthy_values() { - let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap(); - let _guard = ForceHttp1EnvGuard::capture(); - for value in ["1", "true", "True", "YES", "on", " 1 "] { - // Safety: serialized by FORCE_HTTP1_ENV_LOCK; reverted by guard. - unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) }; - assert!( - force_http1_from_env(), - "{value:?} should be parsed as truthy", - ); - } - } - - #[test] - fn force_http1_falsy_values() { - let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap(); - let _guard = ForceHttp1EnvGuard::capture(); - for value in ["0", "false", "no", "off", "", "garbage", "2"] { - unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) }; - assert!( - !force_http1_from_env(), - "{value:?} should NOT be parsed as truthy", - ); - } - } -} diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 0d4a28f6..7aacf589 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -928,8 +928,10 @@ impl Config { .as_ref() .filter(|base| base.contains("integrate.api.nvidia.com")) .cloned(), - ApiProvider::Openrouter | ApiProvider::Novita - | ApiProvider::Fireworks | ApiProvider::Sglang => None, + ApiProvider::Openrouter + | ApiProvider::Novita + | ApiProvider::Fireworks + | ApiProvider::Sglang => None, }; let base = provider_base.or(root_base).unwrap_or_else(|| { match provider { @@ -1663,9 +1665,18 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { .context .verbatim_window_turns .or(base.context.verbatim_window_turns), - l1_threshold: override_cfg.context.l1_threshold.or(base.context.l1_threshold), - l2_threshold: override_cfg.context.l2_threshold.or(base.context.l2_threshold), - l3_threshold: override_cfg.context.l3_threshold.or(base.context.l3_threshold), + l1_threshold: override_cfg + .context + .l1_threshold + .or(base.context.l1_threshold), + l2_threshold: override_cfg + .context + .l2_threshold + .or(base.context.l2_threshold), + l3_threshold: override_cfg + .context + .l3_threshold + .or(base.context.l3_threshold), cycle_threshold: override_cfg .context .cycle_threshold diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index cde15eee..d792f3b2 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -24,7 +24,6 @@ use crate::compaction::{ CompactionConfig, compact_messages_safe, estimate_tokens, merge_system_prompts, should_compact, }; use crate::config::{Config, DEFAULT_MAX_SUBAGENTS, DEFAULT_TEXT_MODEL}; -use crate::seam_manager::{SeamConfig, SeamManager}; use crate::cycle_manager::{ CycleBriefing, CycleConfig, StructuredState, archive_cycle, build_seed_messages, estimate_briefing_tokens, produce_briefing, should_advance_cycle, @@ -38,6 +37,7 @@ use crate::models::{ StreamEvent, SystemBlock, SystemPrompt, Tool, ToolCaller, Usage, context_window_for_model, }; use crate::prompts; +use crate::seam_manager::{SeamConfig, SeamManager}; use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; use crate::tools::spec::{ApprovalRequirement, ToolError, ToolResult, required_str}; @@ -1264,21 +1264,26 @@ impl Engine { let seam_manager = deepseek_client.as_ref().map(|main_client| { let seam_config = SeamConfig { enabled: api_config.context.enabled.unwrap_or(true), - verbatim_window_turns: api_config.context.verbatim_window_turns.unwrap_or( - crate::seam_manager::VERBATIM_WINDOW_TURNS, - ), - l1_threshold: api_config.context.l1_threshold.unwrap_or( - crate::seam_manager::DEFAULT_L1_THRESHOLD, - ), - l2_threshold: api_config.context.l2_threshold.unwrap_or( - crate::seam_manager::DEFAULT_L2_THRESHOLD, - ), - l3_threshold: api_config.context.l3_threshold.unwrap_or( - crate::seam_manager::DEFAULT_L3_THRESHOLD, - ), - cycle_threshold: api_config.context.cycle_threshold.unwrap_or( - crate::seam_manager::DEFAULT_CYCLE_THRESHOLD, - ), + verbatim_window_turns: api_config + .context + .verbatim_window_turns + .unwrap_or(crate::seam_manager::VERBATIM_WINDOW_TURNS), + l1_threshold: api_config + .context + .l1_threshold + .unwrap_or(crate::seam_manager::DEFAULT_L1_THRESHOLD), + l2_threshold: api_config + .context + .l2_threshold + .unwrap_or(crate::seam_manager::DEFAULT_L2_THRESHOLD), + l3_threshold: api_config + .context + .l3_threshold + .unwrap_or(crate::seam_manager::DEFAULT_L3_THRESHOLD), + cycle_threshold: api_config + .context + .cycle_threshold + .unwrap_or(crate::seam_manager::DEFAULT_CYCLE_THRESHOLD), seam_model: api_config .context .seam_model diff --git a/crates/tui/src/core/engine.rs.bak b/crates/tui/src/core/engine.rs.bak deleted file mode 100644 index 6f2b314e..00000000 --- a/crates/tui/src/core/engine.rs.bak +++ /dev/null @@ -1,2853 +0,0 @@ -//! Core engine for `DeepSeek` CLI. -//! -//! The engine handles all AI interactions in a background task, -//! communicating with the UI via channels. This enables: -//! - Non-blocking UI during API calls -//! - Real-time streaming updates -//! - Proper cancellation support -//! - Tool execution orchestration - -use std::path::PathBuf; -use std::sync::{Arc, Mutex as StdMutex}; -use std::time::{Duration, Instant}; -use std::{fs::OpenOptions, io::Write}; - -use anyhow::Result; -use futures_util::StreamExt; -use futures_util::stream::FuturesUnordered; -use serde_json::json; -use tokio::sync::{Mutex as AsyncMutex, RwLock, mpsc}; -use tokio_util::sync::CancellationToken; - -use crate::client::DeepSeekClient; -use crate::compaction::{ - CompactionConfig, compact_messages_safe, estimate_tokens, merge_system_prompts, should_compact, -}; -use crate::config::{Config, DEFAULT_MAX_SUBAGENTS, DEFAULT_TEXT_MODEL}; -use crate::seam_manager::{SeamConfig, SeamManager}; -use crate::cycle_manager::{ - CycleBriefing, CycleConfig, StructuredState, archive_cycle, build_seed_messages, - estimate_briefing_tokens, produce_briefing, should_advance_cycle, -}; -use crate::error_taxonomy::{ErrorCategory, ErrorEnvelope, StreamError}; -use crate::features::{Feature, Features}; -use crate::llm_client::LlmClient; -use crate::mcp::McpPool; -use crate::models::{ - ContentBlock, ContentBlockStart, DEFAULT_CONTEXT_WINDOW_TOKENS, Delta, Message, MessageRequest, - StreamEvent, SystemBlock, SystemPrompt, Tool, ToolCaller, Usage, context_window_for_model, -}; -use crate::prompts; -use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; -use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; -use crate::tools::spec::{ApprovalRequirement, ToolError, ToolResult, required_str}; -use crate::tools::subagent::{ - Mailbox, SharedSubAgentManager, SubAgentRuntime, SubAgentType, new_shared_subagent_manager, -}; -use crate::tools::todo::{SharedTodoList, new_shared_todo_list}; -use crate::tools::user_input::{UserInputRequest, UserInputResponse}; -use crate::tools::{ToolContext, ToolRegistryBuilder}; -use crate::tui::app::AppMode; - -use super::capacity::{ - CapacityController, CapacityControllerConfig, CapacityDecision, CapacityObservationInput, - CapacitySnapshot, GuardrailAction, RiskBand, -}; -use super::capacity_memory::{ - CanonicalState, CapacityMemoryRecord, ReplayInfo, append_capacity_record, - load_last_k_capacity_records, new_record_id, now_rfc3339, -}; -use super::coherence::{CoherenceSignal, CoherenceState, next_coherence_state}; -use super::events::{Event, TurnOutcomeStatus}; -use super::ops::Op; -use super::session::Session; -use super::tool_parser; -use super::turn::{TurnContext, TurnToolCall, post_turn_snapshot, pre_turn_snapshot}; - -// === Types === - -/// Configuration for the engine -#[derive(Debug, Clone)] -pub struct EngineConfig { - /// Model identifier to use for responses. - pub model: String, - /// Workspace root for tool execution and file operations. - pub workspace: PathBuf, - /// Allow shell tool execution when true. - pub allow_shell: bool, - /// Enable trust mode (skip approvals) when true. - pub trust_mode: bool, - /// Path to the notes file used by the notes tool. - pub notes_path: PathBuf, - /// Path to the MCP configuration file. - pub mcp_config_path: PathBuf, - /// Maximum number of assistant steps before stopping. - pub max_steps: u32, - /// Maximum number of concurrently active subagents. - pub max_subagents: usize, - /// Feature flags controlling tool availability. - pub features: Features, - /// Auto-compaction settings for long conversations. - /// - /// As of v0.6.6 the high-level summarization compaction (`compact_messages_safe`) - /// is **disabled by default**; the checkpoint-restart cycle architecture - /// (`cycle_manager`) replaces it. The compaction config is still wired through - /// for the per-tool-result truncation path (`compact_tool_result_for_context`) - /// and for users who explicitly opt back in via `[compaction] enabled = true`. - pub compaction: CompactionConfig, - /// Checkpoint-restart cycle settings (issue #124). - pub cycle: CycleConfig, - /// Capacity-controller settings. - pub capacity: CapacityControllerConfig, - /// Shared Todo list state. - pub todos: SharedTodoList, - /// Shared Plan state. - pub plan_state: SharedPlanState, - /// Maximum sub-agent recursion depth (default 3). See - /// `SubAgentRuntime::max_spawn_depth`. Override via - /// `[runtime] max_spawn_depth = N` in `~/.deepseek/config.toml`. - pub max_spawn_depth: u32, - /// Per-domain network policy decider (#135). Shared across the session so - /// session-scoped approvals (`/network allow `) persist for the - /// remainder of the run. - pub network_policy: Option, - /// Whether to take side-git workspace snapshots before/after each turn. - pub snapshots_enabled: bool, - /// Post-edit LSP diagnostics injection (#136). When `None`, the engine - /// constructs a disabled manager so the field is always present. - pub lsp_config: Option, -} - -impl Default for EngineConfig { - fn default() -> Self { - Self { - model: DEFAULT_TEXT_MODEL.to_string(), - workspace: PathBuf::from("."), - allow_shell: true, - trust_mode: false, - notes_path: PathBuf::from("notes.txt"), - mcp_config_path: PathBuf::from("mcp.json"), - max_steps: 100, - max_subagents: DEFAULT_MAX_SUBAGENTS, - features: Features::with_defaults(), - compaction: CompactionConfig::default(), - cycle: CycleConfig::default(), - capacity: CapacityControllerConfig::default(), - todos: new_shared_todo_list(), - plan_state: new_shared_plan_state(), - max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, - network_policy: None, - snapshots_enabled: true, - lsp_config: None, - } - } -} - -/// Handle to communicate with the engine -#[derive(Clone)] -pub struct EngineHandle { - /// Send operations to the engine - pub tx_op: mpsc::Sender, - /// Receive events from the engine - pub rx_event: Arc>>, - /// Shared pointer to the cancellation token for the current request. - cancel_token: Arc>, - /// Send approval decisions to the engine - tx_approval: mpsc::Sender, - /// Send user input responses to the engine - tx_user_input: mpsc::Sender, - /// Send steer input for an in-flight turn. - tx_steer: mpsc::Sender, -} - -impl EngineHandle { - /// Send an operation to the engine - pub async fn send(&self, op: Op) -> Result<()> { - self.tx_op.send(op).await?; - Ok(()) - } - - /// Cancel the current request - pub fn cancel(&self) { - match self.cancel_token.lock() { - Ok(token) => token.cancel(), - Err(poisoned) => poisoned.into_inner().cancel(), - } - } - - /// Check if a request is currently cancelled - #[must_use] - #[allow(dead_code)] - pub fn is_cancelled(&self) -> bool { - match self.cancel_token.lock() { - Ok(token) => token.is_cancelled(), - Err(poisoned) => poisoned.into_inner().is_cancelled(), - } - } - - /// Approve a pending tool call - pub async fn approve_tool_call(&self, id: impl Into) -> Result<()> { - self.tx_approval - .send(ApprovalDecision::Approved { id: id.into() }) - .await?; - Ok(()) - } - - /// Deny a pending tool call - pub async fn deny_tool_call(&self, id: impl Into) -> Result<()> { - self.tx_approval - .send(ApprovalDecision::Denied { id: id.into() }) - .await?; - Ok(()) - } - - /// Retry a tool call with an elevated sandbox policy. - pub async fn retry_tool_with_policy( - &self, - id: impl Into, - policy: crate::sandbox::SandboxPolicy, - ) -> Result<()> { - self.tx_approval - .send(ApprovalDecision::RetryWithPolicy { - id: id.into(), - policy, - }) - .await?; - Ok(()) - } - - /// Submit a response for request_user_input. - pub async fn submit_user_input( - &self, - id: impl Into, - response: UserInputResponse, - ) -> Result<()> { - self.tx_user_input - .send(UserInputDecision::Submitted { - id: id.into(), - response, - }) - .await?; - Ok(()) - } - - /// Cancel a request_user_input prompt. - pub async fn cancel_user_input(&self, id: impl Into) -> Result<()> { - self.tx_user_input - .send(UserInputDecision::Cancelled { id: id.into() }) - .await?; - Ok(()) - } - - /// Steer an in-flight turn with additional user input. - pub async fn steer(&self, content: impl Into) -> Result<()> { - self.tx_steer.send(content.into()).await?; - Ok(()) - } -} - -// === Engine === - -/// The core engine that processes operations and emits events -pub struct Engine { - config: EngineConfig, - deepseek_client: Option, - deepseek_client_error: Option, - session: Session, - subagent_manager: SharedSubAgentManager, - shell_manager: SharedShellManager, - mcp_pool: Option>>, - rx_op: mpsc::Receiver, - rx_approval: mpsc::Receiver, - rx_user_input: mpsc::Receiver, - rx_steer: mpsc::Receiver, - tx_event: mpsc::Sender, - cancel_token: CancellationToken, - shared_cancel_token: Arc>, - tool_exec_lock: Arc>, - capacity_controller: CapacityController, - /// Append-only layered context manager (#159). Produces soft seams at - /// 192K/384K/576K and Flash-cycle briefings at 768K. - seam_manager: Option, - coherence_state: CoherenceState, - turn_counter: u64, - /// Post-edit LSP diagnostics injection (#136). Populated unconditionally - /// — when LSP is disabled in config, this is an inert manager that - /// always returns `None` from `diagnostics_for`. - lsp_manager: Arc, - /// Diagnostics collected during the current step's tool calls. Drained - /// and forwarded as a synthetic user message before the next API call. - pending_lsp_blocks: Vec, -} - -// === Internal stream helpers === - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ContentBlockKind { - Text, - Thinking, - ToolUse, -} - -#[derive(Debug, Clone)] -struct ToolUseState { - id: String, - name: String, - input: serde_json::Value, - caller: Option, - input_buffer: String, -} - -/// Maximum time to wait for a single stream chunk before assuming a stall. -/// **This is the idle timeout** — it resets on every SSE chunk, so long -/// thinking turns that ARE producing reasoning_content stay alive. Only a -/// genuine `chunk_timeout` window of silence kills the stream. -const STREAM_CHUNK_TIMEOUT_SECS: u64 = 90; -/// Maximum total bytes of text/thinking content before aborting the stream. -const STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MB -/// Sanity backstop for total stream wall-clock duration. **Not** a routine -/// kill switch — `STREAM_CHUNK_TIMEOUT_SECS` (idle) is the primary stall -/// detector. The wall-clock cap is here only to bound pathological cases -/// (e.g. a server that keeps sending heartbeats forever without progress). -/// -/// History: this used to be 300s (5 min) which was too aggressive — V4 -/// thinking turns on hard prompts legitimately exceed 5 minutes wall-clock -/// while still emitting reasoning_content chunks the whole way. Bumped to -/// 30 min in v0.6.6 to address `TODO_FIXES.md` #1. Codex defaults to a -/// per-chunk idle of 300s with no wall-clock cap; we keep both layers but -/// give the wall-clock a generous window so it never fires in practice. -const STREAM_MAX_DURATION_SECS: u64 = 1800; // 30 minutes (was 300s; #103/#1) -/// Hard cap on consecutive recoverable stream errors before we surface a turn -/// failure. Bumped 3 → 5 in v0.6.7 along with the HTTP/2 keepalive defaults -/// (#103) — keepalive should make spurious decode errors rarer, so we can -/// tolerate a longer streak before giving up on the turn. -const MAX_STREAM_ERRORS_BEFORE_FAIL: u32 = 5; -/// Cap on transparent stream-level retries — these only happen when the wire -/// dies before any content was streamed, so DeepSeek hasn't billed us and -/// the user hasn't seen anything. Two attempts is enough to ride out a -/// flaky edge node without amplifying real outages (#103). -const MAX_TRANSPARENT_STREAM_RETRIES: u32 = 2; - -/// Decide whether a stream error is eligible for a transparent retry. -/// -/// True only when ALL three conditions hold: -/// 1. No content has been received on the current attempt — otherwise DeepSeek -/// has already billed us for output tokens and the user has seen partial -/// deltas; resending would double-bill and desync the UI. -/// 2. We still have transparent-retry budget remaining. -/// 3. The turn has not been cancelled. -/// -/// Extracted as a pure function so the four #103 retry cases can be exercised -/// in unit tests without booting the full engine state machine. -fn should_transparently_retry_stream( - any_content_received: bool, - transparent_attempts: u32, - cancelled: bool, -) -> bool { - !any_content_received && transparent_attempts < MAX_TRANSPARENT_STREAM_RETRIES && !cancelled -} -/// Max output tokens requested for normal agent turns. Generous on purpose: -/// V4 thinking models can produce tens of thousands of reasoning tokens on -/// hard prompts before the visible reply, and DeepSeek V4 ships with a 1M -/// context window. 256K leaves the model effectively unconstrained on -/// output without us imposing artificial per-turn caps that surfaced as the -/// assistant "stopping mid-response" when reasoning consumed the budget. -const TURN_MAX_OUTPUT_TOKENS: u32 = 262_144; -/// Keep this many most recent messages when emergency trimming is required. -const MIN_RECENT_MESSAGES_TO_KEEP: usize = 4; -/// Allow a few emergency recovery attempts before failing the turn. -const MAX_CONTEXT_RECOVERY_ATTEMPTS: u8 = 2; -/// Reserve additional headroom to avoid hitting provider hard limits. -const CONTEXT_HEADROOM_TOKENS: usize = 1024; -/// Hard cap for any tool output inserted into model context. -const TOOL_RESULT_CONTEXT_HARD_LIMIT_CHARS: usize = 12_000; -/// Soft cap for known noisy tools inserted into model context. -const TOOL_RESULT_CONTEXT_SOFT_LIMIT_CHARS: usize = 2_000; -/// Snippet length kept when compacting tool output for model context. -const TOOL_RESULT_CONTEXT_SNIPPET_CHARS: usize = 900; -/// Hard cap for tool output inserted into a large-context model. -const LARGE_CONTEXT_TOOL_RESULT_HARD_LIMIT_CHARS: usize = 180_000; -/// Soft cap for known noisy tools inserted into a large-context model. -const LARGE_CONTEXT_TOOL_RESULT_SOFT_LIMIT_CHARS: usize = 60_000; -/// Snippet length kept when compacting large-context tool output. -const LARGE_CONTEXT_TOOL_RESULT_SNIPPET_CHARS: usize = 40_000; -/// Context window size at which tool output limits can be relaxed. -const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000; -/// Max chars to keep from metadata-provided output summaries. -const TOOL_RESULT_METADATA_SUMMARY_CHARS: usize = 320; -const COMPACTION_SUMMARY_MARKER: &str = "Conversation Summary (Auto-Generated)"; -const WORKING_SET_SUMMARY_MARKER: &str = "## Repo Working Set"; - -pub(crate) const TOOL_CALL_START_MARKERS: [&str; 5] = [ - "[TOOL_CALL]", - "", -]; - -const MULTI_TOOL_PARALLEL_NAME: &str = "multi_tool_use.parallel"; -const REQUEST_USER_INPUT_NAME: &str = "request_user_input"; -const CODE_EXECUTION_TOOL_NAME: &str = "code_execution"; -const CODE_EXECUTION_TOOL_TYPE: &str = "code_execution_20250825"; -const TOOL_SEARCH_REGEX_NAME: &str = "tool_search_tool_regex"; -const TOOL_SEARCH_REGEX_TYPE: &str = "tool_search_tool_regex_20251119"; -const TOOL_SEARCH_BM25_NAME: &str = "tool_search_tool_bm25"; -const TOOL_SEARCH_BM25_TYPE: &str = "tool_search_tool_bm25_20251119"; -pub(crate) const TOOL_CALL_END_MARKERS: [&str; 5] = [ - "[/TOOL_CALL]", - "", - "", - "", - "", -]; - -/// Compact one-shot notice emitted when a model attempts to forge a tool-call -/// wrapper in plain text instead of using the API tool channel. The visible -/// content is still scrubbed; this exists so the user can see why their text -/// shrank. -pub(crate) const FAKE_WRAPPER_NOTICE: &str = - "Stripped non-API tool-call wrapper from model output (use the API tool channel)"; - -/// True if `text` contains any of the known fake-wrapper start markers. Used by -/// the streaming loop to decide whether to emit `FAKE_WRAPPER_NOTICE`. -pub(crate) fn contains_fake_tool_wrapper(text: &str) -> bool { - TOOL_CALL_START_MARKERS.iter().any(|m| text.contains(m)) -} - -fn find_first_marker(text: &str, markers: &[&str]) -> Option<(usize, usize)> { - markers - .iter() - .filter_map(|marker| text.find(marker).map(|idx| (idx, marker.len()))) - .min_by_key(|(idx, _)| *idx) -} - -pub(crate) fn filter_tool_call_delta(delta: &str, in_tool_call: &mut bool) -> String { - if delta.is_empty() { - return String::new(); - } - - let mut output = String::new(); - let mut rest = delta; - - loop { - if *in_tool_call { - let Some((idx, len)) = find_first_marker(rest, &TOOL_CALL_END_MARKERS) else { - break; - }; - rest = &rest[idx + len..]; - *in_tool_call = false; - } else { - let Some((idx, len)) = find_first_marker(rest, &TOOL_CALL_START_MARKERS) else { - output.push_str(rest); - break; - }; - output.push_str(&rest[..idx]); - rest = &rest[idx + len..]; - *in_tool_call = true; - } - } - - output -} - -/// Compute the tool input that should be reported when a tool's stream block -/// closes (`ContentBlockStop`). Prefers the parsed `input_buffer` over the -/// initial `input` placeholder so a `ToolCallStarted` event never carries a -/// stale `{}` when args were actually streamed in via `InputJsonDelta`. -/// -/// Order of preference: -/// 1. `input_buffer` parses cleanly → use that. -/// 2. `input_buffer` is empty → fall back to `input` (model embedded args -/// directly in the `ContentBlockStart` frame and sent no deltas). -/// 3. `input_buffer` non-empty but unparseable → fall back to `input` -/// (the per-delta parser has already mirrored the most recent valid -/// partial parse into `tool_state.input`). -fn is_tool_search_tool(name: &str) -> bool { - matches!(name, TOOL_SEARCH_REGEX_NAME | TOOL_SEARCH_BM25_NAME) -} - -fn should_default_defer_tool(name: &str, mode: AppMode) -> bool { - if mode == AppMode::Yolo { - return false; - } - - // Shell tools are kept active in Agent so the model can run verification - // commands (build/test/git/cargo) without first having to discover the - // tool through ToolSearch. Plan mode never registers shell tools. - let always_loaded_in_action_modes = matches!(mode, AppMode::Agent) - && matches!( - name, - "exec_shell" - | "exec_shell_wait" - | "exec_shell_interact" - | "exec_wait" - | "exec_interact" - ); - if always_loaded_in_action_modes { - return false; - } - - !matches!( - name, - "read_file" - | "list_dir" - | "grep_files" - | "file_search" - | "diagnostics" - | "rlm" - | "recall_archive" - | MULTI_TOOL_PARALLEL_NAME - | "update_plan" - | "todo_write" - | REQUEST_USER_INPUT_NAME - ) -} - -fn ensure_advanced_tooling(catalog: &mut Vec) { - if !catalog.iter().any(|t| t.name == CODE_EXECUTION_TOOL_NAME) { - catalog.push(Tool { - tool_type: Some(CODE_EXECUTION_TOOL_TYPE.to_string()), - name: CODE_EXECUTION_TOOL_NAME.to_string(), - description: "Execute Python code in a local sandboxed runtime and return stdout/stderr/return_code as JSON.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "code": { "type": "string", "description": "Python source code to execute." } - }, - "required": ["code"] - }), - allowed_callers: Some(vec!["direct".to_string()]), - defer_loading: Some(false), - input_examples: None, - strict: None, - cache_control: None, - }); - } - - if !catalog.iter().any(|t| t.name == TOOL_SEARCH_REGEX_NAME) { - catalog.push(Tool { - tool_type: Some(TOOL_SEARCH_REGEX_TYPE.to_string()), - name: TOOL_SEARCH_REGEX_NAME.to_string(), - description: "Search deferred tool definitions using a regex query and return matching tool references.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "query": { "type": "string", "description": "Regex pattern to search tool names/descriptions/schema." } - }, - "required": ["query"] - }), - allowed_callers: Some(vec!["direct".to_string()]), - defer_loading: Some(false), - input_examples: None, - strict: None, - cache_control: None, - }); - } - - if !catalog.iter().any(|t| t.name == TOOL_SEARCH_BM25_NAME) { - catalog.push(Tool { - tool_type: Some(TOOL_SEARCH_BM25_TYPE.to_string()), - name: TOOL_SEARCH_BM25_NAME.to_string(), - description: "Search deferred tool definitions using natural-language matching and return matching tool references.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "query": { "type": "string", "description": "Natural language query for tool discovery." } - }, - "required": ["query"] - }), - allowed_callers: Some(vec!["direct".to_string()]), - defer_loading: Some(false), - input_examples: None, - strict: None, - cache_control: None, - }); - } -} - -fn initial_active_tools(catalog: &[Tool]) -> std::collections::HashSet { - let mut active = std::collections::HashSet::new(); - for tool in catalog { - if !tool.defer_loading.unwrap_or(false) || is_tool_search_tool(&tool.name) { - active.insert(tool.name.clone()); - } - } - if active.is_empty() - && !catalog.is_empty() - && let Some(first) = catalog.first() - { - active.insert(first.name.clone()); - } - active -} - -fn active_tool_list_from_catalog( - catalog: &[Tool], - active: &std::collections::HashSet, -) -> Vec { - catalog - .iter() - .filter(|tool| active.contains(&tool.name)) - .cloned() - .collect() -} - -fn active_tools_for_step( - catalog: &[Tool], - active: &std::collections::HashSet, - force_update_plan: bool, -) -> Vec { - // DeepSeek reasoning models reject explicit named tool_choice forcing here, so for - // obvious quick-plan asks we narrow the first-step tool surface to update_plan instead. - if force_update_plan { - let forced: Vec<_> = catalog - .iter() - .filter(|tool| tool.name == "update_plan") - .cloned() - .collect(); - if !forced.is_empty() { - return forced; - } - } - - active_tool_list_from_catalog(catalog, active) -} - -fn tool_search_haystack(tool: &Tool) -> String { - format!( - "{}\n{}\n{}", - tool.name.to_lowercase(), - tool.description.to_lowercase(), - tool.input_schema.to_string().to_lowercase() - ) -} - -fn discover_tools_with_regex(catalog: &[Tool], query: &str) -> Result, ToolError> { - let regex = regex::Regex::new(query) - .map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?; - - let mut matches = Vec::new(); - for tool in catalog { - if is_tool_search_tool(&tool.name) { - continue; - } - let hay = tool_search_haystack(tool); - if regex.is_match(&hay) { - matches.push(tool.name.clone()); - } - if matches.len() >= 5 { - break; - } - } - Ok(matches) -} - -fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str) -> Vec { - let terms: Vec = query - .split_whitespace() - .map(|term| term.trim().to_lowercase()) - .filter(|term| !term.is_empty()) - .collect(); - if terms.is_empty() { - return Vec::new(); - } - - let mut scored: Vec<(i64, String)> = Vec::new(); - for tool in catalog { - if is_tool_search_tool(&tool.name) { - continue; - } - let hay = tool_search_haystack(tool); - let mut score = 0i64; - for term in &terms { - if hay.contains(term) { - score += 1; - } - if tool.name.to_lowercase().contains(term) { - score += 2; - } - } - if score > 0 { - scored.push((score, tool.name.clone())); - } - } - scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); - scored.into_iter().take(5).map(|(_, name)| name).collect() -} - -fn edit_distance(a: &str, b: &str) -> usize { - if a == b { - return 0; - } - if a.is_empty() { - return b.chars().count(); - } - if b.is_empty() { - return a.chars().count(); - } - - let b_chars: Vec = b.chars().collect(); - let mut prev: Vec = (0..=b_chars.len()).collect(); - let mut curr = vec![0usize; b_chars.len() + 1]; - - for (i, a_ch) in a.chars().enumerate() { - curr[0] = i + 1; - for (j, b_ch) in b_chars.iter().enumerate() { - let cost = if a_ch == *b_ch { 0 } else { 1 }; - let delete = prev[j + 1] + 1; - let insert = curr[j] + 1; - let substitute = prev[j] + cost; - curr[j + 1] = delete.min(insert).min(substitute); - } - std::mem::swap(&mut prev, &mut curr); - } - - prev[b_chars.len()] -} - -fn suggest_tool_names(catalog: &[Tool], requested: &str, limit: usize) -> Vec { - let requested = requested.trim().to_ascii_lowercase(); - if requested.is_empty() || limit == 0 { - return Vec::new(); - } - - let mut candidates: Vec<(u8, usize, String)> = Vec::new(); - for tool in catalog { - let candidate = tool.name.to_ascii_lowercase(); - let prefix_match = candidate.starts_with(&requested) || requested.starts_with(&candidate); - let contains_match = candidate.contains(&requested) || requested.contains(&candidate); - let distance = edit_distance(&candidate, &requested); - let close_typo = distance <= 3; - - if !(prefix_match || contains_match || close_typo) { - continue; - } - - let rank = if prefix_match { - 0 - } else if contains_match { - 1 - } else { - 2 - }; - candidates.push((rank, distance, tool.name.clone())); - } - - candidates.sort_by(|a, b| { - a.0.cmp(&b.0) - .then_with(|| a.1.cmp(&b.1)) - .then_with(|| a.2.cmp(&b.2)) - }); - candidates.dedup_by(|a, b| a.2 == b.2); - candidates - .into_iter() - .take(limit) - .map(|(_, _, name)| name) - .collect() -} - -fn missing_tool_error_message(tool_name: &str, catalog: &[Tool]) -> String { - let suggestions = suggest_tool_names(catalog, tool_name, 3); - if suggestions.is_empty() { - return format!( - "Tool '{tool_name}' is not available in the current tool catalog. \ - Verify mode/feature flags, or use {TOOL_SEARCH_BM25_NAME} with a short query." - ); - } - - format!( - "Tool '{tool_name}' is not available in the current tool catalog. \ - Did you mean: {}? You can also use {TOOL_SEARCH_BM25_NAME} to discover tools.", - suggestions.join(", ") - ) -} - -fn maybe_activate_requested_deferred_tool( - tool_name: &str, - catalog: &[Tool], - active_tools: &mut std::collections::HashSet, -) -> bool { - let Some(def) = catalog.iter().find(|def| def.name == tool_name) else { - return false; - }; - - if !def.defer_loading.unwrap_or(false) || active_tools.contains(tool_name) { - return false; - } - - active_tools.insert(tool_name.to_string()) -} - -fn execute_tool_search( - tool_name: &str, - input: &serde_json::Value, - catalog: &[Tool], - active_tools: &mut std::collections::HashSet, -) -> Result { - let query = required_str(input, "query")?; - let discovered = if tool_name == TOOL_SEARCH_REGEX_NAME { - discover_tools_with_regex(catalog, query)? - } else { - discover_tools_with_bm25_like(catalog, query) - }; - - for name in &discovered { - active_tools.insert(name.clone()); - } - - let references = discovered - .iter() - .map(|name| json!({"type": "tool_reference", "tool_name": name})) - .collect::>(); - - let payload = json!({ - "type": "tool_search_tool_search_result", - "tool_references": references, - }); - - Ok(ToolResult { - content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()), - success: true, - metadata: Some(json!({ - "tool_references": discovered, - })), - }) -} - -async fn execute_code_execution_tool( - input: &serde_json::Value, - workspace: &std::path::Path, -) -> Result { - let code = required_str(input, "code")?; - let mut cmd = tokio::process::Command::new("python3"); - cmd.arg("-c"); - cmd.arg(code); - cmd.current_dir(workspace); - - let output = tokio::time::timeout(Duration::from_secs(120), cmd.output()) - .await - .map_err(|_| ToolError::Timeout { seconds: 120 }) - .and_then(|res| res.map_err(|e| ToolError::execution_failed(e.to_string())))?; - - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let return_code = output.status.code().unwrap_or(-1); - let success = output.status.success(); - let payload = json!({ - "type": "code_execution_result", - "stdout": stdout, - "stderr": stderr, - "return_code": return_code, - "content": [], - }); - - Ok(ToolResult { - content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()), - success, - metadata: Some(payload), - }) -} - -fn caller_type_for_tool_use(caller: Option<&ToolCaller>) -> &str { - caller.map_or("direct", |c| c.caller_type.as_str()) -} - -/// #136: derive the file path(s) edited by a tool call. Returns the empty -/// vec for tools that don't modify files. We intentionally only handle the -/// three known edit tools — adding more (e.g. specialized refactor tools) -/// is a one-line change here. -fn edited_paths_for_tool(tool_name: &str, input: &serde_json::Value) -> Vec { - match tool_name { - "edit_file" | "write_file" => { - if let Some(path) = input.get("path").and_then(|v| v.as_str()) { - vec![PathBuf::from(path)] - } else { - Vec::new() - } - } - "apply_patch" => { - // `apply_patch` accepts either a `path` override or a list of - // `files` (each `{path, content}`). We try both shapes. - let mut out = Vec::new(); - if let Some(path) = input.get("path").and_then(|v| v.as_str()) { - out.push(PathBuf::from(path)); - } - if let Some(files) = input.get("files").and_then(|v| v.as_array()) { - for entry in files { - if let Some(path) = entry.get("path").and_then(|v| v.as_str()) { - out.push(PathBuf::from(path)); - } - } - } - // Fallback: parse `---`/`+++` headers from a unified diff payload. - if out.is_empty() - && let Some(patch) = input.get("patch").and_then(|v| v.as_str()) - { - out.extend(parse_patch_paths(patch)); - } - out - } - _ => Vec::new(), - } -} - -/// Lightweight parser for `+++ b/` lines in a unified diff. Used as a -/// fallback when `apply_patch` is invoked with raw `patch` text and no -/// `path`/`files` override. We deliberately keep this dumb — the real -/// `apply_patch` tool already validates the patch shape; we only need a -/// best-effort hint for the LSP hook. -fn parse_patch_paths(patch: &str) -> Vec { - let mut out = Vec::new(); - for line in patch.lines() { - if let Some(rest) = line.strip_prefix("+++ ") { - let trimmed = rest.trim(); - // Strip leading `b/` per git diff conventions. - let path = trimmed.strip_prefix("b/").unwrap_or(trimmed); - // Skip `/dev/null` (deletion). - if path == "/dev/null" { - continue; - } - out.push(PathBuf::from(path)); - } - } - out -} - -fn caller_allowed_for_tool(caller: Option<&ToolCaller>, tool_def: Option<&Tool>) -> bool { - let requested = caller_type_for_tool_use(caller); - if let Some(def) = tool_def - && let Some(allowed) = &def.allowed_callers - { - if allowed.is_empty() { - return requested == "direct"; - } - return allowed.iter().any(|item| item == requested); - } - requested == "direct" -} - -fn format_tool_error(err: &ToolError, tool_name: &str) -> String { - match err { - ToolError::InvalidInput { message } => { - format!("Invalid input for tool '{tool_name}': {message}") - } - ToolError::MissingField { field } => { - format!("Tool '{tool_name}' is missing required field '{field}'") - } - ToolError::PathEscape { path } => format!( - "Path escapes workspace: {}. Use a workspace-relative path or enable trust mode.", - path.display() - ), - ToolError::ExecutionFailed { message } => message.clone(), - ToolError::Timeout { seconds } => format!( - "Tool '{tool_name}' timed out after {seconds}s. Try a narrower scope or a longer timeout." - ), - ToolError::NotAvailable { message } => { - let lower = message.to_ascii_lowercase(); - if lower.contains("current tool catalog") || lower.contains("did you mean:") { - message.clone() - } else { - format!( - "Tool '{tool_name}' is not available: {message}. Check mode, feature flags, or tool name." - ) - } - } - ToolError::PermissionDenied { message } => format!( - "Tool '{tool_name}' was denied: {message}. Adjust approval mode or request permission." - ), - } -} - -fn summarize_text(text: &str, limit: usize) -> String { - if text.chars().count() <= limit { - return text.to_string(); - } - let take = limit.saturating_sub(3); - let mut out: String = text.chars().take(take).collect(); - out.push_str("..."); - out -} - -fn summarize_text_head_tail(text: &str, limit: usize) -> String { - let total = text.chars().count(); - if total <= limit { - return text.to_string(); - } - if limit <= 20 { - return summarize_text(text, limit); - } - - let marker = "\n\n[... output truncated for context ...]\n\n"; - let marker_len = marker.chars().count(); - if limit <= marker_len + 20 { - return summarize_text(text, limit); - } - - let remaining = limit - marker_len; - let head_len = remaining.saturating_mul(2) / 3; - let tail_len = remaining.saturating_sub(head_len); - let head: String = text.chars().take(head_len).collect(); - let tail_vec: Vec = text.chars().rev().take(tail_len).collect(); - let tail: String = tail_vec.into_iter().rev().collect(); - format!("{head}{marker}{tail}") -} - -fn tool_result_is_noisy(tool_name: &str) -> bool { - matches!( - tool_name, - "exec_shell" - | "exec_shell_wait" - | "exec_shell_interact" - | "multi_tool_use.parallel" - | "web_search" - ) -} - -fn tool_result_metadata_summary(metadata: Option<&serde_json::Value>) -> Option { - let obj = metadata?.as_object()?; - for key in ["summary", "stdout_summary", "stderr_summary", "message"] { - if let Some(text) = obj.get(key).and_then(serde_json::Value::as_str) { - let trimmed = text.trim(); - if !trimmed.is_empty() { - return Some(summarize_text(trimmed, TOOL_RESULT_METADATA_SUMMARY_CHARS)); - } - } - } - None -} - -#[derive(Debug, Clone, Copy)] -struct ToolResultContextLimits { - hard_limit_chars: usize, - noisy_soft_limit_chars: usize, - snippet_chars: usize, -} - -fn tool_result_context_limits_for_model(model: &str) -> ToolResultContextLimits { - let is_large_context = - context_window_for_model(model).is_some_and(|window| window >= LARGE_CONTEXT_WINDOW_TOKENS); - - if is_large_context { - ToolResultContextLimits { - hard_limit_chars: LARGE_CONTEXT_TOOL_RESULT_HARD_LIMIT_CHARS, - noisy_soft_limit_chars: LARGE_CONTEXT_TOOL_RESULT_SOFT_LIMIT_CHARS, - snippet_chars: LARGE_CONTEXT_TOOL_RESULT_SNIPPET_CHARS, - } - } else { - ToolResultContextLimits { - hard_limit_chars: TOOL_RESULT_CONTEXT_HARD_LIMIT_CHARS, - noisy_soft_limit_chars: TOOL_RESULT_CONTEXT_SOFT_LIMIT_CHARS, - snippet_chars: TOOL_RESULT_CONTEXT_SNIPPET_CHARS, - } - } -} - -pub(crate) fn compact_tool_result_for_context( - model: &str, - tool_name: &str, - output: &ToolResult, -) -> String { - let raw = output.content.trim(); - if raw.is_empty() { - return String::new(); - } - - let limits = tool_result_context_limits_for_model(model); - let raw_chars = raw.chars().count(); - let should_compact = raw_chars > limits.hard_limit_chars - || (tool_result_is_noisy(tool_name) && raw_chars > limits.noisy_soft_limit_chars); - if !should_compact { - return raw.to_string(); - } - - let snippet = summarize_text_head_tail(raw, limits.snippet_chars); - let omitted = raw_chars.saturating_sub(snippet.chars().count()); - let summary = tool_result_metadata_summary(output.metadata.as_ref()); - - if let Some(summary) = summary { - format!( - "[{tool_name} output compacted to protect context]\nSummary: {summary}\nSnippet: {snippet}\n(Original: {raw_chars} chars, omitted: {omitted} chars.)" - ) - } else { - format!( - "[{tool_name} output compacted to protect context]\nSnippet: {snippet}\n(Original: {raw_chars} chars, omitted: {omitted} chars.)" - ) - } -} - -fn extract_compaction_summary_prompt(prompt: Option) -> Option { - match prompt { - Some(SystemPrompt::Blocks(blocks)) => { - let summary_blocks: Vec<_> = blocks - .into_iter() - .filter(|block| block.text.contains(COMPACTION_SUMMARY_MARKER)) - .collect(); - if summary_blocks.is_empty() { - None - } else { - Some(SystemPrompt::Blocks(summary_blocks)) - } - } - Some(SystemPrompt::Text(text)) => { - if text.contains(COMPACTION_SUMMARY_MARKER) { - Some(SystemPrompt::Text(text)) - } else { - None - } - } - None => None, - } -} - -fn remove_working_set_summary(prompt: Option<&SystemPrompt>) -> Option { - match prompt { - Some(SystemPrompt::Blocks(blocks)) => { - let filtered: Vec = blocks - .iter() - .filter(|block| !block.text.contains(WORKING_SET_SUMMARY_MARKER)) - .cloned() - .collect(); - if filtered.is_empty() { - None - } else { - Some(SystemPrompt::Blocks(filtered)) - } - } - Some(SystemPrompt::Text(text)) => Some(SystemPrompt::Text(text.clone())), - None => None, - } -} - -fn append_working_set_summary( - prompt: Option, - working_set_summary: Option<&str>, -) -> Option { - let Some(summary) = working_set_summary.map(str::trim).filter(|s| !s.is_empty()) else { - return prompt; - }; - let working_set_block = SystemBlock { - block_type: "text".to_string(), - text: summary.to_string(), - cache_control: None, - }; - - match prompt { - Some(SystemPrompt::Text(text)) => Some(SystemPrompt::Blocks(vec![ - SystemBlock { - block_type: "text".to_string(), - text, - cache_control: None, - }, - working_set_block, - ])), - Some(SystemPrompt::Blocks(mut blocks)) => { - blocks.retain(|block| !block.text.contains(WORKING_SET_SUMMARY_MARKER)); - blocks.push(working_set_block); - Some(SystemPrompt::Blocks(blocks)) - } - None => Some(SystemPrompt::Blocks(vec![working_set_block])), - } -} - -fn estimate_text_tokens_conservative(text: &str) -> usize { - text.chars().count().div_ceil(3) -} - -fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize { - match system { - Some(SystemPrompt::Text(text)) => estimate_text_tokens_conservative(text), - Some(SystemPrompt::Blocks(blocks)) => blocks - .iter() - .map(|block| estimate_text_tokens_conservative(&block.text)) - .sum(), - None => 0, - } -} - -fn estimate_input_tokens_conservative( - messages: &[Message], - system: Option<&SystemPrompt>, -) -> usize { - let message_tokens = estimate_tokens(messages).saturating_mul(3).div_ceil(2); - let system_tokens = estimate_system_tokens_conservative(system); - let framing_overhead = messages.len().saturating_mul(12).saturating_add(48); - message_tokens - .saturating_add(system_tokens) - .saturating_add(framing_overhead) -} - -fn context_input_budget(model: &str, requested_output_tokens: u32) -> Option { - let window = usize::try_from(context_window_for_model(model)?).ok()?; - let output = usize::try_from(requested_output_tokens).ok()?; - window - .checked_sub(output) - .and_then(|v| v.checked_sub(CONTEXT_HEADROOM_TOKENS)) -} - -fn is_context_length_error_message(message: &str) -> bool { - crate::error_taxonomy::classify_error_message(message) == ErrorCategory::InvalidInput -} - -fn emit_tool_audit(event: serde_json::Value) { - let Some(path) = std::env::var_os("DEEPSEEK_TOOL_AUDIT_LOG") else { - return; - }; - let line = match serde_json::to_string(&event) { - Ok(line) => line, - Err(_) => return, - }; - let path = PathBuf::from(path); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { - let _ = writeln!(file, "{line}"); - } -} - -impl Engine { - fn reset_cancel_token(&mut self) { - let token = CancellationToken::new(); - self.cancel_token = token.clone(); - match self.shared_cancel_token.lock() { - Ok(mut shared) => { - *shared = token; - } - Err(poisoned) => { - *poisoned.into_inner() = token; - } - } - } - - /// Create a new engine with the given configuration - pub fn new(config: EngineConfig, api_config: &Config) -> (Self, EngineHandle) { - let (tx_op, rx_op) = mpsc::channel(32); - let (tx_event, rx_event) = mpsc::channel(256); - let (tx_approval, rx_approval) = mpsc::channel(64); - let (tx_user_input, rx_user_input) = mpsc::channel(32); - let (tx_steer, rx_steer) = mpsc::channel(64); - let cancel_token = CancellationToken::new(); - let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone())); - let tool_exec_lock = Arc::new(RwLock::new(())); - - // Create clients for both providers - let (deepseek_client, deepseek_client_error) = match DeepSeekClient::new(api_config) { - Ok(client) => (Some(client), None), - Err(err) => (None, Some(err.to_string())), - }; - - let mut session = Session::new( - config.model.clone(), - config.workspace.clone(), - config.allow_shell, - config.trust_mode, - config.notes_path.clone(), - config.mcp_config_path.clone(), - ); - - // Set up system prompt with project context (default to agent mode) - let working_set_summary = session.working_set.summary_block(&config.workspace); - let system_prompt = - prompts::system_prompt_for_mode_with_context(AppMode::Agent, &config.workspace, None); - session.system_prompt = - append_working_set_summary(Some(system_prompt), working_set_summary.as_deref()); - - let subagent_manager = - new_shared_subagent_manager(config.workspace.clone(), config.max_subagents); - let shell_manager = new_shared_shell_manager(config.workspace.clone()); - let capacity_controller = CapacityController::new(config.capacity.clone()); - - // Create Flash seam manager for layered context (#159). Uses the same - // API credentials as the main client but targets the Flash model for - // cost-effective summarisation and cycle briefing work. - let seam_manager = deepseek_client.as_ref().map(|main_client| { - let seam_config = SeamConfig { - enabled: api_config.context.enabled.unwrap_or(true), - verbatim_window_turns: api_config.context.verbatim_window_turns.unwrap_or( - crate::seam_manager::VERBATIM_WINDOW_TURNS, - ), - l1_threshold: api_config.context.l1_threshold.unwrap_or( - crate::seam_manager::DEFAULT_L1_THRESHOLD, - ), - l2_threshold: api_config.context.l2_threshold.unwrap_or( - crate::seam_manager::DEFAULT_L2_THRESHOLD, - ), - l3_threshold: api_config.context.l3_threshold.unwrap_or( - crate::seam_manager::DEFAULT_L3_THRESHOLD, - ), - cycle_threshold: api_config.context.cycle_threshold.unwrap_or( - crate::seam_manager::DEFAULT_CYCLE_THRESHOLD, - ), - seam_model: api_config - .context - .seam_model - .clone() - .unwrap_or_else(|| crate::seam_manager::DEFAULT_SEAM_MODEL.to_string()), - }; - SeamManager::new(main_client.clone(), seam_config) - }); - - let lsp_manager = Arc::new(match config.lsp_config.clone() { - Some(cfg) => crate::lsp::LspManager::new(cfg, config.workspace.clone()), - None => crate::lsp::LspManager::disabled(), - }); - - let mut engine = Engine { - config, - deepseek_client, - deepseek_client_error, - session, - subagent_manager, - shell_manager, - mcp_pool: None, - rx_op, - rx_approval, - rx_user_input, - rx_steer, - tx_event, - cancel_token: cancel_token.clone(), - shared_cancel_token: shared_cancel_token.clone(), - tool_exec_lock, - capacity_controller, - seam_manager, - coherence_state: CoherenceState::default(), - turn_counter: 0, - lsp_manager, - pending_lsp_blocks: Vec::new(), - }; - engine.rehydrate_latest_canonical_state(); - pending_lsp_blocks: Vec::new(), - }; - engine.rehydrate_latest_canonical_state(); - - let handle = EngineHandle { - tx_op, - rx_event: Arc::new(RwLock::new(rx_event)), - cancel_token: shared_cancel_token, - tx_approval, - tx_user_input, - tx_steer, - }; - - (engine, handle) - } - - /// Run the engine event loop - #[allow(clippy::too_many_lines)] - pub async fn run(mut self) { - while let Some(op) = self.rx_op.recv().await { - match op { - Op::SendMessage { - content, - mode, - model, - reasoning_effort, - allow_shell, - trust_mode, - auto_approve, - } => { - self.handle_send_message( - content, - mode, - model, - reasoning_effort, - allow_shell, - trust_mode, - auto_approve, - ) - .await; - } - Op::CancelRequest => { - self.cancel_token.cancel(); - self.reset_cancel_token(); - } - Op::ApproveToolCall { id } => { - // Tool approval handling will be implemented in tools module - let _ = self - .tx_event - .send(Event::status(format!("Approved tool call: {id}"))) - .await; - } - Op::DenyToolCall { id } => { - let _ = self - .tx_event - .send(Event::status(format!("Denied tool call: {id}"))) - .await; - } - Op::SpawnSubAgent { prompt } => { - let Some(client) = self.deepseek_client.clone() else { - let message = self - .deepseek_client_error - .as_deref() - .map(|err| format!("Failed to spawn sub-agent: {err}")) - .unwrap_or_else(|| { - "Failed to spawn sub-agent: API client not configured".to_string() - }); - let _ = self - .tx_event - .send(Event::error(ErrorEnvelope::fatal(message))) - .await; - continue; - }; - - let runtime = SubAgentRuntime::new( - client, - self.session.model.clone(), - // Sub-agents don't inherit YOLO mode - use Agent mode defaults - self.build_tool_context(AppMode::Agent, self.session.auto_approve), - self.session.allow_shell, - Some(self.tx_event.clone()), - Arc::clone(&self.subagent_manager), - ) - .with_max_spawn_depth(self.config.max_spawn_depth); - - let result = { - let mut manager = self.subagent_manager.lock().await; - manager.spawn_background( - Arc::clone(&self.subagent_manager), - runtime, - SubAgentType::General, - prompt.clone(), - None, - ) - }; - - match result { - Ok(snapshot) => { - let _ = self - .tx_event - .send(Event::status(format!( - "Spawned sub-agent {}", - snapshot.agent_id - ))) - .await; - } - Err(err) => { - let _ = self - .tx_event - .send(Event::error(ErrorEnvelope::fatal(format!( - "Failed to spawn sub-agent: {err}" - )))) - .await; - } - } - } - Op::ListSubAgents => { - let agents = { - let mut manager = self.subagent_manager.lock().await; - manager.cleanup(Duration::from_secs(60 * 60)); - manager.list() - }; - let _ = self.tx_event.send(Event::AgentList { agents }).await; - } - Op::ChangeMode { mode } => { - let _ = self - .tx_event - .send(Event::status(format!("Mode changed to: {mode:?}"))) - .await; - } - Op::SetModel { model } => { - self.session.model = model; - self.config.model.clone_from(&self.session.model); - let _ = self - .tx_event - .send(Event::status(format!( - "Model set to: {}", - self.session.model - ))) - .await; - } - Op::SetCompaction { config } => { - let enabled = config.enabled; - self.config.compaction = config; - let _ = self - .tx_event - .send(Event::status(format!( - "Auto-compaction {}", - if enabled { "enabled" } else { "disabled" } - ))) - .await; - } - Op::SyncSession { - messages, - system_prompt, - model, - workspace, - } => { - self.session.messages = messages; - self.session.compaction_summary_prompt = - extract_compaction_summary_prompt(system_prompt.clone()); - self.session.system_prompt = system_prompt; - self.session.model = model; - self.session.workspace = workspace.clone(); - self.config.model.clone_from(&self.session.model); - self.config.workspace = workspace.clone(); - let ctx = crate::project_context::load_project_context_with_parents(&workspace); - self.session.project_context = if ctx.has_instructions() { - Some(ctx) - } else { - None - }; - self.session.rebuild_working_set(); - self.rehydrate_latest_canonical_state(); - self.emit_session_updated().await; - let _ = self - .tx_event - .send(Event::status("Session context synced".to_string())) - .await; - } - Op::CompactContext => { - self.handle_manual_compaction().await; - } - Op::Rlm { - content, - model, - child_model, - max_depth, - } => { - self.handle_rlm(content, model, child_model, max_depth) - .await; - } - Op::Shutdown => { - break; - } - } - } - } - - async fn emit_session_updated(&self) { - let _ = self - .tx_event - .send(Event::SessionUpdated { - messages: self.session.messages.clone(), - system_prompt: self.session.system_prompt.clone(), - model: self.session.model.clone(), - workspace: self.session.workspace.clone(), - }) - .await; - } - - async fn add_session_message(&mut self, message: Message) { - self.session.add_message(message); - self.emit_session_updated().await; - } - - /// #136: post-edit hook. Inspects the tool name + input, derives the - /// edited file path, and asks the LSP manager for diagnostics. The - /// rendered block is queued in `pending_lsp_blocks` and flushed to the - /// session message stream just before the next API request. Failure is - /// silent by design — a missing/crashing LSP server must never block - /// the agent. - async fn run_post_edit_lsp_hook(&mut self, tool_name: &str, tool_input: &serde_json::Value) { - if !self.lsp_manager.config().enabled { - return; - } - let paths = edited_paths_for_tool(tool_name, tool_input); - for path in paths { - let absolute = if path.is_absolute() { - path.clone() - } else { - self.session.workspace.join(&path) - }; - // Use a short edit-sequence based on the existing turn counter so - // log output stays correlated even though we do not currently - // batch by sequence. - let seq = self.turn_counter; - if let Some(block) = self.lsp_manager.diagnostics_for(&absolute, seq).await { - self.pending_lsp_blocks.push(block); - } - } - } - - /// Drain `pending_lsp_blocks` into a single synthetic user message so the - /// model sees the diagnostics on its next request. Skips when nothing is - /// pending. The message uses the standard `text` content block shape - /// (the same shape as the post-tool steer messages) so we don't need to - /// invent a new envelope. - async fn flush_pending_lsp_diagnostics(&mut self) { - if self.pending_lsp_blocks.is_empty() { - return; - } - let blocks = std::mem::take(&mut self.pending_lsp_blocks); - let rendered = crate::lsp::render_blocks(&blocks); - if rendered.is_empty() { - return; - } - self.add_session_message(Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: rendered, - cache_control: None, - }], - }) - .await; - } - - /// Handle a send message operation - #[allow(clippy::too_many_arguments)] - async fn handle_send_message( - &mut self, - content: String, - mode: AppMode, - model: String, - reasoning_effort: Option, - allow_shell: bool, - trust_mode: bool, - auto_approve: bool, - ) { - // Reset cancel token for fresh turn (in case previous was cancelled) - self.reset_cancel_token(); - - // Drain stale steer messages from previous turns. - while self.rx_steer.try_recv().is_ok() {} - - // Create turn context first so start event includes a stable turn id. - let mut turn = TurnContext::new(self.config.max_steps); - self.turn_counter = self.turn_counter.saturating_add(1); - self.capacity_controller.mark_turn_start(self.turn_counter); - - // Snapshot the workspace BEFORE we touch a single tool. Run the git - // work on the blocking pool so the async runtime stays responsive; - // failure is non-fatal (the helper logs at WARN). - if self.config.snapshots_enabled { - let pre_workspace = self.session.workspace.clone(); - let pre_seq = self.turn_counter; - let _ = tokio::task::spawn_blocking(move || pre_turn_snapshot(&pre_workspace, pre_seq)) - .await; - } - - // Emit turn started event - let _ = self - .tx_event - .send(Event::TurnStarted { - turn_id: turn.id.clone(), - }) - .await; - - // Check if we have the appropriate client - if self.deepseek_client.is_none() { - let message = self - .deepseek_client_error - .as_deref() - .map(|err| format!("Failed to send message: {err}")) - .unwrap_or_else(|| "Failed to send message: API client not configured".to_string()); - let _ = self - .tx_event - .send(Event::error(ErrorEnvelope::fatal_auth(message.clone()))) - .await; - let _ = self - .tx_event - .send(Event::TurnComplete { - usage: turn.usage.clone(), - status: TurnOutcomeStatus::Failed, - error: Some(message), - }) - .await; - return; - } - - self.session - .working_set - .observe_user_message(&content, &self.session.workspace); - let force_update_plan_first = should_force_update_plan_first(mode, &content); - - // Add user message to session - let user_msg = Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: content, - cache_control: None, - }], - }; - self.session.add_message(user_msg); - - self.session.model = model; - self.config.model.clone_from(&self.session.model); - self.session.reasoning_effort = reasoning_effort; - self.session.allow_shell = allow_shell; - self.config.allow_shell = allow_shell; - self.session.trust_mode = trust_mode; - self.config.trust_mode = trust_mode; - self.session.auto_approve = auto_approve; - - // Update system prompt to match current mode and include persisted compaction context. - self.refresh_system_prompt(mode); - self.emit_session_updated().await; - - // Build tool registry and tool list for the current mode - let todo_list = self.config.todos.clone(); - let plan_state = self.config.plan_state.clone(); - - let tool_context = self.build_tool_context(mode, auto_approve); - let mut builder = if mode == AppMode::Plan { - ToolRegistryBuilder::new() - .with_read_only_file_tools() - .with_search_tools() - .with_git_tools() - .with_git_history_tools() - .with_diagnostics_tool() - .with_validation_tools() - .with_todo_tool(todo_list.clone()) - .with_plan_tool(plan_state.clone()) - } else { - ToolRegistryBuilder::new() - .with_agent_tools(self.session.allow_shell) - .with_todo_tool(todo_list.clone()) - .with_plan_tool(plan_state.clone()) - }; - - builder = builder - .with_review_tool(self.deepseek_client.clone(), self.session.model.clone()) - .with_rlm_tool(self.deepseek_client.clone(), self.session.model.clone()) - .with_user_input_tool() - .with_parallel_tool(); - - if self.config.features.enabled(Feature::ApplyPatch) && mode != AppMode::Plan { - builder = builder.with_patch_tools(); - } - if self.config.features.enabled(Feature::WebSearch) { - builder = builder.with_web_tools(); - } - // Plan mode now keeps shell available — the existing approval flow - // and command-safety classifier gate destructive commands. Writes - // and patches stay blocked above; that's the only "destructive" - // boundary plan mode enforces by tool registration. - if self.config.features.enabled(Feature::ShellTool) && self.session.allow_shell { - builder = builder.with_shell_tools(); - } - - // Mailbox for structured sub-agent envelopes (#128/#130). One per - // turn: the receiver is drained by a short-lived task that converts - // envelopes into `Event::SubAgentMailbox` so the UI can route them - // to the matching in-transcript card. The drainer exits naturally - // when every cloned sender is dropped at turn-end. - let mailbox_for_runtime = if self.config.features.enabled(Feature::Subagents) { - let cancel_token = self.cancel_token.child_token(); - let (mailbox, mut receiver) = Mailbox::new(cancel_token.clone()); - let tx_event_clone = self.tx_event.clone(); - tokio::spawn(async move { - while let Some(envelope) = receiver.recv().await { - if tx_event_clone - .send(Event::SubAgentMailbox { - seq: envelope.seq, - message: envelope.message, - }) - .await - .is_err() - { - break; - } - } - }); - Some((mailbox, cancel_token)) - } else { - None - }; - - let tool_registry = match mode { - AppMode::Agent | AppMode::Yolo => { - if self.config.features.enabled(Feature::Subagents) { - let runtime = if let Some(client) = self.deepseek_client.clone() { - let mut rt = SubAgentRuntime::new( - client, - self.session.model.clone(), - tool_context.clone(), - self.session.allow_shell, - Some(self.tx_event.clone()), - Arc::clone(&self.subagent_manager), - ) - .with_max_spawn_depth(self.config.max_spawn_depth); - if let Some((mailbox, cancel_token)) = mailbox_for_runtime.as_ref() { - rt = rt - .with_mailbox(mailbox.clone()) - .with_cancel_token(cancel_token.clone()); - } - Some(rt) - } else { - None - }; - Some( - builder - .with_subagent_tools( - self.subagent_manager.clone(), - runtime.expect("sub-agent runtime should exist with active client"), - ) - .build(tool_context), - ) - } else { - Some(builder.build(tool_context)) - } - } - _ => Some(builder.build(tool_context)), - }; - - let mcp_tools = if self.config.features.enabled(Feature::Mcp) { - self.mcp_tools().await - } else { - Vec::new() - }; - let tools = tool_registry.as_ref().map(|registry| { - let mut tools = registry.to_api_tools(); - for tool in &mut tools { - tool.defer_loading = Some(should_default_defer_tool(&tool.name, mode)); - } - let mut mcp_tools = mcp_tools; - for tool in &mut mcp_tools { - if mode == AppMode::Yolo { - tool.defer_loading = Some(false); - continue; - } - - let keep_loaded = matches!( - tool.name.as_str(), - "list_mcp_resources" - | "list_mcp_resource_templates" - | "mcp_read_resource" - | "read_mcp_resource" - | "mcp_get_prompt" - ); - tool.defer_loading = Some(!keep_loaded); - } - tools.extend(mcp_tools); - tools - }); - - // Main turn loop - let (status, error) = self - .handle_deepseek_turn( - &mut turn, - tool_registry.as_ref(), - tools, - mode, - force_update_plan_first, - ) - .await; - - // Update session usage - self.session.total_usage.add(&turn.usage); - - // Emit turn complete event - let _ = self - .tx_event - .send(Event::TurnComplete { - usage: turn.usage, - status, - error, - }) - .await; - - // Post-turn snapshot. Same non-blocking, non-fatal contract as - // the pre-turn hook above. - if self.config.snapshots_enabled { - let post_workspace = self.session.workspace.clone(); - let post_seq = self.turn_counter; - let _ = - tokio::task::spawn_blocking(move || post_turn_snapshot(&post_workspace, post_seq)) - .await; - } - - // Checkpoint-restart cycle boundary (issue #124). The turn just - // settled cleanly — no in-flight tools, no streaming, no pending - // approval — so this is the safe phase to swap the context if we've - // crossed the per-cycle token threshold. We only fire on a - // Completed turn; Failed/Interrupted turns leave the buffer alone - // so the user can retry without a forced reset. - if matches!(status, TurnOutcomeStatus::Completed) { - self.maybe_advance_cycle(mode).await; - } - } - - async fn handle_manual_compaction(&mut self) { - let id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]); - let zero_usage = Usage { - input_tokens: 0, - output_tokens: 0, - ..Usage::default() - }; - let Some(client) = self.deepseek_client.clone() else { - let message = "Manual compaction unavailable: API client not configured".to_string(); - self.emit_compaction_failed(id, false, message.clone()) - .await; - let _ = self - .tx_event - .send(Event::error(ErrorEnvelope::fatal_auth(message.clone()))) - .await; - let _ = self - .tx_event - .send(Event::TurnComplete { - usage: zero_usage, - status: TurnOutcomeStatus::Failed, - error: Some(message), - }) - .await; - return; - }; - - let start_message = "Manual context compaction started".to_string(); - self.emit_compaction_started(id.clone(), false, start_message) - .await; - - let compaction_pins = self - .session - .working_set - .pinned_message_indices(&self.session.messages, &self.session.workspace); - let compaction_paths = self.session.working_set.top_paths(24); - let messages_before = self.session.messages.len(); - let mut turn_status = TurnOutcomeStatus::Completed; - let mut turn_error = None; - - match compact_messages_safe( - &client, - &self.session.messages, - &self.config.compaction, - Some(&self.session.workspace), - Some(&compaction_pins), - Some(&compaction_paths), - ) - .await - { - Ok(result) => { - if !result.messages.is_empty() || self.session.messages.is_empty() { - let messages_after = result.messages.len(); - self.session.messages = result.messages; - self.merge_compaction_summary(result.summary_prompt); - self.emit_session_updated().await; - let removed = messages_before.saturating_sub(messages_after); - let message = if result.retries_used > 0 { - format!( - "Compaction complete: {messages_before} → {messages_after} messages ({removed} removed, {} retries)", - result.retries_used - ) - } else { - format!( - "Compaction complete: {messages_before} → {messages_after} messages ({removed} removed)" - ) - }; - self.emit_compaction_completed( - id, - false, - message, - Some(messages_before), - Some(messages_after), - ) - .await; - } else { - let message = "Compaction skipped: produced empty result".to_string(); - self.emit_compaction_failed(id, false, message.clone()) - .await; - turn_status = TurnOutcomeStatus::Failed; - turn_error = Some(message); - } - } - Err(err) => { - let message = format!("Manual context compaction failed: {err}"); - self.emit_compaction_failed(id, false, message.clone()) - .await; - let _ = self.tx_event.send(Event::status(message.clone())).await; - turn_status = TurnOutcomeStatus::Failed; - turn_error = Some(message); - } - } - - let _ = self - .tx_event - .send(Event::TurnComplete { - usage: zero_usage, - status: turn_status, - error: turn_error, - }) - .await; - } - - /// Handle a Recursive Language Model (RLM) query — Algorithm 1 from - /// Zhang et al. (arXiv:2512.24601). - /// - /// The prompt is stored as PROMPT in a REPL variable. The root LLM - /// only sees metadata about the REPL state, never the prompt text - /// directly. The model generates Python code, which is executed by - /// the REPL. When FINAL() is called, the loop ends. - async fn handle_rlm( - &mut self, - content: String, - model: String, - child_model: String, - max_depth: u32, - ) { - use crate::rlm::turn::run_rlm_turn; - - let Some(ref client) = self.deepseek_client else { - let err = self - .deepseek_client_error - .as_deref() - .map(|s| s.to_string()) - .unwrap_or_else(|| "API client not configured".to_string()); - let _ = self - .tx_event - .send(Event::error(ErrorEnvelope::fatal_auth(format!( - "RLM error: {err}" - )))) - .await; - return; - }; - - let _ = self - .tx_event - .send(Event::status("RLM turn started".to_string())) - .await; - - let result = run_rlm_turn( - client, - model, - content, - child_model, - self.tx_event.clone(), - max_depth, - ) - .await; - - let has_error = result.error.is_some(); - if let Some(ref err) = result.error { - let _ = self - .tx_event - .send(Event::error(ErrorEnvelope::tool(format!( - "RLM error: {err}" - )))) - .await; - } - - if !result.answer.is_empty() { - // Add the final answer as an assistant message in the session. - self.add_session_message(crate::models::Message { - role: "assistant".to_string(), - content: vec![crate::models::ContentBlock::Text { - text: result.answer.clone(), - cache_control: None, - }], - }) - .await; - - let _ = self - .tx_event - .send(Event::MessageDelta { - index: 0, - content: result.answer.clone(), - }) - .await; - let _ = self - .tx_event - .send(Event::MessageComplete { index: 0 }) - .await; - } - - let _ = self - .tx_event - .send(Event::TurnComplete { - usage: result.usage, - status: if has_error { - crate::core::events::TurnOutcomeStatus::Failed - } else { - crate::core::events::TurnOutcomeStatus::Completed - }, - error: result.error, - }) - .await; - } - - fn estimated_input_tokens(&self) -> usize { - estimate_input_tokens_conservative( - &self.session.messages, - self.session.system_prompt.as_ref(), - ) - } - - fn trim_oldest_messages_to_budget(&mut self, target_input_budget: usize) -> usize { - let mut removed = 0usize; - while self.session.messages.len() > MIN_RECENT_MESSAGES_TO_KEEP - && self.estimated_input_tokens() > target_input_budget - { - self.session.messages.remove(0); - removed = removed.saturating_add(1); - } - removed - } - - async fn recover_context_overflow( - &mut self, - client: &DeepSeekClient, - reason: &str, - requested_output_tokens: u32, - ) -> bool { - let Some(target_budget) = - context_input_budget(&self.session.model, requested_output_tokens) - else { - return false; - }; - - let id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]); - let start_message = format!("Emergency context compaction started ({reason})"); - self.emit_compaction_started(id.clone(), true, start_message) - .await; - - let before_tokens = self.estimated_input_tokens(); - let before_count = self.session.messages.len(); - - let mut retries_used = 0u32; - let mut summary_prompt = None; - let mut compacted_messages = self.session.messages.clone(); - - let mut forced_config = self.config.compaction.clone(); - forced_config.enabled = true; - forced_config.token_threshold = forced_config - .token_threshold - .min(target_budget.saturating_sub(1)) - .max(1); - forced_config.message_threshold = forced_config.message_threshold.max(1); - - match compact_messages_safe( - client, - &self.session.messages, - &forced_config, - Some(&self.session.workspace), - None, - None, - ) - .await - { - Ok(result) => { - retries_used = result.retries_used; - compacted_messages = result.messages; - summary_prompt = result.summary_prompt; - } - Err(err) => { - let _ = self - .tx_event - .send(Event::status(format!( - "Emergency compaction API pass failed: {err}. Falling back to local trim." - ))) - .await; - } - } - - if !compacted_messages.is_empty() || self.session.messages.is_empty() { - self.session.messages = compacted_messages; - } - self.merge_compaction_summary(summary_prompt); - - let trimmed = self.trim_oldest_messages_to_budget(target_budget); - self.emit_session_updated().await; - let after_tokens = self.estimated_input_tokens(); - let after_count = self.session.messages.len(); - let recovered = after_tokens <= target_budget - && (after_tokens < before_tokens || after_count < before_count || trimmed > 0); - - if recovered { - let removed = before_count.saturating_sub(after_count); - let mut details = format!( - "Emergency compaction complete: {before_count} → {after_count} messages ({removed} removed), ~{before_tokens} → ~{after_tokens} tokens" - ); - if retries_used > 0 { - details.push_str(&format!(" ({} retries)", retries_used)); - } - if trimmed > 0 { - details.push_str(&format!(", trimmed {trimmed} oldest")); - } - self.emit_compaction_completed( - id, - true, - details.clone(), - Some(before_count), - Some(after_count), - ) - .await; - let _ = self.tx_event.send(Event::status(details)).await; - return true; - } - - let message = format!( - "Emergency context compaction failed to reduce request below model limit \ - (estimate ~{} tokens, budget ~{}).", - after_tokens, target_budget - ); - self.emit_compaction_failed(id, true, message.clone()).await; - let _ = self.tx_event.send(Event::status(message)).await; - false - } - - fn build_tool_context(&self, mode: AppMode, auto_approve: bool) -> ToolContext { - // Load the per-workspace trusted-paths list (#29) on every tool-context - // build. Cheap (a small JSON file) and always reflects the latest - // `/trust add` / `/trust remove` mutations without an explicit cache - // refresh hook. - let trusted = crate::workspace_trust::WorkspaceTrust::load_for(&self.session.workspace); - let mut ctx = ToolContext::with_auto_approve( - self.session.workspace.clone(), - self.session.trust_mode, - self.session.notes_path.clone(), - self.session.mcp_config_path.clone(), - mode == AppMode::Yolo || auto_approve, - ) - .with_state_namespace(self.session.id.clone()) - .with_features(self.config.features.clone()) - .with_shell_manager(self.shell_manager.clone()) - .with_trusted_external_paths(trusted.paths().to_vec()); - - if let Some(decider) = self.config.network_policy.as_ref() { - ctx = ctx.with_network_policy(decider.clone()); - } - - if mode == AppMode::Yolo { - ctx.with_elevated_sandbox_policy(crate::sandbox::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![self.session.workspace.clone()], - network_access: true, - exclude_tmpdir: false, - exclude_slash_tmp: false, - }) - } else { - ctx - } - } - - async fn ensure_mcp_pool(&mut self) -> Result>, ToolError> { - if let Some(pool) = self.mcp_pool.as_ref() { - return Ok(Arc::clone(pool)); - } - let mut pool = McpPool::from_config_path(&self.session.mcp_config_path) - .map_err(|e| ToolError::execution_failed(format!("Failed to load MCP config: {e}")))?; - if let Some(decider) = self.config.network_policy.as_ref() { - pool = pool.with_network_policy(decider.clone()); - } - let pool = Arc::new(AsyncMutex::new(pool)); - self.mcp_pool = Some(Arc::clone(&pool)); - Ok(pool) - } - - async fn mcp_tools(&mut self) -> Vec { - let pool = match self.ensure_mcp_pool().await { - Ok(pool) => pool, - Err(err) => { - let _ = self.tx_event.send(Event::status(err.to_string())).await; - return Vec::new(); - } - }; - - let mut pool = pool.lock().await; - let errors = pool.connect_all().await; - for (server, err) in errors { - let _ = self - .tx_event - .send(Event::status(format!( - "Failed to connect MCP server '{server}': {err}" - ))) - .await; - } - - pool.to_api_tools() - } - - async fn execute_mcp_tool_with_pool( - pool: Arc>, - name: &str, - input: serde_json::Value, - ) -> Result { - let mut pool = pool.lock().await; - let result = pool - .call_tool(name, input) - .await - .map_err(|e| ToolError::execution_failed(format!("MCP tool failed: {e}")))?; - let content = serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()); - Ok(ToolResult::success(content)) - } - - async fn execute_parallel_tool( - &mut self, - input: serde_json::Value, - tool_registry: Option<&crate::tools::ToolRegistry>, - tool_exec_lock: Arc>, - ) -> Result { - let calls = parse_parallel_tool_calls(&input)?; - let mcp_pool = if calls.iter().any(|(tool, _)| McpPool::is_mcp_tool(tool)) { - Some(self.ensure_mcp_pool().await?) - } else { - None - }; - let Some(registry) = tool_registry else { - return Err(ToolError::not_available( - "tool registry unavailable for multi_tool_use.parallel", - )); - }; - - let mut tasks = FuturesUnordered::new(); - for (tool_name, tool_input) in calls { - if tool_name == MULTI_TOOL_PARALLEL_NAME { - return Err(ToolError::invalid_input( - "multi_tool_use.parallel cannot call itself", - )); - } - if McpPool::is_mcp_tool(&tool_name) { - if !mcp_tool_is_parallel_safe(&tool_name) { - return Err(ToolError::invalid_input(format!( - "Tool '{tool_name}' is an MCP tool and cannot run in parallel. \ - Allowed MCP tools: list_mcp_resources, list_mcp_resource_templates, \ - mcp_read_resource, read_mcp_resource, mcp_get_prompt." - ))); - } - } else { - let Some(spec) = registry.get(&tool_name) else { - return Err(ToolError::not_available(format!( - "tool '{tool_name}' is not registered" - ))); - }; - if !spec.is_read_only() { - return Err(ToolError::invalid_input(format!( - "Tool '{tool_name}' is not read-only and cannot run in parallel" - ))); - } - if spec.approval_requirement() != ApprovalRequirement::Auto { - return Err(ToolError::invalid_input(format!( - "Tool '{tool_name}' requires approval and cannot run in parallel" - ))); - } - if !spec.supports_parallel() { - return Err(ToolError::invalid_input(format!( - "Tool '{tool_name}' does not support parallel execution" - ))); - } - } - - let registry_ref = registry; - let lock = tool_exec_lock.clone(); - let tx_event = self.tx_event.clone(); - let mcp_pool = mcp_pool.clone(); - tasks.push(async move { - let result = Engine::execute_tool_with_lock( - lock, - true, - false, - tx_event, - tool_name.clone(), - tool_input.clone(), - Some(registry_ref), - mcp_pool, - None, - ) - .await; - (tool_name, result) - }); - } - - let mut results = Vec::new(); - while let Some((tool_name, result)) = tasks.next().await { - match result { - Ok(output) => { - let mut error = None; - if !output.success { - error = Some(output.content.clone()); - } - results.push(ParallelToolResultEntry { - tool_name, - success: output.success, - content: output.content, - error, - }); - } - Err(err) => { - let message = format!("{err}"); - results.push(ParallelToolResultEntry { - tool_name, - success: false, - content: format!("Error: {message}"), - error: Some(message), - }); - } - } - } - - ToolResult::json(&ParallelToolResult { results }) - .map_err(|e| ToolError::execution_failed(e.to_string())) - } - - #[allow(clippy::too_many_arguments)] - async fn execute_tool_with_lock( - lock: Arc>, - supports_parallel: bool, - interactive: bool, - tx_event: mpsc::Sender, - tool_name: String, - tool_input: serde_json::Value, - registry: Option<&crate::tools::ToolRegistry>, - mcp_pool: Option>>, - context_override: Option, - ) -> Result { - let _guard = if supports_parallel { - ToolExecGuard::Read(lock.read().await) - } else { - ToolExecGuard::Write(lock.write().await) - }; - - if interactive { - let _ = tx_event.send(Event::PauseEvents).await; - } - - let result = if McpPool::is_mcp_tool(&tool_name) { - if let Some(pool) = mcp_pool { - Engine::execute_mcp_tool_with_pool(pool, &tool_name, tool_input).await - } else { - Err(ToolError::not_available(format!( - "tool '{tool_name}' is not registered" - ))) - } - } else if let Some(registry) = registry { - registry - .execute_full_with_context(&tool_name, tool_input, context_override.as_ref()) - .await - } else { - Err(ToolError::not_available(format!( - "tool '{tool_name}' is not registered" - ))) - }; - - if interactive { - let _ = tx_event.send(Event::ResumeEvents).await; - } - - result - } - - /// Handle a turn using the DeepSeek API. - #[allow(clippy::too_many_lines)] - /// Run the pre-request layered-context checkpoint (#159). Checks whether - /// cumulative tokens have crossed a soft-seam threshold and, if so, - /// produces an `` block via Flash and appends it as an - /// assistant message. Called from `handle_deepseek_turn` before each API - /// request so the model always has the latest navigation aids. - async fn layered_context_checkpoint(&mut self) { - let Some(ref seam_mgr) = self.seam_manager else { - return; - }; - if !seam_mgr.config().enabled { - return; - } - - // Cumulative tokens: session total (all turns so far) + current - // estimated input (the messages that will be sent next). - let cumulative_input = self - .session - .total_usage - .input_tokens - .saturating_add(self.session.total_usage.output_tokens); - let cumulative_estimate = - cumulative_input.saturating_add(self.estimated_input_tokens() as u64); - - let highest = seam_mgr.highest_level().await; - let Some(level) = seam_mgr.seam_level_for(cumulative_estimate as usize, highest) else { - return; - }; - - // Determine the message range to summarize: everything before the - // verbatim window. The verbatim window (last ~16 turns) stays - // untouched so the model always has ground-truth recent context. - let msg_count = self.session.messages.len(); - let verbatim_start = seam_mgr.verbatim_window_start(msg_count); - if verbatim_start == 0 { - return; // Not enough messages to summarize. - } - - let msg_range_end = verbatim_start; - let pinned = self - .session - .working_set - .pinned_message_indices(&self.session.messages, &self.session.workspace); - - let _ = self - .tx_event - .send(Event::status(format!( - "⏻ producing L{level} context seam ({msg_range_end} messages)…" - ))) - .await; - - // If we have existing seams, recompact; otherwise produce fresh. - let existing_seams = seam_mgr.collect_seam_texts(&self.session.messages).await; - let seam_text = if existing_seams.is_empty() { - match seam_mgr - .produce_soft_seam( - &self.session.messages, - level, - 0, - msg_range_end, - Some(&self.session.workspace), - &pinned, - ) - .await - { - Ok(text) => text, - Err(err) => { - crate::logging::warn(format!("L{level} soft seam failed: {err}")); - return; - } - } - } else { - let recent: Vec<&Message> = (0..msg_range_end) - .filter_map(|i| self.session.messages.get(i)) - .collect(); - match seam_mgr - .recompact(&existing_seams, &recent, level, 0, msg_range_end) - .await - { - Ok(text) => text, - Err(err) => { - crate::logging::warn(format!("L{level} recompact failed: {err}")); - return; - } - } - }; - - if seam_text.is_empty() { - return; - } - - // Append the seam as an assistant message. This is an append-only - // operation — no messages are deleted. The prefix cache stays hot. - self.add_session_message(Message { - role: "assistant".to_string(), - content: vec![ContentBlock::Text { - text: seam_text, - cache_control: None, - }], - }) - .await; - - let seam_count = seam_mgr.seam_count().await; - let _ = self - .tx_event - .send(Event::status(format!( - "⏻ L{level} seam complete ({seam_count} total, {msg_range_end} messages covered)" - ))) - .await; - } - - /// Run the checkpoint-restart cycle boundary if the session has crossed - /// its token threshold (issue #124). No-op in the common case. - /// - /// Caller must invoke this only at a clean turn boundary (no in-flight - /// tool, no open stream, no pending approval modal). The phase guard - /// inside `should_advance_cycle` is a defence-in-depth check; the - /// engine's wider state machine is the primary enforcement layer. - /// - /// Sub-agents are intentionally NOT awaited: each sub-agent has its own - /// context, the parent's reset doesn't invalidate them. Their handles - /// are captured in the structured-state block so the next cycle can see - /// they're still running. - async fn maybe_advance_cycle(&mut self, mode: AppMode) { - if !should_advance_cycle( - self.session.total_usage.input_tokens, - self.session.total_usage.output_tokens, - &self.session.model, - &self.config.cycle, - false, - ) { - return; - } - - let Some(client) = self.deepseek_client.clone() else { - crate::logging::warn( - "Cycle boundary skipped: API client not configured for briefing turn", - ); - return; - }; - - let from = self.session.cycle_count; - let to = from.saturating_add(1); - let archive_started = self.session.current_cycle_started; - let max_briefing_tokens = self.config.cycle.briefing_max_for(&self.session.model); - - let _ = self - .tx_event - .send(Event::status(format!( - "↻ context refreshing (cycle {from} → {to}, generating briefing…)" - ))) - .await; - - // 1. Generate the model-curated briefing. Prefer the Flash seam - // manager (#159) for cost and speed; fall back to the main model - // (legacy produce_briefing) when the seam manager isn't available. - let briefing_text = if let Some(ref seam_mgr) = self.seam_manager { - let seams = seam_mgr.collect_seam_texts(&self.session.messages).await; - let state_text = { - let s = StructuredState::capture( - mode.label(), - self.config.workspace.clone(), - std::env::current_dir().ok(), - &self.session.working_set, - &self.config.todos, - &self.config.plan_state, - Some(&self.subagent_manager), - ) - .await; - s.to_system_block() - }; - match seam_mgr - .produce_flash_briefing(&seams, state_text.as_deref()) - .await - { - Ok(text) => text, - Err(err) => { - crate::logging::warn(format!( - "Flash briefing failed, falling back to main model: {err}" - )); - match produce_briefing( - &client, - &self.session.model, - &self.session.messages, - max_briefing_tokens, - ) - .await - { - Ok(text) => text, - Err(err2) => { - crate::logging::warn(format!( - "Cycle briefing turn failed; skipping cycle advance: {err2}" - )); - let _ = self - .tx_event - .send(Event::status(format!( - "↻ cycle handoff failed (continuing in cycle {from}): {err2}" - ))) - .await; - return; - } - } - } - } - } else { - match produce_briefing( - &client, - &self.session.model, - &self.session.messages, - max_briefing_tokens, - ) - .await - { - Ok(text) => text, - Err(err) => { - crate::logging::warn(format!( - "Cycle briefing turn failed; skipping cycle advance: {err}" - )); - let _ = self - .tx_event - .send(Event::status(format!( - "↻ cycle handoff failed (continuing in cycle {from}): {err}" - ))) - .await; - return; - } - } - }; - - let briefing_tokens = estimate_briefing_tokens(&briefing_text); - let now = chrono::Utc::now(); - let briefing = CycleBriefing { - cycle: to, - timestamp: now, - briefing_text: briefing_text.clone(), - token_estimate: briefing_tokens, - }; - - // 2. Archive the cycle to disk. If the archive write fails we still - // proceed with the swap — the briefing alone preserves enough - // state to continue, and the user can recover the lost archive - // from their session log if needed. - match archive_cycle( - &self.session.id, - to, - &self.session.messages, - &self.session.model, - archive_started, - ) { - Ok(path) => { - crate::logging::info(format!("Cycle {to} archived to {}", path.display())); - } - Err(err) => { - crate::logging::warn(format!( - "Failed to archive cycle {to}; continuing with swap: {err}" - )); - } - } - - // 3. Capture structured state. Locks are held only for the snapshot. - let state = StructuredState::capture( - mode.label(), - self.config.workspace.clone(), - std::env::current_dir().ok(), - &self.session.working_set, - &self.config.todos, - &self.config.plan_state, - Some(&self.subagent_manager), - ) - .await; - let state_block = state.to_system_block(); - - // 4. Build the seed messages. The next cycle starts with the - // base system prompt (refreshed below) and these seeds. - let seed_messages = build_seed_messages( - state_block.as_deref(), - Some(&briefing), - None, // pending_user_message — pulled from steer/queue elsewhere - ); - - // 5. Atomic swap. - self.session.messages = seed_messages; - self.session.cycle_count = to; - self.session.current_cycle_started = now; - self.session.cycle_briefings.push(briefing.clone()); - // Reset seam tracking for the new cycle. - if let Some(ref seam_mgr) = self.seam_manager { - seam_mgr.reset().await; - } - // Drop any compaction summary — that path is incompatible with the - // fresh-context model and would Frankenstein-merge with the briefing. - self.session.compaction_summary_prompt = None; - self.refresh_system_prompt(mode); - self.emit_session_updated().await; - - let _ = self - .tx_event - .send(Event::CycleAdvanced { - from, - to, - briefing: briefing.clone(), - }) - .await; - let _ = self - .tx_event - .send(Event::status(format!( - "↻ context refreshed (cycle {from} → {to}, briefing: {briefing_tokens} tokens carried)" - ))) - .await; - } - - /// Refresh the system prompt based on current mode and context. - fn refresh_system_prompt(&mut self, mode: AppMode) { - let working_set_summary = self - .session - .working_set - .summary_block(&self.config.workspace); - let base = prompts::system_prompt_for_mode_with_context(mode, &self.config.workspace, None); - let stable_prompt = - merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone()); - self.session.system_prompt = - append_working_set_summary(stable_prompt, working_set_summary.as_deref()); - } - - fn merge_compaction_summary(&mut self, summary_prompt: Option) { - if summary_prompt.is_none() { - return; - } - self.session.compaction_summary_prompt = merge_system_prompts( - self.session.compaction_summary_prompt.as_ref(), - summary_prompt.clone(), - ); - let current_without_working_set = - remove_working_set_summary(self.session.system_prompt.as_ref()); - let merged = merge_system_prompts(current_without_working_set.as_ref(), summary_prompt); - let working_set_summary = self - .session - .working_set - .summary_block(&self.config.workspace); - self.session.system_prompt = - append_working_set_summary(merged, working_set_summary.as_deref()); - } -} - -/// Spawn the engine in a background task -pub fn spawn_engine(config: EngineConfig, api_config: &Config) -> EngineHandle { - let (engine, handle) = Engine::new(config, api_config); - - tokio::spawn(async move { - engine.run().await; - }); - - handle -} - -#[cfg(test)] -pub(crate) struct MockEngineHandle { - pub handle: EngineHandle, - pub rx_op: mpsc::Receiver, - rx_approval: mpsc::Receiver, - pub rx_steer: mpsc::Receiver, - pub tx_event: mpsc::Sender, - pub cancel_token: CancellationToken, -} - -#[cfg(test)] -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum MockApprovalEvent { - Approved { - id: String, - }, - Denied { - id: String, - }, - RetryWithPolicy { - id: String, - policy: crate::sandbox::SandboxPolicy, - }, -} - -#[cfg(test)] -impl MockEngineHandle { - pub(crate) async fn recv_approval_event(&mut self) -> Option { - match self.rx_approval.recv().await? { - ApprovalDecision::Approved { id } => Some(MockApprovalEvent::Approved { id }), - ApprovalDecision::Denied { id } => Some(MockApprovalEvent::Denied { id }), - ApprovalDecision::RetryWithPolicy { id, policy } => { - Some(MockApprovalEvent::RetryWithPolicy { id, policy }) - } - } - } -} - -#[cfg(test)] -pub(crate) fn mock_engine_handle() -> MockEngineHandle { - let (tx_op, rx_op) = mpsc::channel(32); - let (tx_event, rx_event) = mpsc::channel(256); - let (tx_approval, rx_approval) = mpsc::channel(64); - let (tx_user_input, _rx_user_input) = mpsc::channel(32); - let (tx_steer, rx_steer) = mpsc::channel(64); - let cancel_token = CancellationToken::new(); - let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone())); - let handle = EngineHandle { - tx_op, - rx_event: Arc::new(RwLock::new(rx_event)), - cancel_token: shared_cancel_token, - tx_approval, - tx_user_input, - tx_steer, - }; - - MockEngineHandle { - handle, - rx_op, - rx_approval, - rx_steer, - tx_event, - cancel_token, - } -} - -mod approval; -mod capacity_flow; -mod dispatch; -mod turn_loop; - -use self::approval::{ApprovalDecision, ApprovalResult, UserInputDecision}; -use self::dispatch::{ - ParallelToolResult, ParallelToolResultEntry, ToolExecGuard, ToolExecOutcome, ToolExecutionPlan, - final_tool_input, mcp_tool_approval_description, mcp_tool_is_parallel_safe, - mcp_tool_is_read_only, parse_parallel_tool_calls, parse_tool_input, - should_force_update_plan_first, should_parallelize_tool_batch, should_stop_after_plan_tool, -}; - -#[cfg(test)] -mod tests; diff --git a/crates/tui/src/cycle_manager.rs.bak3 b/crates/tui/src/cycle_manager.rs.bak3 deleted file mode 100644 index 8dd9f6c9..00000000 --- a/crates/tui/src/cycle_manager.rs.bak3 +++ /dev/null @@ -1,1014 +0,0 @@ -//! Checkpoint-restart cycle management for long-running sessions (issue #124). -//! -//! ## Why -//! -//! DeepSeek V4's empirical retrieval elbow is 128K tokens (paper Figure 9 — -//! 8K/0.90, 64K/0.87, 128K/0.85, 256K/0.76, 512K/0.66, 1M/0.59). Lossy -//! summarization compaction creates a "Frankenstein" context — half verbatim, -//! half paraphrased — that the model cannot tell apart, so it treats the -//! summary as if it were verbatim and confabulates around the gaps. -//! -//! Checkpoint-restart fixes this by giving every cycle a *homogeneous* fresh -//! context: original system prompt, structured state (todos / plan / working -//! set / sub-agent handles), and a model-curated free-form briefing of at -//! most ~3,000 tokens. The previous cycle is archived to disk in JSONL form -//! so a future `recall_archive` tool (issue #127) can search it on demand. -//! -//! ## Layers of carry-forward -//! -//! 1. **Auto-preserved** (deterministic, no agent judgment): the original -//! system prompt, `SharedTodoList`, `SharedPlanState`, working-set paths, -//! open sub-agent snapshots, mode / workspace / cwd, and the user's most -//! recent unsent message. -//! 2. **Free-form briefing** (model-curated, wrapped as ``): -//! decisions made + why, constraints discovered, hypotheses being tested, -//! approaches that failed, open questions. Tool output bytes, file -//! contents, and step-by-step recaps explicitly do NOT belong here — -//! they're either in the archive or recoverable from disk. -//! -//! ## Trigger -//! -//! - Token threshold: **768K** by default (~75% of the 1M window). Soft seams -//! at 192K/384K/576K (layered context manager, #159) handle intermediate -//! thresholds. The hard cycle only fires near the wall. -//! - Phase guard: callers only invoke `should_advance_cycle` at clean turn -//! boundaries (no in-flight tool, no streaming, no approval modal). -//! - Per-model overrides: `[cycle.per_model]` in config.toml lets operators -//! tune the threshold separately for `deepseek-v4-pro` vs. `-flash`. - -use std::collections::HashMap; -use std::fs::{File, OpenOptions}; -use std::io::Write; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -use crate::client::DeepSeekClient; -use crate::llm_client::LlmClient; -use crate::models::{ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt}; -use crate::tools::plan::{PlanSnapshot, SharedPlanState}; -use crate::tools::subagent::{SharedSubAgentManager, SubAgentResult, SubAgentStatus}; -use crate::tools::todo::{SharedTodoList, TodoListSnapshot}; -use crate::working_set::WorkingSet; - -/// JSONL header record emitted as the first line of an archived cycle file. -const CYCLE_ARCHIVE_SCHEMA_VERSION: u32 = 1; - -/// Default token threshold at which a cycle boundary fires. -/// -/// Bumped from 110K (pre-#159) to 768K (~75% of 1M window) in v0.7.2. -/// The layered context manager (#159) handles intermediate thresholds via -/// soft seams at 192K/384K/576K, so the hard cycle only fires near the wall. -/// Default token threshold at which a cycle boundary fires. -/// -/// Bumped from 110K (pre-#159) to 768K (~75% of 1M window) in v0.7.2. -/// The layered context manager (#159) handles intermediate thresholds via -/// soft seams at 192K/384K/576K, so the hard cycle only fires near the wall. -pub const DEFAULT_CYCLE_THRESHOLD_TOKENS: usize = 768_000; - -/// Default cap on the model-curated briefing block. -pub const DEFAULT_BRIEFING_MAX_TOKENS: usize = 3_000; - -/// Conservative chars-per-token used to bound the briefing length to the -/// configured token cap. Matches `compaction::estimate_tokens` (~4 chars/token). -const APPROX_CHARS_PER_TOKEN: usize = 4; - -/// Per-model cycle tuning. Loaded from `[cycle.per_model.]`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ModelCycleConfig { - /// Token threshold above which a cycle boundary fires. - pub threshold_tokens: usize, - /// Cap on the model-curated `` briefing. - pub briefing_max_tokens: usize, -} - -impl Default for ModelCycleConfig { - fn default() -> Self { - Self { - threshold_tokens: DEFAULT_CYCLE_THRESHOLD_TOKENS, - briefing_max_tokens: DEFAULT_BRIEFING_MAX_TOKENS, - } - } -} - -/// Top-level cycle configuration. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CycleConfig { - /// Whether checkpoint-restart cycles are enabled. Defaults to true. - pub enabled: bool, - /// Default token threshold; per-model overrides take precedence when present. - pub threshold_tokens: usize, - /// Default briefing cap; per-model overrides take precedence when present. - pub briefing_max_tokens: usize, - /// Per-model overrides keyed by model identifier (e.g. `deepseek-v4-pro`). - pub per_model: HashMap, -} - -impl Default for CycleConfig { - fn default() -> Self { - let mut per_model: HashMap = HashMap::new(); - per_model.insert("deepseek-v4-pro".to_string(), ModelCycleConfig::default()); - per_model.insert("deepseek-v4-flash".to_string(), ModelCycleConfig::default()); - Self { - enabled: true, - threshold_tokens: DEFAULT_CYCLE_THRESHOLD_TOKENS, - briefing_max_tokens: DEFAULT_BRIEFING_MAX_TOKENS, - per_model, - } - } -} - -impl CycleConfig { - /// Resolve the threshold for a given model (per-model override > default). - #[must_use] - pub fn threshold_for(&self, model: &str) -> usize { - self.per_model - .get(model) - .map(|m| m.threshold_tokens) - .unwrap_or(self.threshold_tokens) - } - - /// Resolve the briefing-token cap for a given model. - #[must_use] - pub fn briefing_max_for(&self, model: &str) -> usize { - self.per_model - .get(model) - .map(|m| m.briefing_max_tokens) - .unwrap_or(self.briefing_max_tokens) - } -} - -/// Snapshot of a model-curated briefing produced at cycle handoff. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CycleBriefing { - /// 1-based cycle number this briefing closes (i.e. the cycle being archived). - pub cycle: u32, - /// UTC timestamp when the briefing turn completed. - pub timestamp: DateTime, - /// Extracted contents of the `` block. - pub briefing_text: String, - /// Approximate token count of `briefing_text`. - pub token_estimate: usize, -} - -/// Decide whether a cycle boundary should fire. -/// -/// `usage` is the *cumulative* session input+output tokens (both `u64` to -/// match `SessionUsage`). `in_flight` is true when a tool is mid-execution, -/// stream is open, or an approval modal is pending — in those cases the -/// caller must wait until the next clean boundary. -#[must_use] -pub fn should_advance_cycle( - cumulative_input_tokens: u64, - cumulative_output_tokens: u64, - model: &str, - cfg: &CycleConfig, - in_flight: bool, -) -> bool { - if !cfg.enabled || in_flight { - return false; - } - let total = cumulative_input_tokens.saturating_add(cumulative_output_tokens); - let threshold = cfg.threshold_for(model) as u64; - if threshold == 0 { - return false; - } - total >= threshold -} - -/// Roll-up of state that survives a cycle boundary deterministically. -/// -/// Construction is cheap — borrow the live state, snapshot it once, render it -/// into a system block. The snapshot decouples rendering from any mutex held -/// by the engine. -#[derive(Debug, Clone, Default)] -pub struct StructuredState { - pub mode_label: String, - pub workspace: PathBuf, - pub cwd: Option, - pub working_set_summary: Option, - pub todo_snapshot: Option, - pub plan_snapshot: Option, - pub subagent_snapshots: Vec, -} - -impl StructuredState { - /// Capture the current state. All locks are held only for the duration of - /// the snapshot. - pub async fn capture( - mode_label: impl Into, - workspace: PathBuf, - cwd: Option, - working_set: &WorkingSet, - todos: &SharedTodoList, - plan_state: &SharedPlanState, - subagents: Option<&SharedSubAgentManager>, - ) -> Self { - let working_set_summary = working_set.summary_block(&workspace); - - let todo_snapshot = { - let guard = todos.lock().await; - let snap = guard.snapshot(); - if snap.items.is_empty() { - None - } else { - Some(snap) - } - }; - - let plan_snapshot = { - let guard = plan_state.lock().await; - if guard.is_empty() { - None - } else { - Some(guard.snapshot()) - } - }; - - let subagent_snapshots = if let Some(handle) = subagents { - let guard = handle.lock().await; - guard - .list() - .into_iter() - .filter(|s| matches!(s.status, SubAgentStatus::Running)) - .collect() - } else { - Vec::new() - }; - - Self { - mode_label: mode_label.into(), - workspace, - cwd, - working_set_summary, - todo_snapshot, - plan_snapshot, - subagent_snapshots, - } - } - - /// Render the structured state as a single system block. Returns `None` - /// when there is nothing meaningful to carry forward (rare in practice — - /// at least the workspace and mode are always present). - #[must_use] - pub fn to_system_block(&self) -> Option { - let mut out = String::new(); - out.push_str("## Cycle State (Auto-Preserved)\n\n"); - out.push_str(&format!("- Mode: `{}`\n", self.mode_label)); - out.push_str(&format!("- Workspace: `{}`\n", self.workspace.display())); - if let Some(cwd) = self.cwd.as_ref() { - out.push_str(&format!("- Cwd: `{}`\n", cwd.display())); - } - - if let Some(plan) = self.plan_snapshot.as_ref() { - out.push_str("\n### Plan\n"); - if let Some(explanation) = plan.explanation.as_ref() { - out.push_str(&format!("{explanation}\n\n")); - } - for item in &plan.items { - let marker = match item.status { - crate::tools::plan::StepStatus::Pending => "[ ]", - crate::tools::plan::StepStatus::InProgress => "[~]", - crate::tools::plan::StepStatus::Completed => "[x]", - }; - out.push_str(&format!("- {marker} {}\n", item.step)); - } - } - - if let Some(todos) = self.todo_snapshot.as_ref() { - out.push_str(&format!( - "\n### Todos ({}% complete)\n", - todos.completion_pct - )); - for item in &todos.items { - let marker = match item.status { - crate::tools::todo::TodoStatus::Pending => "[ ]", - crate::tools::todo::TodoStatus::InProgress => "[~]", - crate::tools::todo::TodoStatus::Completed => "[x]", - }; - out.push_str(&format!("- {marker} {}\n", item.content)); - } - } - - if !self.subagent_snapshots.is_empty() { - out.push_str("\n### Open Sub-Agents\n"); - for s in &self.subagent_snapshots { - let role = s.assignment.role.as_deref().unwrap_or("—"); - let goal = if s.assignment.objective.is_empty() { - "(no objective set)" - } else { - s.assignment.objective.as_str() - }; - out.push_str(&format!("- `{}` (role: {}) — {}\n", s.agent_id, role, goal)); - } - } - - if let Some(working_set) = self.working_set_summary.as_deref() { - out.push('\n'); - out.push_str(working_set); - out.push('\n'); - } - - Some(out) - } -} - -/// Build the prompt the model uses to produce its `` briefing. -pub const CYCLE_HANDOFF_TEMPLATE: &str = include_str!("prompts/cycle_handoff.md"); - -/// Run the briefing turn. The caller drives this just before swapping the -/// session message buffer. The returned text is the contents of the -/// `` block — outer tags stripped, length-bounded to -/// `max_briefing_tokens` worth of characters as a defensive backstop in case -/// the model ignores the cap. -pub async fn produce_briefing( - client: &DeepSeekClient, - model: &str, - conversation: &[Message], - max_briefing_tokens: usize, -) -> Result { - if conversation.is_empty() { - return Ok(String::new()); - } - - // Append a synthetic instruction asking for the carry_forward block. We - // do not mutate the caller's conversation; this is a one-shot turn. - let mut messages: Vec = conversation.to_vec(); - messages.push(Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: format!( - "[CYCLE BOUNDARY] {}\n\nProduce your `` block now. \ - Stay under {} tokens. Output only the block — no other text.", - "The next turn starts in a fresh context.", max_briefing_tokens - ), - cache_control: None, - }], - }); - - let request = MessageRequest { - model: model.to_string(), - messages, - max_tokens: u32::try_from(max_briefing_tokens.saturating_mul(2)) - .unwrap_or(8_192) - .max(1_024), - system: Some(SystemPrompt::Blocks(vec![SystemBlock { - block_type: "text".to_string(), - text: CYCLE_HANDOFF_TEMPLATE.to_string(), - cache_control: None, - }])), - tools: None, - tool_choice: None, - metadata: None, - thinking: None, - reasoning_effort: None, - stream: Some(false), - // Briefings benefit from low temperature — we want consistent state - // capture, not stylistic variation. - temperature: Some(0.2), - top_p: None, - }; - - let response = client - .create_message(request) - .await - .with_context(|| format!("Cycle briefing turn failed for model {model}"))?; - - let raw = response - .content - .iter() - .filter_map(|block| match block { - ContentBlock::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("\n"); - - let extracted = extract_carry_forward(&raw); - let bounded = enforce_briefing_cap(&extracted, max_briefing_tokens); - Ok(bounded) -} - -/// Pull the contents of the first `...` block -/// out of the raw model response. If the tags are missing, return the trimmed -/// raw text — the caller would rather have *some* briefing than nothing. -#[must_use] -pub fn extract_carry_forward(raw: &str) -> String { - let lower = raw.to_ascii_lowercase(); - let open_tag = ""; - let close_tag = ""; - - if let Some(start) = lower.find(open_tag) { - let after = start + open_tag.len(); - let tail = &raw[after..]; - let tail_lower = &lower[after..]; - if let Some(end) = tail_lower.find(close_tag) { - return tail[..end].trim().to_string(); - } - // Open tag without close tag — take everything after, trimmed. - return tail.trim().to_string(); - } - raw.trim().to_string() -} - -/// Defensive bound on briefing length. Calibrated at ~4 chars/token to match -/// the rest of the codebase's token estimator. -fn enforce_briefing_cap(text: &str, max_tokens: usize) -> String { - let max_chars = max_tokens.saturating_mul(APPROX_CHARS_PER_TOKEN); - if max_chars == 0 { - return String::new(); - } - if text.chars().count() <= max_chars { - return text.to_string(); - } - let mut out: String = text.chars().take(max_chars).collect(); - out.push_str("\n\n[...briefing truncated to fit cap...]"); - out -} - -/// Estimate briefing tokens — same method as `compaction::estimate_tokens` -/// for symmetry: ~4 chars per token. -#[must_use] -pub fn estimate_briefing_tokens(text: &str) -> usize { - text.len().div_ceil(APPROX_CHARS_PER_TOKEN) -} - -/// Header record written as the first line of an archived cycle JSONL file. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CycleArchiveHeader { - pub schema_version: u32, - pub cycle: u32, - pub session_id: String, - pub model: String, - pub started: DateTime, - pub ended: DateTime, - pub message_count: usize, -} - -/// Resolve the on-disk archive directory: `~/.deepseek/sessions//cycles`. -fn archive_dir_for(session_id: &str) -> Result { - let home = dirs::home_dir().context("Could not resolve home directory for cycle archive")?; - Ok(home - .join(".deepseek") - .join("sessions") - .join(session_id) - .join("cycles")) -} - -/// Archive a cycle's messages to JSONL on disk and return the path written. -/// -/// The first line is a `CycleArchiveHeader` JSON object; each subsequent -/// line is a single `Message` serialized as JSON. -pub fn archive_cycle( - session_id: &str, - cycle_n: u32, - messages: &[Message], - model: &str, - started: DateTime, -) -> Result { - let dir = archive_dir_for(session_id)?; - std::fs::create_dir_all(&dir).with_context(|| { - format!( - "Failed to create cycle archive directory at {}", - dir.display() - ) - })?; - - let path = dir.join(format!("{cycle_n}.jsonl")); - let header = CycleArchiveHeader { - schema_version: CYCLE_ARCHIVE_SCHEMA_VERSION, - cycle: cycle_n, - session_id: session_id.to_string(), - model: model.to_string(), - started, - ended: Utc::now(), - message_count: messages.len(), - }; - - write_archive_file(&path, &header, messages) - .with_context(|| format!("Failed to write cycle archive at {}", path.display()))?; - - Ok(path) -} - -fn write_archive_file( - path: &Path, - header: &CycleArchiveHeader, - messages: &[Message], -) -> Result<()> { - let tmp_path = path.with_extension("jsonl.tmp"); - { - let file = OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&tmp_path)?; - let mut buf = std::io::BufWriter::new(file); - let header_line = serde_json::to_string(header)?; - buf.write_all(header_line.as_bytes())?; - buf.write_all(b"\n")?; - for message in messages { - let line = serde_json::to_string(message)?; - buf.write_all(line.as_bytes())?; - buf.write_all(b"\n")?; - } - // BufWriter flushes on drop, but we want any error surfaced now — - // not silently into the void. - buf.flush()?; - // File handle drops with `buf`. - } - std::fs::rename(&tmp_path, path)?; - Ok(()) -} - -/// Open an archived cycle JSONL for streaming reads. Returns the parsed -/// header and an iterator over messages. Reserved for the future -/// `recall_archive` tool (#127). -#[allow(dead_code)] -pub fn open_archive(path: &Path) -> Result<(CycleArchiveHeader, ArchiveMessageReader)> { - use std::io::{BufRead, BufReader}; - - let file = File::open(path) - .with_context(|| format!("Failed to open cycle archive at {}", path.display()))?; - let mut reader = BufReader::new(file); - let mut header_line = String::new(); - reader.read_line(&mut header_line)?; - let header: CycleArchiveHeader = - serde_json::from_str(header_line.trim()).with_context(|| { - format!( - "Cycle archive at {} is missing a valid header", - path.display() - ) - })?; - - if header.schema_version > CYCLE_ARCHIVE_SCHEMA_VERSION { - anyhow::bail!( - "Cycle archive schema v{} at {} is newer than supported v{}", - header.schema_version, - path.display(), - CYCLE_ARCHIVE_SCHEMA_VERSION - ); - } - - Ok((header, ArchiveMessageReader { reader })) -} - -/// Iterator yielding `Message`s from an opened archive file. Yields `None` -/// when the file is exhausted. Errors propagate through the `Result`. -#[allow(dead_code)] -#[derive(Debug)] -pub struct ArchiveMessageReader { - reader: std::io::BufReader, -} - -#[allow(dead_code)] -impl Iterator for ArchiveMessageReader { - type Item = Result; - - fn next(&mut self) -> Option { - use std::io::BufRead; - - let mut line = String::new(); - match self.reader.read_line(&mut line) { - Ok(0) => None, - Ok(_) => { - let trimmed = line.trim(); - if trimmed.is_empty() { - return self.next(); - } - Some( - serde_json::from_str::(trimmed) - .map_err(|e| anyhow::anyhow!("Archive line parse failed: {e}")), - ) - } - Err(e) => Some(Err(anyhow::Error::new(e))), - } - } -} - -/// Compose the seed messages for the next cycle. -/// -/// Layout (deterministic order): -/// -/// 1. (system prompt is provided separately, not as a `Message`) -/// 2. Optional structured-state user message (todos / plan / working set / -/// sub-agents) — labeled with `[CYCLE STATE]` so the assistant can tell -/// it apart from a real user turn. -/// 3. The model-curated `` briefing — labeled with `[CYCLE -/// BRIEFING]` so the assistant knows it was self-authored on the previous -/// cycle. -/// 4. Optional pending user message that hadn't been sent yet. -/// -/// The original system prompt is composed by the engine and stays separate -/// from this list — the engine sets `session.system_prompt` directly. -#[must_use] -pub fn build_seed_messages( - structured_state_block: Option<&str>, - briefing: Option<&CycleBriefing>, - pending_user_message: Option<&str>, -) -> Vec { - let mut out: Vec = Vec::new(); - - if let Some(state) = structured_state_block - && !state.trim().is_empty() - { - out.push(Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: format!( - "[CYCLE STATE — auto-preserved across the cycle boundary]\n\n{}", - state.trim() - ), - cache_control: None, - }], - }); - // A user message expects an assistant ack so the next real user - // message lands on a clean alternation. We synthesize a one-line ack. - out.push(Message { - role: "assistant".to_string(), - content: vec![ContentBlock::Text { - text: "Acknowledged. State carried into the new cycle.".to_string(), - cache_control: None, - }], - }); - } - - if let Some(brief) = briefing - && !brief.briefing_text.trim().is_empty() - { - out.push(Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: format!( - "[CYCLE BRIEFING — written by you on cycle {} at {}]\n\n\n{}\n", - brief.cycle, - brief.timestamp.to_rfc3339(), - brief.briefing_text.trim() - ), - cache_control: None, - }], - }); - out.push(Message { - role: "assistant".to_string(), - content: vec![ContentBlock::Text { - text: "Briefing absorbed. Continuing.".to_string(), - cache_control: None, - }], - }); - } - - if let Some(pending) = pending_user_message - && !pending.trim().is_empty() - { - out.push(Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: pending.trim().to_string(), - cache_control: None, - }], - }); - } - - out -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::{ContentBlock, Message}; - use std::path::PathBuf; - use tempfile::tempdir; - - fn user_msg(text: &str) -> Message { - Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: text.to_string(), - cache_control: None, - }], - } - } - - fn asst_msg(text: &str) -> Message { - Message { - role: "assistant".to_string(), - content: vec![ContentBlock::Text { - text: text.to_string(), - cache_control: None, - }], - } - } - - #[test] - fn cycle_config_default_includes_v4_overrides() { - let cfg = CycleConfig::default(); - assert!(cfg.enabled); - assert!(cfg.per_model.contains_key("deepseek-v4-pro")); - assert!(cfg.per_model.contains_key("deepseek-v4-flash")); - assert_eq!(cfg.threshold_tokens, DEFAULT_CYCLE_THRESHOLD_TOKENS); - assert_eq!(cfg.briefing_max_tokens, DEFAULT_BRIEFING_MAX_TOKENS); - } - - #[test] - fn threshold_for_falls_back_to_default() { - let cfg = CycleConfig::default(); - assert_eq!( - cfg.threshold_for("deepseek-v4-pro"), - DEFAULT_CYCLE_THRESHOLD_TOKENS - ); - assert_eq!( - cfg.threshold_for("unknown-model"), - DEFAULT_CYCLE_THRESHOLD_TOKENS - ); - } - - #[test] - fn threshold_for_uses_per_model_override() { - let mut cfg = CycleConfig::default(); - cfg.per_model.insert( - "deepseek-v4-pro".to_string(), - ModelCycleConfig { - threshold_tokens: 80_000, - briefing_max_tokens: 2_000, - }, - ); - assert_eq!(cfg.threshold_for("deepseek-v4-pro"), 80_000); - assert_eq!(cfg.briefing_max_for("deepseek-v4-pro"), 2_000); - } - - #[test] - fn should_advance_below_threshold_returns_false() { - let cfg = CycleConfig::default(); - assert!(!should_advance_cycle( - 50_000, - 0, - "deepseek-v4-pro", - &cfg, - false - )); - } - - #[test] - fn should_advance_at_threshold_returns_true() { - let cfg = CycleConfig::default(); - assert!(should_advance_cycle( - DEFAULT_CYCLE_THRESHOLD_TOKENS as u64, - 0, - "deepseek-v4-pro", - &cfg, - false - )); - } - - #[test] - fn should_advance_combines_input_and_output() { - let cfg = CycleConfig::default(); - // 400K + 400K = 800K > 768K threshold - assert!(should_advance_cycle( - 400_000, - 400_000, - "deepseek-v4-pro", - &cfg, - false - )); - } - - #[test] - fn in_flight_phase_guard_blocks_advance() { - let cfg = CycleConfig::default(); - assert!(!should_advance_cycle( - DEFAULT_CYCLE_THRESHOLD_TOKENS as u64 * 2, - 0, - "deepseek-v4-pro", - &cfg, - true, - )); - } - - #[test] - fn disabled_config_blocks_advance() { - let cfg = CycleConfig { - enabled: false, - ..Default::default() - }; - assert!(!should_advance_cycle( - DEFAULT_CYCLE_THRESHOLD_TOKENS as u64 * 2, - 0, - "deepseek-v4-pro", - &cfg, - false, - )); - } - - #[test] - fn extract_carry_forward_pulls_block() { - let raw = "Here is your handoff:\n\nDecision A: chose X because Y.\n\nDone."; - assert_eq!(extract_carry_forward(raw), "Decision A: chose X because Y."); - } - - #[test] - fn extract_carry_forward_handles_missing_close_tag() { - let raw = "\nDecision A: chose X."; - // Missing close tag → returns the tail, trimmed. - assert_eq!(extract_carry_forward(raw), "Decision A: chose X."); - } - - #[test] - fn extract_carry_forward_no_tags_returns_trimmed_body() { - let raw = " Decision A: chose X. "; - assert_eq!(extract_carry_forward(raw), "Decision A: chose X."); - } - - #[test] - fn extract_carry_forward_case_insensitive() { - let raw = "\nState here.\n"; - assert_eq!(extract_carry_forward(raw), "State here."); - } - - #[test] - fn enforce_briefing_cap_truncates_oversized_text() { - let max_tokens = 10; // 10 * 4 = 40 chars - let big = "x".repeat(200); - let bounded = enforce_briefing_cap(&big, max_tokens); - assert!(bounded.starts_with(&"x".repeat(40))); - assert!(bounded.contains("[...briefing truncated")); - } - - #[test] - fn enforce_briefing_cap_passes_short_text_through() { - let txt = "hello world"; - assert_eq!(enforce_briefing_cap(txt, 100), "hello world"); - } - - #[test] - fn build_seed_messages_empty_when_all_inputs_empty() { - let seeds = build_seed_messages(None, None, None); - assert!(seeds.is_empty()); - } - - #[test] - fn build_seed_messages_includes_state_briefing_and_pending() { - let briefing = CycleBriefing { - cycle: 1, - timestamp: Utc::now(), - briefing_text: "Decisions: chose A.".to_string(), - token_estimate: 5, - }; - - let seeds = build_seed_messages( - Some("## Cycle State\n- Mode: agent"), - Some(&briefing), - Some("Continue working on issue #124"), - ); - - // Expected layout: state user + ack assistant + briefing user + ack assistant + pending user. - assert_eq!(seeds.len(), 5); - assert_eq!(seeds[0].role, "user"); - assert_eq!(seeds[1].role, "assistant"); - assert_eq!(seeds[2].role, "user"); - assert_eq!(seeds[3].role, "assistant"); - assert_eq!(seeds[4].role, "user"); - - if let ContentBlock::Text { text, .. } = &seeds[0].content[0] { - assert!(text.contains("[CYCLE STATE")); - assert!(text.contains("agent")); - } else { - panic!("expected text block"); - } - if let ContentBlock::Text { text, .. } = &seeds[2].content[0] { - assert!(text.contains("[CYCLE BRIEFING")); - assert!(text.contains("")); - assert!(text.contains("Decisions: chose A.")); - } else { - panic!("expected text block"); - } - if let ContentBlock::Text { text, .. } = &seeds[4].content[0] { - assert_eq!(text, "Continue working on issue #124"); - } else { - panic!("expected text block"); - } - } - - #[test] - fn build_seed_messages_skips_blank_pending() { - let seeds = build_seed_messages(Some("## State"), None, Some(" ")); - // State block + ack — no pending message. - assert_eq!(seeds.len(), 2); - assert_eq!(seeds[0].role, "user"); - assert_eq!(seeds[1].role, "assistant"); - } - - #[test] - fn structured_state_to_system_block_renders_minimal() { - let state = StructuredState { - mode_label: "agent".to_string(), - workspace: PathBuf::from("/tmp/ws"), - cwd: None, - working_set_summary: None, - todo_snapshot: None, - plan_snapshot: None, - subagent_snapshots: Vec::new(), - }; - let block = state.to_system_block().expect("renders"); - assert!(block.contains("Mode: `agent`")); - assert!(block.contains("Workspace: `/tmp/ws`")); - } - - #[test] - fn archive_cycle_writes_jsonl_with_header_and_messages() { - let dir = tempdir().expect("tempdir"); - let session_id = format!("test-session-{}", uuid::Uuid::new_v4()); - - // Redirect dirs::home_dir() into our tempdir. On Unix that reads - // HOME; on Windows it reads USERPROFILE — set both so the test is - // platform-portable. SAFETY: cargo runs each test binary - // single-threaded by default; we do not await across the env - // mutation window. - let original_home = std::env::var("HOME").ok(); - let original_userprofile = std::env::var("USERPROFILE").ok(); - unsafe { - std::env::set_var("HOME", dir.path()); - std::env::set_var("USERPROFILE", dir.path()); - } - - let messages = vec![ - user_msg("hello"), - asst_msg("hi"), - user_msg("can you read Cargo.toml?"), - ]; - - let started = Utc::now(); - let path = archive_cycle(&session_id, 1, &messages, "deepseek-v4-pro", started) - .expect("archive_cycle should succeed"); - - assert!(path.exists(), "archive file should exist on disk"); - assert_eq!(path.file_name().and_then(|s| s.to_str()), Some("1.jsonl")); - - let contents = std::fs::read_to_string(&path).expect("read archive back"); - let mut lines = contents.lines(); - - let header_line = lines.next().expect("header line present"); - let header: CycleArchiveHeader = serde_json::from_str(header_line).expect("header parses"); - assert_eq!(header.cycle, 1); - assert_eq!(header.session_id, session_id); - assert_eq!(header.model, "deepseek-v4-pro"); - assert_eq!(header.message_count, 3); - assert_eq!(header.schema_version, CYCLE_ARCHIVE_SCHEMA_VERSION); - - for expected in &messages { - let line = lines.next().expect("message line present"); - let parsed: Message = serde_json::from_str(line).expect("message parses"); - assert_eq!(&parsed, expected); - } - assert!(lines.next().is_none(), "no extra trailing lines"); - - // Restore env so subsequent tests aren't surprised. - unsafe { - match original_home { - Some(value) => std::env::set_var("HOME", value), - None => std::env::remove_var("HOME"), - } - match original_userprofile { - Some(value) => std::env::set_var("USERPROFILE", value), - None => std::env::remove_var("USERPROFILE"), - } - } - } - - #[test] - fn open_archive_rejects_newer_schema_version() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("999.jsonl"); - let header = CycleArchiveHeader { - schema_version: CYCLE_ARCHIVE_SCHEMA_VERSION + 5, - cycle: 999, - session_id: "future-session".to_string(), - model: "deepseek-v9".to_string(), - started: Utc::now(), - ended: Utc::now(), - message_count: 0, - }; - let mut payload = serde_json::to_string(&header).unwrap(); - payload.push('\n'); - std::fs::write(&path, payload).unwrap(); - - let err = open_archive(&path).expect_err("must reject newer schema version"); - let msg = format!("{err:#}"); - assert!(msg.contains("newer than supported"), "got: {msg}"); - } - - /// Mock `produce_briefing`-style flow purely client-side: we feed a known - /// raw string through `extract_carry_forward` + `enforce_briefing_cap` - /// and assert the same result we'd produce after a real LLM call. - /// Avoids spinning up a live mock server while still proving the - /// extraction contract. - #[test] - fn briefing_extraction_pipeline_preserves_block() { - let raw = "thinking: ok\n\nDecision: pick lib A; constraint: no async.\n\n"; - let extracted = extract_carry_forward(raw); - let bounded = enforce_briefing_cap(&extracted, 50); - assert_eq!(bounded, "Decision: pick lib A; constraint: no async."); - } -} diff --git a/crates/tui/src/models.rs b/crates/tui/src/models.rs index 93f7c920..32f357f3 100644 --- a/crates/tui/src/models.rs +++ b/crates/tui/src/models.rs @@ -157,7 +157,7 @@ pub struct ContainerInfo { } /// Server-side tool usage counters. -#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] pub struct ServerToolUsage { #[serde(skip_serializing_if = "Option::is_none")] pub code_execution_requests: Option, @@ -181,7 +181,7 @@ pub struct MessageResponse { } /// Token usage metadata for a response. -#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] pub struct Usage { pub input_tokens: u32, pub output_tokens: u32, diff --git a/crates/tui/src/seam_manager.rs b/crates/tui/src/seam_manager.rs index 60fa5c47..08273140 100644 --- a/crates/tui/src/seam_manager.rs +++ b/crates/tui/src/seam_manager.rs @@ -36,8 +36,8 @@ use chrono::{DateTime, Utc}; use tokio::sync::Mutex; use crate::client::DeepSeekClient; -use crate::compaction::plan_compaction; use crate::compaction::KEEP_RECENT_MESSAGES; +use crate::compaction::plan_compaction; use crate::llm_client::LlmClient; use crate::models::{ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt}; diff --git a/crates/tui/src/tools/subagent/mailbox.rs b/crates/tui/src/tools/subagent/mailbox.rs index ae45fcae..6cab579b 100644 --- a/crates/tui/src/tools/subagent/mailbox.rs +++ b/crates/tui/src/tools/subagent/mailbox.rs @@ -19,6 +19,8 @@ use serde::{Deserialize, Serialize}; use tokio::sync::{Mutex, mpsc, watch}; use tokio_util::sync::CancellationToken; +use crate::models::Usage; + use super::SubAgentType; /// Stable, structured progress envelope shared across the sub-agent surface. @@ -62,10 +64,10 @@ pub enum MailboxMessage { /// Published after each turn so the parent's cost counter updates live. TokenUsage { agent_id: String, - /// Prompt tokens consumed (input, including cached). - prompt_tokens: u32, - /// Completion tokens consumed (output). - completion_tokens: u32, + /// Model that produced this usage, used for pricing. + model: String, + /// Provider usage payload, including cache-hit/cache-miss fields. + usage: Usage, }, } @@ -103,13 +105,13 @@ impl MailboxMessage { pub(crate) fn token_usage( agent_id: impl Into, - prompt_tokens: u32, - completion_tokens: u32, + model: impl Into, + usage: Usage, ) -> Self { Self::TokenUsage { agent_id: agent_id.into(), - prompt_tokens, - completion_tokens, + model: model.into(), + usage, } } } @@ -459,8 +461,12 @@ mod tests { ( MailboxMessage::TokenUsage { agent_id: "a9".into(), - prompt_tokens: 100, - completion_tokens: 50, + model: "deepseek-v4-flash".into(), + usage: Usage { + input_tokens: 100, + output_tokens: 50, + ..Default::default() + }, }, "a9", ), diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index b48f9bc2..df311fda 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -2730,8 +2730,8 @@ async fn run_subagent( if let Some(mb) = runtime.mailbox.as_ref() { let _ = mb.send(MailboxMessage::token_usage( &agent_id, - response.usage.input_tokens, - response.usage.output_tokens, + response.model.clone(), + response.usage.clone(), )); } diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index f2457033..7f2d6973 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -207,9 +207,7 @@ impl HistoryCell { } => render_thinking(content, width, *streaming, *duration_secs, false, false), HistoryCell::Tool(cell) => cell.lines_with_motion(width, false), HistoryCell::SubAgent(cell) => cell.lines(width), - HistoryCell::ArchivedContext { .. } => { - render_archived_context(self, width, false) - } + HistoryCell::ArchivedContext { .. } => render_archived_context(self, width, false), } } @@ -318,9 +316,7 @@ impl HistoryCell { ), HistoryCell::Tool(cell) => cell.transcript_lines(width), HistoryCell::SubAgent(cell) => cell.lines(width), - HistoryCell::ArchivedContext { .. } => { - render_archived_context(self, width, true) - } + HistoryCell::ArchivedContext { .. } => render_archived_context(self, width, true), } } @@ -359,47 +355,19 @@ fn parse_archived_context(text: &str) -> Option { let tag_end = text.find('>')?; let tag = &text[..tag_end]; - let level = tag - .split(' ') - .find(|part| part.starts_with("level=")) - .and_then(|part| part.split('"').nth(1)) + let level = archived_context_attr(tag, "level") .and_then(|v| v.parse::().ok()) .unwrap_or(0); - let range = tag - .split(' ') - .find(|part| part.starts_with("range=")) - .and_then(|part| part.split('"').nth(1)) - .unwrap_or("") - .to_string(); + let range = archived_context_attr(tag, "range").unwrap_or_default(); - let tokens = tag - .split(' ') - .find(|part| part.starts_with("tokens=")) - .and_then(|part| part.split('"').nth(1)) - .unwrap_or("") - .to_string(); + let tokens = archived_context_attr(tag, "tokens").unwrap_or_default(); - let density = tag - .split(' ') - .find(|part| part.starts_with("density=")) - .and_then(|part| part.split('"').nth(1)) - .unwrap_or("") - .to_string(); + let density = archived_context_attr(tag, "density").unwrap_or_default(); - let model = tag - .split(' ') - .find(|part| part.starts_with("model=")) - .and_then(|part| part.split('"').nth(1)) - .unwrap_or("") - .to_string(); + let model = archived_context_attr(tag, "model").unwrap_or_default(); - let timestamp = tag - .split(' ') - .find(|part| part.starts_with("timestamp=")) - .and_then(|part| part.split('"').nth(1)) - .unwrap_or("") - .to_string(); + let timestamp = archived_context_attr(tag, "timestamp").unwrap_or_default(); let close_tag = text.rfind("")?; let summary_start = tag_end + 1; @@ -416,8 +384,20 @@ fn parse_archived_context(text: &str) -> Option { }) } +fn archived_context_attr(tag: &str, name: &str) -> Option { + let needle = format!("{name}=\""); + let start = tag.find(&needle)? + needle.len(); + let rest = &tag[start..]; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + /// Render an `` block with dimmed/italic styling. -fn render_archived_context(cell: &HistoryCell, width: u16, _low_motion: bool) -> Vec> { +fn render_archived_context( + cell: &HistoryCell, + width: u16, + _low_motion: bool, +) -> Vec> { let HistoryCell::ArchivedContext { level, range, @@ -441,9 +421,7 @@ fn render_archived_context(cell: &HistoryCell, width: u16, _low_motion: bool) -> let label_style = Style::default() .fg(palette::TEXT_DIM) .add_modifier(Modifier::BOLD); - let body_style = Style::default() - .fg(palette::TEXT_DIM) - .italic(); + let body_style = Style::default().fg(palette::TEXT_DIM).italic(); let content_width = width.saturating_sub(4).max(1); @@ -493,10 +471,7 @@ fn render_archived_context(cell: &HistoryCell, width: u16, _low_motion: bool) -> let rendered = crate::tui::markdown_render::render_markdown(&body, content_width, body_style); for (idx, line) in rendered.into_iter().enumerate() { if idx == 0 { - let mut spans = vec![Span::styled( - "▏ ", - Style::default().fg(palette::TEXT_DIM), - )]; + let mut spans = vec![Span::styled("▏ ", Style::default().fg(palette::TEXT_DIM))]; spans.extend(line.spans); lines.push(Line::from(spans)); } else { @@ -527,46 +502,46 @@ pub fn history_cells_from_message(msg: &Message) -> Vec { continue; } match msg.role.as_str() { - "user" => { - if let Some(HistoryCell::User { content }) = cells.last_mut() { - if !content.is_empty() { - content.push('\n'); + "user" => { + if let Some(HistoryCell::User { content }) = cells.last_mut() { + if !content.is_empty() { + content.push('\n'); + } + content.push_str(text); + } else { + cells.push(HistoryCell::User { + content: text.clone(), + }); } - content.push_str(text); - } else { - cells.push(HistoryCell::User { - content: text.clone(), - }); } - } - "assistant" => { - if let Some(HistoryCell::Assistant { content, .. }) = cells.last_mut() { - if !content.is_empty() { - content.push('\n'); + "assistant" => { + if let Some(HistoryCell::Assistant { content, .. }) = cells.last_mut() { + if !content.is_empty() { + content.push('\n'); + } + content.push_str(text); + } else { + cells.push(HistoryCell::Assistant { + content: text.clone(), + streaming: false, + }); } - content.push_str(text); - } else { - cells.push(HistoryCell::Assistant { - content: text.clone(), - streaming: false, - }); } - } - "system" => { - if let Some(HistoryCell::System { content }) = cells.last_mut() { - if !content.is_empty() { - content.push('\n'); + "system" => { + if let Some(HistoryCell::System { content }) = cells.last_mut() { + if !content.is_empty() { + content.push('\n'); + } + content.push_str(text); + } else { + cells.push(HistoryCell::System { + content: text.clone(), + }); } - content.push_str(text); - } else { - cells.push(HistoryCell::System { - content: text.clone(), - }); } + _ => {} } - _ => {} } - }, ContentBlock::Thinking { thinking } => { if let Some(HistoryCell::Thinking { content, .. }) = cells.last_mut() { if !content.is_empty() { @@ -2396,6 +2371,7 @@ mod tests { running_status_label_with_elapsed, }; use crate::deepseek_theme::Theme; + use crate::models::{ContentBlock, Message}; use crate::palette; use ratatui::style::Modifier; use std::time::{Duration, Instant}; @@ -2433,6 +2409,40 @@ mod tests { assert_eq!(summary, "Line one\nLine two"); } + #[test] + fn archived_context_metadata_preserves_spaces_in_attributes() { + let msg = Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "\nSummary body\n".to_string(), + cache_control: None, + }], + }; + + let cells = super::history_cells_from_message(&msg); + assert_eq!(cells.len(), 1); + let HistoryCell::ArchivedContext { + level, + range, + tokens, + density, + model, + timestamp, + summary, + } = &cells[0] + else { + panic!("expected archived context cell"); + }; + + assert_eq!(*level, 1); + assert_eq!(range, "msg 0-128"); + assert_eq!(tokens, "2499"); + assert_eq!(density, "~2,500 tokens"); + assert_eq!(model, "deepseek-v4-flash"); + assert_eq!(timestamp, "2026-04-28T00:00:00Z"); + assert_eq!(summary, "Summary body"); + } + #[test] fn render_thinking_collapsed_shows_details_affordance() { let lines = render_thinking( diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 95284547..1157b5d6 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -351,7 +351,14 @@ mod tests { .collect(); assert_eq!( names, - vec!["DeepSeek", "NVIDIA NIM", "OpenRouter", "Novita AI", "Fireworks AI", "SGLang"] + vec![ + "DeepSeek", + "NVIDIA NIM", + "OpenRouter", + "Novita AI", + "Fireworks AI", + "SGLang" + ] ); } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index e475dc19..36283eb9 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5502,15 +5502,8 @@ 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 { - prompt_tokens, - completion_tokens, - .. - } = message - { - if let Some(cost) = - crate::pricing::calculate_turn_cost(&app.model, *prompt_tokens, *completion_tokens) - { + 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. diff --git a/crates/tui/src/tui/ui.rs.bak3 b/crates/tui/src/tui/ui.rs.bak3 deleted file mode 100644 index e308d7ab..00000000 --- a/crates/tui/src/tui/ui.rs.bak3 +++ /dev/null @@ -1,6635 +0,0 @@ -//! TUI event loop and rendering logic for `DeepSeek` CLI. - -use std::collections::HashSet; -use std::io::{self, Stdout}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{Duration, Instant}; - -use anyhow::Result; -use crossterm::{ - event::{ - self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, - Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, - MouseEventKind, - }, - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::{ - Frame, Terminal, - backend::CrosstermBackend, - layout::{Constraint, Direction, Layout, Rect}, - style::Style, - text::Span, - widgets::Block, -}; -use tracing; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - -use crate::audit::log_sensitive_event; -use crate::client::DeepSeekClient; -use crate::commands; -use crate::compaction::estimate_input_tokens_conservative; -use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL}; -use crate::core::coherence::CoherenceState; -use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; -use crate::core::events::Event as EngineEvent; -use crate::core::ops::Op; -use crate::hooks::HookEvent; -use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model}; -use crate::palette; -use crate::prompts; -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::tui::command_palette::{ - CommandPaletteView, build_entries as build_command_palette_entries, -}; -use crate::tui::context_inspector::build_context_inspector_text; -use crate::tui::event_broker::EventBroker; -use crate::tui::live_transcript::LiveTranscriptOverlay; -use crate::tui::onboarding; -use crate::tui::pager::PagerView; -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::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, -}; -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, -}; -use super::slash_menu::{ - apply_slash_menu_selection, try_autocomplete_slash_command, visible_slash_menu_entries, -}; -use super::views::{ConfigView, HelpView, ModalKind, ViewEvent}; -use super::widgets::pending_input_preview::{ContextPreviewItem, PendingInputPreview}; -use super::widgets::{ - ChatWidget, ComposerWidget, FooterProps, FooterToast, FooterWidget, HeaderData, HeaderWidget, - Renderable, -}; - -// === Constants === - -/// Upper bound on slash-menu entries returned to the renderer. The composer's -/// render path already paginates with center-tracking (see -/// `widgets::ComposerWidget::render`), so this only needs to be high enough to -/// encompass the full filtered command list — never the visible-row budget. -/// Bumped from 6 to 128 to fix #64 (selection couldn't reach commands beyond -/// the visible window because the source list itself was capped). -const SLASH_MENU_LIMIT: usize = 128; -const MENTION_MENU_LIMIT: usize = 6; -const MIN_CHAT_HEIGHT: u16 = 3; -const MIN_COMPOSER_HEIGHT: u16 = 2; -const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0; -const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0; -const UI_IDLE_POLL_MS: u64 = 48; -const UI_ACTIVE_POLL_MS: u64 = 24; -// Forced repaint cadence while a turn is live (model loading, compacting, -// sub-agents running). Drives the footer water-spout animation as well as -// the per-tool spinner pulse — keep this fast enough that the spout reads as -// motion (~12 fps) instead of teleport-frames. -const UI_STATUS_ANIMATION_MS: u64 = 80; -const WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15; -const SIDEBAR_VISIBLE_MIN_WIDTH: u16 = 100; - -/// Run the interactive TUI event loop. -/// -/// # Examples -/// -/// ```ignore -/// # use crate::config::Config; -/// # use crate::tui::TuiOptions; -/// # async fn example(config: &Config, options: TuiOptions) -> anyhow::Result<()> { -/// crate::tui::run_tui(config, options).await -/// # } -/// ``` -pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { - let use_alt_screen = options.use_alt_screen; - let use_mouse_capture = options.use_mouse_capture; - let use_bracketed_paste = options.use_bracketed_paste; - enable_raw_mode()?; - let mut stdout = io::stdout(); - if use_alt_screen { - execute!(stdout, EnterAlternateScreen)?; - } - if use_mouse_capture { - execute!(stdout, EnableMouseCapture)?; - } - if use_bracketed_paste { - execute!(stdout, EnableBracketedPaste)?; - } - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - let event_broker = EventBroker::new(); - - // Local mutable copy so runtime config flips (e.g. `/provider` switch) - // can rebuild the API client without restarting the process. - let mut config = config.clone(); - let config = &mut config; - let mut app = App::new(options.clone(), config); - - // Load existing session if resuming. - if let Some(ref session_id) = options.resume_session_id - && let Ok(manager) = SessionManager::default_location() - { - // Try to load by prefix or full ID - let load_result: std::io::Result> = - if session_id == "latest" { - // Special case: resume the most recent session - match manager.get_latest_session() { - Ok(Some(meta)) => manager.load_session(&meta.id).map(Some), - Ok(None) => Ok(None), - Err(e) => Err(e), - } - } else { - manager.load_session_by_prefix(session_id).map(Some) - }; - - match load_result { - Ok(Some(saved)) => { - app.api_messages.clone_from(&saved.messages); - app.model.clone_from(&saved.metadata.model); - app.update_model_compaction_budget(); - app.workspace.clone_from(&saved.metadata.workspace); - app.current_session_id = Some(saved.metadata.id.clone()); - app.total_tokens = u32::try_from(saved.metadata.total_tokens).unwrap_or(u32::MAX); - app.total_conversation_tokens = app.total_tokens; - app.last_prompt_tokens = None; - app.last_completion_tokens = None; - app.last_prompt_cache_hit_tokens = None; - app.last_prompt_cache_miss_tokens = None; - app.last_reasoning_replay_tokens = None; - if let Some(prompt) = saved.system_prompt { - app.system_prompt = Some(SystemPrompt::Text(prompt)); - } - // Convert saved messages to HistoryCell format for display - app.clear_history(); - app.push_history_cell(HistoryCell::System { - content: format!( - "Resumed session: {} ({})", - saved.metadata.title, - &saved.metadata.id[..8.min(saved.metadata.id.len())] - ), - }); - - for msg in &saved.messages { - app.extend_history(history_cells_from_message(msg)); - } - app.mark_history_updated(); - app.status_message = Some(format!( - "Resumed session: {}", - &saved.metadata.id[..8.min(saved.metadata.id.len())] - )); - } - Ok(None) => { - app.status_message = Some("No sessions found to resume".to_string()); - } - Err(e) => { - app.status_message = Some(format!("Failed to load session: {e}")); - } - } - } - - if let Ok(manager) = SessionManager::default_location() { - match manager.load_offline_queue_state() { - Ok(Some(state)) => { - app.queued_messages = state - .messages - .into_iter() - .map(queued_session_to_ui) - .collect(); - app.queued_draft = state.draft.map(queued_session_to_ui); - if app.status_message.is_none() && app.queued_message_count() > 0 { - app.status_message = Some(format!( - "Recovered {} queued message(s)", - app.queued_message_count() - )); - } - } - Ok(None) => {} - Err(err) => { - if app.status_message.is_none() { - app.status_message = Some(format!("Failed to restore offline queue: {err}")); - } - } - } - } - - let engine_config = build_engine_config(&app, config); - - // Spawn the Engine - it will handle all API communication - let engine_handle = spawn_engine(engine_config, config); - - if !app.api_messages.is_empty() { - let _ = engine_handle - .send(Op::SyncSession { - messages: app.api_messages.clone(), - system_prompt: app.system_prompt.clone(), - model: app.model.clone(), - workspace: app.workspace.clone(), - }) - .await; - } - - // Fire session start hook - { - let context = app.base_hook_context(); - let _ = app.execute_hooks(HookEvent::SessionStart, &context); - } - - let task_manager = TaskManager::start( - TaskManagerConfig::from_runtime( - config, - app.workspace.clone(), - Some(app.model.clone()), - Some(app.max_subagents.clamp(1, 4)), - ), - config.clone(), - ) - .await?; - app.task_panel = task_manager - .list_tasks(Some(10)) - .await - .into_iter() - .map(task_summary_to_panel_entry) - .collect(); - - let result = run_event_loop( - &mut terminal, - &mut app, - config, - engine_handle, - task_manager, - &event_broker, - ) - .await; - - // Fire session end hook - { - let context = app.base_hook_context(); - let _ = app.execute_hooks(HookEvent::SessionEnd, &context); - } - - // Clear crash-recovery checkpoint on normal exit so the next launch starts fresh. - clear_checkpoint(); - - disable_raw_mode()?; - if use_alt_screen { - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - } - if use_mouse_capture { - execute!(terminal.backend_mut(), DisableMouseCapture)?; - } - if use_bracketed_paste { - execute!(terminal.backend_mut(), DisableBracketedPaste)?; - } - terminal.show_cursor()?; - - result -} - -fn build_engine_config(app: &App, config: &Config) -> EngineConfig { - EngineConfig { - model: app.model.clone(), - workspace: app.workspace.clone(), - allow_shell: app.allow_shell, - trust_mode: app.trust_mode, - notes_path: config.notes_path(), - mcp_config_path: config.mcp_config_path(), - // Effectively unlimited. V4 has a 1M context window and the user - // wants the model running until it's actually done. The previous cap - // of 100 hit the ceiling on long multi-step plans (wide refactors, - // sub-agent orchestration) and presented as the agent "giving up - // mid-task". `u32::MAX` is the type ceiling; users can still - // interrupt with Ctrl+C / Esc, and a turn naturally ends when the - // model stops emitting tool calls. A real runaway is rare and - // human-noticeable; we trust the operator over a hard step cap. - max_steps: u32::MAX, - max_subagents: app.max_subagents, - features: config.features(), - compaction: app.compaction_config(), - cycle: app.cycle_config(), - capacity: crate::core::capacity::CapacityControllerConfig::from_app_config(config), - todos: app.todos.clone(), - plan_state: app.plan_state.clone(), - max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, - network_policy: config.network.clone().map(|toml_cfg| { - crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) - }), - snapshots_enabled: config.snapshots_config().enabled, - lsp_config: config - .lsp - .clone() - .map(crate::config::LspConfigToml::into_runtime), - } -} - -#[allow(clippy::too_many_lines)] -async fn run_event_loop( - terminal: &mut Terminal>, - app: &mut App, - config: &mut Config, - mut engine_handle: EngineHandle, - task_manager: SharedTaskManager, - event_broker: &EventBroker, -) -> Result<()> { - // Track streaming state - let mut current_streaming_text = String::new(); - let mut last_queue_state = (app.queued_messages.clone(), app.queued_draft.clone()); - let mut last_task_refresh = Instant::now() - .checked_sub(Duration::from_secs(2)) - .unwrap_or_else(Instant::now); - let mut last_status_frame = Instant::now() - .checked_sub(Duration::from_millis(UI_STATUS_ANIMATION_MS)) - .unwrap_or_else(Instant::now); - // 120 FPS draw cap. Without this we redraw on every SSE chunk during a - // long stream — wasted work the user can't perceive. See - // `tui::frame_rate_limiter` for the rationale; ports the small piece of - // codex's frame coalescing that maps cleanly onto our poll-based loop. - let mut frame_rate_limiter = crate::tui::frame_rate_limiter::FrameRateLimiter::default(); - - loop { - if last_task_refresh.elapsed() >= Duration::from_millis(2500) { - let tasks = task_manager.list_tasks(Some(10)).await; - app.task_panel = tasks.into_iter().map(task_summary_to_panel_entry).collect(); - last_task_refresh = Instant::now(); - app.needs_redraw = true; - } - - // First, poll for engine events (non-blocking) - let mut received_engine_event = false; - let mut transcript_batch_updated = false; - let mut queued_to_send: Option = None; - { - let mut rx = engine_handle.rx_event.write().await; - while let Ok(event) = rx.try_recv() { - received_engine_event = true; - match event { - EngineEvent::MessageStarted { .. } => { - // Assistant text starting after parallel tool work - // means the tool group is done. Flush the active - // cell first so the message lands BELOW the - // committed tool group (Codex pattern: streamed - // assistant content always flows after work). - app.flush_active_cell(); - current_streaming_text.clear(); - app.streaming_state.reset(); - app.streaming_state.start_text(0, None); - app.streaming_message_index = None; - } - EngineEvent::MessageDelta { content, .. } => { - let sanitized = sanitize_stream_chunk(&content); - if sanitized.is_empty() { - continue; - } - // First delta of a fresh stream has no streaming - // cell yet; flush active so the tool group settles - // before the assistant prose appears below it. - if app.streaming_message_index.is_none() { - app.flush_active_cell(); - } - current_streaming_text.push_str(&sanitized); - let index = ensure_streaming_assistant_history_cell(app); - app.streaming_state.push_content(0, &sanitized); - let committed = app.streaming_state.commit_text(0); - if !committed.is_empty() { - append_streaming_text(app, index, &committed); - transcript_batch_updated = true; - } - } - EngineEvent::MessageComplete { .. } => { - if let Some(index) = app.streaming_message_index.take() { - let remaining = app.streaming_state.finalize_block_text(0); - if !remaining.is_empty() { - append_streaming_text(app, index, &remaining); - } - if let Some(HistoryCell::Assistant { streaming, .. }) = - app.history.get_mut(index) - { - *streaming = false; - } - // Streaming flag flipped — the cell's compact / - // transcript variants render slightly - // differently, so bump its revision so the cache - // refreshes this row only. - app.bump_history_cell(index); - transcript_batch_updated = true; - } - - let mut blocks = Vec::new(); - let thinking = app.last_reasoning.take(); - if let Some(thinking) = thinking { - blocks.push(ContentBlock::Thinking { thinking }); - } - if !current_streaming_text.is_empty() { - blocks.push(ContentBlock::Text { - text: current_streaming_text.clone(), - cache_control: None, - }); - } - for (id, name, input) in app.pending_tool_uses.drain(..) { - blocks.push(ContentBlock::ToolUse { - id, - name, - input, - caller: None, - }); - } - - // DeepSeek rejects assistant messages that contain only reasoning blocks. - // Keep reasoning in transcript cells, but only persist assistant turns that - // include visible text and/or tool calls. - let has_sendable_content = blocks.iter().any(|block| { - matches!( - block, - ContentBlock::Text { .. } | ContentBlock::ToolUse { .. } - ) - }); - if has_sendable_content { - app.api_messages.push(Message { - role: "assistant".to_string(), - content: blocks, - }); - } - } - EngineEvent::ThinkingStarted { .. } => { - // P2.3: thinking lives in the active cell so it groups - // visually with the tool calls that follow until the - // next assistant prose chunk flushes the group. - app.reasoning_buffer.clear(); - app.reasoning_header = None; - app.thinking_started_at = Some(Instant::now()); - app.streaming_state.reset(); - app.streaming_state.start_thinking(0, None); - let _ = ensure_streaming_thinking_active_entry(app); - } - EngineEvent::ThinkingDelta { content, .. } => { - let sanitized = sanitize_stream_chunk(&content); - if sanitized.is_empty() { - continue; - } - app.reasoning_buffer.push_str(&sanitized); - if app.reasoning_header.is_none() { - app.reasoning_header = extract_reasoning_header(&app.reasoning_buffer); - } - - let entry_idx = ensure_streaming_thinking_active_entry(app); - app.streaming_state.push_content(0, &sanitized); - let committed = app.streaming_state.commit_text(0); - if !committed.is_empty() { - append_streaming_thinking(app, entry_idx, &committed); - transcript_batch_updated = true; - } - } - EngineEvent::ThinkingComplete { .. } => { - let duration = app - .thinking_started_at - .take() - .map(|t| t.elapsed().as_secs_f32()); - let remaining = app.streaming_state.finalize_block_text(0); - if finalize_streaming_thinking_active_entry(app, duration, &remaining) { - transcript_batch_updated = true; - } - - if !app.reasoning_buffer.is_empty() { - app.last_reasoning = Some(app.reasoning_buffer.clone()); - } - app.reasoning_buffer.clear(); - } - EngineEvent::ToolCallStarted { id, name, input } => { - app.pending_tool_uses - .push((id.clone(), name.clone(), input.clone())); - // Note this dispatch so the next sub-agent `Started` - // mailbox envelope routes into the right card kind - // (delegate vs fanout). - if matches!( - name.as_str(), - "agent_spawn" - | "agent_swarm" - | "spawn_agents_on_csv" - | "rlm" - | "delegate" - ) { - app.pending_subagent_dispatch = Some(name.clone()); - if matches!( - name.as_str(), - "agent_swarm" | "spawn_agents_on_csv" | "rlm" - ) { - // New fanout invocation — children should - // group under a fresh card, not the - // previous swarm's leftover. - app.last_fanout_card_index = None; - } - } - handle_tool_call_started(app, &id, &name, &input); - } - EngineEvent::ToolCallComplete { id, name, result } => { - if name == "update_plan" { - app.plan_tool_used_in_turn = true; - } - let tool_content = match &result { - Ok(output) => sanitize_stream_chunk( - &crate::core::engine::compact_tool_result_for_context( - &app.model, &name, output, - ), - ), - Err(err) => sanitize_stream_chunk(&format!("Error: {err}")), - }; - app.api_messages.push(Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: id.clone(), - content: tool_content, - is_error: None, - content_blocks: None, - }], - }); - handle_tool_call_complete(app, &id, &name, &result); - - // Immediately refresh the task panel sidebar when a - // tool that changes task state completes, so the - // Tasks panel stays in sync with tool execution - // rather than waiting up to 2.5 s for the periodic - // poll. - if matches!( - name.as_str(), - "agent_spawn" | "agent_swarm" | "agent_cancel" | "todo_write" - ) { - let tasks = task_manager.list_tasks(Some(10)).await; - app.task_panel = - tasks.into_iter().map(task_summary_to_panel_entry).collect(); - last_task_refresh = Instant::now(); - } - } - EngineEvent::TurnStarted { turn_id } => { - app.is_loading = true; - app.offline_mode = false; - current_streaming_text.clear(); - app.streaming_state.reset(); - app.streaming_message_index = None; - app.streaming_thinking_active_entry = None; - app.turn_started_at = Some(Instant::now()); - app.runtime_turn_id = Some(turn_id); - app.runtime_turn_status = Some("in_progress".to_string()); - app.reasoning_buffer.clear(); - app.reasoning_header = None; - app.last_reasoning = None; - app.pending_tool_uses.clear(); - app.plan_tool_used_in_turn = false; - persist_checkpoint(app); - last_status_frame = Instant::now(); - } - EngineEvent::TurnComplete { - usage, - status, - error, - } => { - // Finalize any in-flight tool group. Cancellation - // marks still-running entries as Failed so the user - // sees they were interrupted rather than the spinner - // hanging forever. - if matches!( - status, - crate::core::events::TurnOutcomeStatus::Interrupted - | crate::core::events::TurnOutcomeStatus::Failed - ) { - app.finalize_active_cell_as_interrupted(); - // Also mark the streaming Assistant cell (if any) - // so partial reasoning/text isn't left with a - // permanent spinner. Idempotent with the - // optimistic call in the Esc handler. - app.finalize_streaming_assistant_as_interrupted(); - } else { - app.flush_active_cell(); - } - app.is_loading = false; - app.offline_mode = false; - app.streaming_state.reset(); - // Capture elapsed before clearing turn_started_at so - // notifications can use the real wall-clock duration. - let turn_elapsed = - app.turn_started_at.map(|t| t.elapsed()).unwrap_or_default(); - app.turn_started_at = None; - // Stream lock applies per-turn; clear it so the next - // turn's chunks pull the view down again until the - // user opts out by scrolling up. - app.user_scrolled_during_stream = false; - app.runtime_turn_status = Some(match status { - crate::core::events::TurnOutcomeStatus::Completed => { - "completed".to_string() - } - crate::core::events::TurnOutcomeStatus::Interrupted => { - "interrupted".to_string() - } - crate::core::events::TurnOutcomeStatus::Failed => "failed".to_string(), - }); - let turn_tokens = usage.input_tokens + usage.output_tokens; - app.total_tokens = app.total_tokens.saturating_add(turn_tokens); - app.total_conversation_tokens = - app.total_conversation_tokens.saturating_add(turn_tokens); - app.last_prompt_tokens = Some(usage.input_tokens); - app.last_completion_tokens = Some(usage.output_tokens); - app.last_prompt_cache_hit_tokens = usage.prompt_cache_hit_tokens; - app.last_prompt_cache_miss_tokens = usage.prompt_cache_miss_tokens; - app.last_reasoning_replay_tokens = usage.reasoning_replay_tokens; - if let Some(error) = error { - app.status_message = Some(format!("Turn failed: {error}")); - } - - // Update session cost - let turn_cost = - crate::pricing::calculate_turn_cost_from_usage(&app.model, &usage); - if let Some(cost) = turn_cost { - app.session_cost += cost; - } - - // Emit OSC 9 / BEL desktop notification for long turns. - if status == crate::core::events::TurnOutcomeStatus::Completed { - let notif = config.notifications_config(); - let method = - crate::tui::notifications::Method::from_str(match ¬if.method { - crate::config::NotificationMethod::Auto => "auto", - crate::config::NotificationMethod::Osc9 => "osc9", - crate::config::NotificationMethod::Bel => "bel", - crate::config::NotificationMethod::Off => "off", - }); - let threshold = std::time::Duration::from_secs(notif.threshold_secs); - let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty()); - let msg = if notif.include_summary { - let human = - crate::tui::notifications::humanize_duration(turn_elapsed); - match turn_cost { - Some(c) => { - format!("deepseek: turn complete ({human}, ${c:.2})") - } - None => format!("deepseek: turn complete ({human})"), - } - } else { - "deepseek: turn complete".to_string() - }; - crate::tui::notifications::notify_done( - method, - in_tmux, - &msg, - threshold, - turn_elapsed, - ); - } - - // Auto-save completed turn and clear crash checkpoint. - persist_session_snapshot(app); - clear_checkpoint(); - - if app.mode == AppMode::Plan - && app.plan_tool_used_in_turn - && !app.plan_prompt_pending - && app.queued_message_count() == 0 - && app.queued_draft.is_none() - { - app.plan_prompt_pending = true; - app.add_message(HistoryCell::System { - content: plan_next_step_prompt(), - }); - if app.view_stack.top_kind() != Some(ModalKind::PlanPrompt) { - app.view_stack.push(PlanPromptView::new()); - } - } - app.plan_tool_used_in_turn = false; - - // Esc-to-steer (#122): the user interrupted with input - // pending. Merge every steered message into one fresh - // turn so the model sees a single coherent prompt. - if status == crate::core::events::TurnOutcomeStatus::Interrupted - && app.submit_pending_steers_after_interrupt - { - if let Some(merged) = merge_pending_steers(&mut *app) { - queued_to_send = Some(merged); - } - } else if status == crate::core::events::TurnOutcomeStatus::Failed - && !app.pending_steers.is_empty() - { - // Hard-fail recovery: if the engine failed before - // a clean Interrupted landed, demote pending - // steers to the visible queue so they're not - // silently lost. User can /queue to inspect. - for msg in app.drain_pending_steers() { - app.queue_message(msg); - } - } - - if queued_to_send.is_none() { - queued_to_send = app.pop_queued_message(); - } - } - EngineEvent::Error { - envelope, - recoverable: _, - } => { - apply_engine_error_to_app(app, envelope); - } - EngineEvent::Status { message } => { - app.status_message = Some(message); - } - EngineEvent::SessionUpdated { - messages, - system_prompt, - model, - workspace, - } => { - app.api_messages = messages; - app.system_prompt = system_prompt; - app.model = model; - app.update_model_compaction_budget(); - app.workspace = workspace; - if app.is_loading || app.is_compacting { - persist_checkpoint(app); - } - } - EngineEvent::CompactionStarted { message, .. } => { - app.is_compacting = true; - app.status_message = Some(message); - } - EngineEvent::CompactionCompleted { message, .. } => { - app.is_compacting = false; - app.status_message = Some(message); - } - EngineEvent::CompactionFailed { message, .. } => { - app.is_compacting = false; - app.status_message = Some(message); - } - EngineEvent::CycleAdvanced { from, to, briefing } => { - // Mirror the engine-side counter on the UI app state - // so the sidebar / slash commands stay in sync, and - // record the briefing so `/cycle ` can show it. - app.cycle_count = to; - let briefing_tokens = briefing.token_estimate; - app.cycle_briefings.push(briefing); - let separator = format!( - "─── cycle {from} → {to} (briefing: {briefing_tokens} tokens) ───" - ); - app.add_message(HistoryCell::System { content: separator }); - app.status_message = Some(format!( - "↻ context refreshed (cycle {from} → {to}, briefing: {briefing_tokens} tokens carried)" - )); - } - EngineEvent::CoherenceState { state, .. } => { - app.coherence_state = state; - } - EngineEvent::CapacityDecision { .. } => { - // Telemetry-only event. Surface actual interventions and failures - // instead of replacing the footer with no-op guardrail chatter. - } - EngineEvent::CapacityIntervention { - action, - before_prompt_tokens, - after_prompt_tokens, - .. - } => { - app.status_message = Some(format!( - "Capacity intervention: {action} (~{before_prompt_tokens} -> ~{after_prompt_tokens} tokens)" - )); - } - EngineEvent::CapacityMemoryPersistFailed { action, error, .. } => { - app.status_message = Some(format!( - "Capacity memory persist failed ({action}): {error}" - )); - } - EngineEvent::PauseEvents => { - if !event_broker.is_paused() { - pause_terminal( - terminal, - app.use_alt_screen, - app.use_mouse_capture, - app.use_bracketed_paste, - )?; - event_broker.pause_events(); - } - } - EngineEvent::ResumeEvents => { - if event_broker.is_paused() { - resume_terminal( - terminal, - app.use_alt_screen, - app.use_mouse_capture, - app.use_bracketed_paste, - )?; - event_broker.resume_events(); - } - } - EngineEvent::AgentSpawned { id, prompt } => { - let prompt_summary = summarize_tool_output(&prompt); - app.agent_progress - .insert(id.clone(), format!("starting: {prompt_summary}")); - if app.agent_activity_started_at.is_none() { - app.agent_activity_started_at = Some(Instant::now()); - } - app.status_message = - Some(format!("Sub-agent {id} starting: {prompt_summary}")); - let _ = engine_handle.send(Op::ListSubAgents).await; - } - EngineEvent::AgentProgress { id, status } => { - app.agent_progress - .insert(id.clone(), summarize_tool_output(&status)); - if app.agent_activity_started_at.is_none() { - app.agent_activity_started_at = Some(Instant::now()); - } - app.status_message = Some(format!("Sub-agent {id}: {status}")); - } - EngineEvent::AgentComplete { id, result } => { - app.agent_progress.remove(&id); - app.status_message = Some(format!( - "Sub-agent {id} completed: {}", - summarize_tool_output(&result) - )); - let _ = engine_handle.send(Op::ListSubAgents).await; - } - EngineEvent::AgentList { agents } => { - let mut sorted = agents.clone(); - sort_subagents_in_place(&mut sorted); - app.subagent_cache = sorted.clone(); - reconcile_subagent_activity_state(app); - if app.view_stack.update_subagents(&sorted) { - app.status_message = - Some(format!("Sub-agents: {} total", sorted.len())); - } - // Individual spawn/complete events already log to history; - // full list available via /agents command. - } - EngineEvent::SubAgentMailbox { seq, message } => { - handle_subagent_mailbox(app, seq, &message); - transcript_batch_updated = true; - } - EngineEvent::ApprovalRequired { - id, - tool_name, - description, - approval_key, - } => { - let session_approved = - app.approval_session_approved.contains(&approval_key) - || app.approval_session_approved.contains(&tool_name); - if session_approved || app.approval_mode == ApprovalMode::Auto { - log_sensitive_event( - "tool.approval.auto_approve", - serde_json::json!({ - "tool_name": tool_name, - "approval_key": approval_key, - "session_id": app.current_session_id, - "mode": app.mode.label(), - }), - ); - let _ = engine_handle.approve_tool_call(id.clone()).await; - } else if app.approval_mode == ApprovalMode::Never { - log_sensitive_event( - "tool.approval.auto_deny", - serde_json::json!({ - "tool_name": tool_name, - "session_id": app.current_session_id, - "mode": app.mode.label(), - }), - ); - let _ = engine_handle.deny_tool_call(id.clone()).await; - app.status_message = - Some(format!("Blocked tool '{tool_name}' (approval_mode=never)")); - } else { - let tool_input = app - .pending_tool_uses - .iter() - .find(|(tool_id, _, _)| tool_id == &id) - .map(|(_, _, input)| input.clone()) - .unwrap_or_else(|| serde_json::json!({})); - - if tool_name == "apply_patch" { - maybe_add_patch_preview(app, &tool_input); - } - - // Create approval request and show overlay - let request = ApprovalRequest::new( - &id, - &tool_name, - &description, - &tool_input, - &approval_key, - ); - log_sensitive_event( - "tool.approval.prompted", - serde_json::json!({ - "tool_name": tool_name, - "description": description, - "session_id": app.current_session_id, - "mode": app.mode.label(), - }), - ); - app.view_stack.push(ApprovalView::new(request)); - app.status_message = Some(format!( - "Approval required for '{tool_name}': {description}" - )); - } - } - EngineEvent::UserInputRequired { id, request } => { - app.view_stack.push(UserInputView::new(id.clone(), request)); - app.status_message = Some( - "Action required: answer the popup with 1-4, arrows, or Enter" - .to_string(), - ); - } - EngineEvent::ToolCallProgress { id, output } => { - app.status_message = - Some(format!("Tool {id}: {}", summarize_tool_output(&output))); - } - EngineEvent::ElevationRequired { - tool_id, - tool_name, - command, - denial_reason, - blocked_network, - blocked_write, - } => { - // In YOLO mode, auto-elevate to full access - if app.approval_mode == ApprovalMode::Auto { - log_sensitive_event( - "tool.sandbox.auto_elevate", - serde_json::json!({ - "tool_name": tool_name, - "tool_id": tool_id, - "reason": denial_reason, - "session_id": app.current_session_id, - }), - ); - app.add_message(HistoryCell::System { - content: format!( - "Sandbox denied {tool_name}: {denial_reason} - auto-elevating to full access" - ), - }); - // Auto-elevate to full access (no sandbox) - let policy = crate::sandbox::SandboxPolicy::DangerFullAccess; - let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await; - } else { - log_sensitive_event( - "tool.sandbox.prompt_elevation", - serde_json::json!({ - "tool_name": tool_name, - "tool_id": tool_id, - "reason": denial_reason, - "session_id": app.current_session_id, - }), - ); - // Show elevation dialog - let request = ElevationRequest::for_shell( - &tool_id, - command.as_deref().unwrap_or(&tool_name), - &denial_reason, - blocked_network, - blocked_write, - ); - app.view_stack.push(ElevationView::new(request)); - app.status_message = - Some(format!("Sandbox blocked {tool_name}: {denial_reason}")); - } - } - } - } - } - if transcript_batch_updated { - app.mark_history_updated(); - } - if received_engine_event { - app.needs_redraw = true; - } - - if let Some(next) = queued_to_send { - if let Err(err) = dispatch_user_message(app, &engine_handle, next.clone()).await { - app.queue_message(next); - app.status_message = Some(format!( - "Dispatch failed ({err}); kept {} queued message(s)", - app.queued_message_count() - )); - } - - app.needs_redraw = true; - } - - let queue_state = (app.queued_messages.clone(), app.queued_draft.clone()); - if queue_state != last_queue_state { - persist_offline_queue_state(app); - last_queue_state = queue_state; - app.needs_redraw = true; - } - - if !app.view_stack.is_empty() { - let events = app.view_stack.tick(); - if !events.is_empty() { - app.needs_redraw = true; - } - if handle_view_events(app, config, &task_manager, &mut engine_handle, events).await? { - return Ok(()); - } - } - - let has_running_agents = running_agent_count(app) > 0; - if (app.is_loading || has_running_agents || app.is_compacting) - && last_status_frame.elapsed() - >= Duration::from_millis(status_animation_interval_ms(app)) - { - if !app.low_motion && history_has_live_motion(&app.history) { - app.mark_history_updated(); - } - app.needs_redraw = true; - last_status_frame = Instant::now(); - } - - if event_broker.is_paused() { - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - continue; - } - - let now = Instant::now(); - app.flush_paste_burst_if_due(now); - app.sync_status_message_to_toasts(); - // Expire the "Press Ctrl+C again to quit" prompt silently after its - // window. Triggers a redraw if the prompt was visible. - app.tick_quit_armed(); - let allow_workspace_context_refresh = - !app.is_loading && !has_running_agents && !app.is_compacting; - refresh_workspace_context_if_needed(app, now, allow_workspace_context_refresh); - - // Draw is gated by the frame-rate limiter (120 FPS cap). When a - // redraw is needed but the limiter says we're inside the cooldown - // window, leave `needs_redraw = true` and shorten the poll timeout - // so the loop wakes up exactly when drawing is allowed. - - // Sync low-motion flag into the frame-rate limiter and streaming - // chunking policy. Low-motion mode drops the frame cap to 30 FPS - // and forces Smooth-only chunking so the display stays calm. - frame_rate_limiter.set_low_motion(app.low_motion); - app.streaming_state.set_low_motion(app.low_motion); - - let draw_wait = if app.needs_redraw { - frame_rate_limiter.time_until_next_draw(now) - } else { - None - }; - if app.needs_redraw && draw_wait.is_none() { - terminal.draw(|f| render(f, app))?; // app is &mut - frame_rate_limiter.mark_emitted(Instant::now()); - app.needs_redraw = false; - } - - let mut poll_timeout = if app.is_loading || has_running_agents || app.is_compacting { - Duration::from_millis(active_poll_ms(app)) - } else { - Duration::from_millis(idle_poll_ms(app)) - }; - if let Some(until_flush) = app.paste_burst.next_flush_delay(now) { - poll_timeout = poll_timeout.min(until_flush); - } - if let Some(until_draw) = draw_wait { - poll_timeout = poll_timeout.min(until_draw); - } - // While the quit-confirmation prompt is armed, ensure we wake up to - // expire it on time even if no input event arrives. - if let Some(deadline) = app.quit_armed_until { - let remaining = deadline.saturating_duration_since(now); - poll_timeout = poll_timeout.min(remaining.max(Duration::from_millis(50))); - } - if event::poll(poll_timeout)? { - let evt = event::read()?; - app.needs_redraw = true; - - // Handle bracketed paste events - if let Event::Paste(text) = &evt { - tracing::debug!( - paste_len = text.len(), - preview = %text.chars().take(80).collect::(), - "Received bracketed paste event" - ); - if app.onboarding == OnboardingState::ApiKey { - // Paste into API key input - app.insert_api_key_str(text); - sync_api_key_validation_status(app, false); - } else { - // Paste into main input - if let Some(pending) = app.paste_burst.flush_before_modified_input() { - app.insert_str(&pending); - } - app.insert_paste_text(text); - } - continue; - } - - if let Event::Resize(width, height) = evt { - tracing::debug!(width, height, "Event::Resize received; clearing terminal"); - // Drain any further Resize events queued in this poll cycle so we - // act on the final size only, then issue a single clear + redraw. - // crossterm coalesces some resize events but rapid drag-resizes - // can still queue several; processing them all here avoids the - // common "stale art on the right edge" symptom (#65) caused by - // the diff renderer skipping cells that match a stale back - // buffer between intermediate sizes. - let mut final_w = width; - let mut final_h = height; - while event::poll(Duration::from_millis(0)).unwrap_or(false) { - match event::read() { - Ok(Event::Resize(w, h)) => { - final_w = w; - final_h = h; - } - Ok(other) => { - // Non-resize event during the drain: we can't - // un-read it. Drop it and let the user re-issue - // — the resize-coalesce window is tiny. - tracing::debug!( - ?other, - "non-resize event during resize coalesce; dropping" - ); - break; - } - Err(_) => break, - } - } - terminal.clear()?; - app.handle_resize(final_w, final_h); - // Draw immediately so the cleared screen gets repainted before - // any other events can interleave. Without this, the next - // iteration's draw can race against fast follow-up input and - // leave the user staring at a blank/partial frame. - terminal.draw(|f| render(f, app))?; - app.needs_redraw = false; - continue; - } - - if app.use_mouse_capture - && let Event::Mouse(mouse) = evt - { - handle_mouse_event(app, mouse); - continue; - } - - let Event::Key(key) = evt else { - continue; - }; - - if key.kind != KeyEventKind::Press { - continue; - } - - // Handle onboarding flow - if app.onboarding != OnboardingState::None { - let advance_onboarding = |app: &mut App| { - app.status_message = None; - if app.onboarding_needs_api_key { - app.onboarding = OnboardingState::ApiKey; - } else if !app.trust_mode && onboarding::needs_trust(&app.workspace) { - app.onboarding = OnboardingState::TrustDirectory; - } else { - app.onboarding = OnboardingState::Tips; - } - }; - - match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - let _ = engine_handle.send(Op::Shutdown).await; - return Ok(()); - } - KeyCode::Esc if app.onboarding == OnboardingState::ApiKey => { - app.onboarding = OnboardingState::Welcome; - app.api_key_input.clear(); - app.api_key_cursor = 0; - app.status_message = None; - } - KeyCode::Enter => match app.onboarding { - OnboardingState::Welcome => { - advance_onboarding(app); - } - OnboardingState::ApiKey => { - let key = app.api_key_input.trim().to_string(); - if let ApiKeyValidation::Reject(message) = - validate_api_key_for_onboarding(&key) - { - app.status_message = Some(message); - continue; - } - match app.submit_api_key() { - Ok(_) => { - app.status_message = None; - // Recreate the engine so it picks up the newly saved key - // without requiring a full process restart. - let _ = engine_handle.send(Op::Shutdown).await; - let mut refreshed_config = config.clone(); - refreshed_config.api_key = Some(key); - let engine_config = build_engine_config(app, &refreshed_config); - engine_handle = spawn_engine(engine_config, &refreshed_config); - - if !app.api_messages.is_empty() { - let _ = engine_handle - .send(Op::SyncSession { - messages: app.api_messages.clone(), - system_prompt: app.system_prompt.clone(), - model: app.model.clone(), - workspace: app.workspace.clone(), - }) - .await; - } - - advance_onboarding(app); - } - Err(e) => { - app.status_message = Some(e.to_string()); - } - } - } - OnboardingState::TrustDirectory => {} - OnboardingState::Tips => { - app.finish_onboarding(); - } - OnboardingState::None => {} - }, - KeyCode::Char('y') | KeyCode::Char('Y') - if app.onboarding == OnboardingState::TrustDirectory => - { - match onboarding::mark_trusted(&app.workspace) { - Ok(_) => { - app.trust_mode = true; - app.status_message = None; - app.onboarding = OnboardingState::Tips; - } - Err(err) => { - app.status_message = - Some(format!("Failed to trust workspace: {err}")); - } - } - } - KeyCode::Char('n') | KeyCode::Char('N') - if app.onboarding == OnboardingState::TrustDirectory => - { - app.status_message = None; - app.onboarding = OnboardingState::Tips; - } - KeyCode::Backspace if app.onboarding == OnboardingState::ApiKey => { - app.delete_api_key_char(); - sync_api_key_validation_status(app, false); - } - KeyCode::Char(c) if app.onboarding == OnboardingState::ApiKey => { - app.insert_api_key_char(c); - sync_api_key_validation_status(app, false); - } - KeyCode::Char('v') | KeyCode::Char('V') - if is_paste_shortcut(&key) && app.onboarding == OnboardingState::ApiKey => - { - // Cmd+V / Ctrl+V paste (bracketed paste handled above) - app.paste_api_key_from_clipboard(); - sync_api_key_validation_status(app, false); - } - _ => {} - } - continue; - } - - if key.code == KeyCode::F(1) { - if app.view_stack.top_kind() == Some(ModalKind::Help) { - app.view_stack.pop(); - } else { - app.view_stack.push(HelpView::new()); - } - continue; - } - - if key.code == KeyCode::Char('/') && key.modifiers.contains(KeyModifiers::CONTROL) { - if app.view_stack.top_kind() == Some(ModalKind::Help) { - app.view_stack.pop(); - } else { - app.view_stack.push(HelpView::new()); - } - continue; - } - - if key.code == KeyCode::Char('k') && key.modifiers.contains(KeyModifiers::CONTROL) { - // When the composer is the active input target (no modal/pager - // intercepting keys), Ctrl+K performs an emacs-style kill to - // end-of-line. If the kill is a no-op (cursor at end of empty - // input), fall through to the existing command palette. - if app.view_stack.is_empty() && app.kill_to_end_of_line() { - continue; - } - app.view_stack - .push(CommandPaletteView::new(build_command_palette_entries( - &app.skills_dir, - &app.workspace, - ))); - continue; - } - - // Ctrl+P opens the fuzzy file-picker overlay. Bound only when the - // composer is focused (no other modal on top of the stack) and the - // engine is not actively streaming a turn. - if key.code == KeyCode::Char('p') - && key.modifiers.contains(KeyModifiers::CONTROL) - && app.view_stack.is_empty() - && !app.is_loading - { - open_file_picker(app); - continue; - } - - if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) - && key.modifiers.contains(KeyModifiers::ALT) - && !key.modifiers.contains(KeyModifiers::CONTROL) - && !key.modifiers.contains(KeyModifiers::SUPER) - && app.view_stack.is_empty() - { - open_context_inspector(app); - continue; - } - - if !app.view_stack.is_empty() { - let events = app.view_stack.handle_key(key); - if handle_view_events(app, config, &task_manager, &mut engine_handle, events) - .await? - { - return Ok(()); - } - continue; - } - - let now = Instant::now(); - app.flush_paste_burst_if_due(now); - - // On Windows, AltGr is delivered as `Ctrl+Alt`; treat - // AltGr-typed chars (e.g. European layouts producing `@`, `\`, - // `|`) as plain text rather than swallowing them as a modified - // shortcut. `key_hint::has_ctrl_or_alt` filters AltGr out. - let has_ctrl_alt_or_super = super::widgets::key_hint::has_ctrl_or_alt(key.modifiers) - || key.modifiers.contains(KeyModifiers::SUPER); - let is_plain_char = matches!(key.code, KeyCode::Char(_)) && !has_ctrl_alt_or_super; - let is_enter = matches!(key.code, KeyCode::Enter); - - if !is_plain_char - && !is_enter - && let Some(pending) = app.paste_burst.flush_before_modified_input() - { - app.insert_str(&pending); - } - - if (is_plain_char || is_enter) && super::paste::handle_paste_burst_key(app, &key, now) { - continue; - } - - let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT); - let slash_menu_open = !slash_menu_entries.is_empty(); - if slash_menu_open && app.slash_menu_selected >= slash_menu_entries.len() { - app.slash_menu_selected = slash_menu_entries.len().saturating_sub(1); - } - let mention_menu_entries = - crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT); - let mention_menu_open = !mention_menu_entries.is_empty(); - if mention_menu_open && app.mention_menu_selected >= mention_menu_entries.len() { - app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1); - } - - // Cancel a pending Esc-Esc prime as soon as any non-Esc key - // arrives. Without this the prime would hang around for the - // rest of the session and the user's next genuine Esc would - // suddenly skip straight into the backtrack overlay. - if !matches!(key.code, KeyCode::Esc) - && matches!( - app.backtrack.phase, - crate::tui::backtrack::BacktrackPhase::Primed - ) - { - app.backtrack.reset(); - } - - // Global keybindings - match key.code { - KeyCode::Enter - if app.input.is_empty() - && app.transcript_selection.is_active() - && open_pager_for_selection(app) => - { - continue; - } - KeyCode::Char('l') - if key.modifiers.is_empty() - && app.input.is_empty() - && open_pager_for_last_message(app) => - { - continue; - } - KeyCode::Char('v') | KeyCode::Char('V') - if details_shortcut_modifiers(key.modifiers) - && app.input.is_empty() - && open_tool_details_pager(app) => - { - continue; - } - KeyCode::Char('o') - if key.modifiers == KeyModifiers::CONTROL && open_thinking_pager(app) => - { - continue; - } - KeyCode::Char('t') | KeyCode::Char('T') - if key.modifiers == KeyModifiers::CONTROL => - { - toggle_live_transcript_overlay(app); - continue; - } - KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - app.set_sidebar_focus(SidebarFocus::Plan); - app.status_message = Some("Sidebar focus: plan".to_string()); - } else { - app.set_mode(AppMode::Plan); - } - continue; - } - KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::ALT) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - app.set_sidebar_focus(SidebarFocus::Todos); - app.status_message = Some("Sidebar focus: todos".to_string()); - } else { - app.set_mode(AppMode::Agent); - } - continue; - } - KeyCode::Char('3') if key.modifiers.contains(KeyModifiers::ALT) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - app.set_sidebar_focus(SidebarFocus::Tasks); - app.status_message = Some("Sidebar focus: tasks".to_string()); - } else { - app.set_mode(AppMode::Yolo); - } - continue; - } - KeyCode::Char('4') if key.modifiers.contains(KeyModifiers::ALT) => { - apply_alt_4_shortcut(app, key.modifiers); - continue; - } - KeyCode::Char('!') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_sidebar_focus(SidebarFocus::Plan); - app.status_message = Some("Sidebar focus: plan".to_string()); - continue; - } - KeyCode::Char('@') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_sidebar_focus(SidebarFocus::Todos); - app.status_message = Some("Sidebar focus: todos".to_string()); - continue; - } - KeyCode::Char('#') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_sidebar_focus(SidebarFocus::Tasks); - app.status_message = Some("Sidebar focus: tasks".to_string()); - continue; - } - KeyCode::Char('$') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_sidebar_focus(SidebarFocus::Agents); - app.status_message = Some("Sidebar focus: agents".to_string()); - continue; - } - KeyCode::Char(')') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_sidebar_focus(SidebarFocus::Auto); - app.status_message = Some("Sidebar focus: auto".to_string()); - continue; - } - KeyCode::Char('0') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_sidebar_focus(SidebarFocus::Auto); - app.status_message = Some("Sidebar focus: auto".to_string()); - continue; - } - KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.view_stack.push(SessionPickerView::new()); - continue; - } - KeyCode::Char('c') | KeyCode::Char('C') - if key.modifiers.contains(KeyModifiers::CONTROL) - && app.transcript_selection.is_active() => - { - copy_active_selection(app); - } - KeyCode::Char('c') | KeyCode::Char('C') if is_copy_shortcut(&key) => { - copy_active_selection(app); - } - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Three behaviors layered on Ctrl+C, in priority order: - // 1. While a turn is in flight, cancel it (unchanged). - // 2. Otherwise, on the first press, arm a 2-second - // "press Ctrl+C again to quit" prompt and stay - // running. - // 3. On the second press while still armed, exit cleanly. - // The prompt expires silently after the window so a - // stray Ctrl+C three seconds later re-arms instead of - // accidentally exiting. - if app.is_loading { - engine_handle.cancel(); - app.is_loading = false; - app.streaming_state.reset(); - // Optimistically clear the turn-in-progress flag so - // the footer wave animation halts immediately — - // without this, the strip keeps animating until the - // engine eventually emits TurnComplete (#5a). The - // engine's eventual TurnComplete event will overwrite - // with the real outcome ("interrupted"). - app.runtime_turn_status = None; - app.status_message = Some("Request cancelled".to_string()); - app.disarm_quit(); - } else if app.quit_is_armed() { - let _ = engine_handle.send(Op::Shutdown).await; - return Ok(()); - } else { - app.arm_quit(); - } - } - KeyCode::Char('d') - if key.modifiers.contains(KeyModifiers::CONTROL) && app.input.is_empty() => - { - let _ = engine_handle.send(Op::Shutdown).await; - return Ok(()); - } - KeyCode::Esc if mention_menu_open => { - app.mention_menu_hidden = true; - app.mention_menu_selected = 0; - } - KeyCode::Esc => match next_escape_action(app, slash_menu_open) { - EscapeAction::CloseSlashMenu => { - // A popup-style action wins over backtrack — clear - // any prime so a stale Primed state can't jump us - // straight into Selecting on the next Esc. - app.backtrack.reset(); - app.close_slash_menu(); - } - EscapeAction::CancelRequest => { - app.backtrack.reset(); - engine_handle.cancel(); - app.is_loading = false; - app.streaming_state.reset(); - // Optimistically halt the wave + working label — - // engine's TurnComplete will resync with the real - // outcome. Fixes #5a (wave kept animating after Esc). - app.runtime_turn_status = None; - app.finalize_streaming_assistant_as_interrupted(); - app.status_message = Some("Request cancelled".to_string()); - } - EscapeAction::SteerAndAbort => { - app.backtrack.reset(); - if let Some(input) = app.submit_input() { - let queued = build_queued_message(app, input); - app.push_pending_steer(queued); - engine_handle.cancel(); - app.is_loading = false; - app.streaming_state.reset(); - app.runtime_turn_status = None; - app.finalize_streaming_assistant_as_interrupted(); - let count = app.pending_steers.len(); - app.status_message = Some(if count == 1 { - "Steering: aborting turn and resending input".to_string() - } else { - format!("Steering: aborting turn and resending {count} input(s)") - }); - } - } - EscapeAction::DiscardQueuedDraft => { - app.backtrack.reset(); - app.queued_draft = None; - app.status_message = Some("Stopped editing queued message".to_string()); - } - EscapeAction::ClearInput => { - app.backtrack.reset(); - app.clear_input(); - } - EscapeAction::Noop => { - // Nothing else cares about this Esc — route it - // through the backtrack state machine. While - // streaming or with the live transcript already - // open, fall through silently (#133 acceptance: - // "during streaming Esc-Esc is a silent no-op"). - if app.is_loading - || app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) - { - continue; - } - let total = count_user_history_cells(app); - match app.backtrack.handle_esc(total) { - crate::tui::backtrack::EscEffect::None => {} - crate::tui::backtrack::EscEffect::Prime => { - app.status_message = - Some("Press Esc again to backtrack".to_string()); - app.needs_redraw = true; - } - crate::tui::backtrack::EscEffect::Cancel => { - app.status_message = Some("Backtrack canceled".to_string()); - app.needs_redraw = true; - } - crate::tui::backtrack::EscEffect::OpenOverlay => { - open_backtrack_overlay(app); - } - } - } - }, - // #85: Alt+↑ pops the most-recent queued message back into the - // composer for editing when the preview's affordance is visible - // (queue non-empty, composer idle). Splits the binding into two - // arms so the legacy scroll fallback is unambiguous on the same - // chord. - KeyCode::Up - if key.modifiers.contains(KeyModifiers::ALT) - && app.input.is_empty() - && app.queued_draft.is_none() - && !app.queued_messages.is_empty() => - { - let _ = app.pop_last_queued_into_draft(); - } - KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => { - app.scroll_up(3); - } - KeyCode::Up - if key.modifiers.is_empty() - && mention_menu_open - && app.mention_menu_selected > 0 => - { - app.mention_menu_selected = app.mention_menu_selected.saturating_sub(1); - } - KeyCode::Up - if key.modifiers.is_empty() - && slash_menu_open - && app.slash_menu_selected > 0 => - { - app.slash_menu_selected = app.slash_menu_selected.saturating_sub(1); - } - KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => { - app.scroll_down(3); - } - KeyCode::Down if key.modifiers.is_empty() && mention_menu_open => { - app.mention_menu_selected = (app.mention_menu_selected + 1) - .min(mention_menu_entries.len().saturating_sub(1)); - } - KeyCode::Down if key.modifiers.is_empty() && slash_menu_open => { - app.slash_menu_selected = (app.slash_menu_selected + 1) - .min(slash_menu_entries.len().saturating_sub(1)); - } - KeyCode::PageUp => { - let page = app.last_transcript_visible.max(1); - app.scroll_up(page); - } - KeyCode::PageDown => { - let page = app.last_transcript_visible.max(1); - app.scroll_down(page); - } - KeyCode::Tab => { - if mention_menu_open - && crate::tui::file_mention::apply_mention_menu_selection( - app, - &mention_menu_entries, - ) - { - continue; - } - if slash_menu_open && apply_slash_menu_selection(app, &slash_menu_entries, true) - { - continue; - } - if try_autocomplete_slash_command(app) { - continue; - } - if crate::tui::file_mention::try_autocomplete_file_mention(app) { - continue; - } - let prior_model = app.model.clone(); - app.cycle_mode(); - if app.model != prior_model { - let _ = engine_handle - .send(Op::SetModel { - model: app.model.clone(), - }) - .await; - } - } - KeyCode::BackTab => { - app.cycle_effort(); - } - KeyCode::Char('g') - if key.modifiers.is_empty() && app.input.is_empty() && !slash_menu_open => - { - if let Some(anchor) = - TranscriptScroll::anchor_for(app.transcript_cache.line_meta(), 0) - { - app.transcript_scroll = anchor; - } - } - KeyCode::Char('G') - if (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) - && app.input.is_empty() - && !slash_menu_open => - { - app.scroll_to_bottom(); - } - KeyCode::Char('[') - if key.modifiers.is_empty() - && app.input.is_empty() - && !slash_menu_open - && !jump_to_adjacent_tool_cell(app, SearchDirection::Backward) => - { - app.status_message = Some("No previous tool output".to_string()); - } - KeyCode::Char(']') - if key.modifiers.is_empty() - && app.input.is_empty() - && !slash_menu_open - && !jump_to_adjacent_tool_cell(app, SearchDirection::Forward) => - { - app.status_message = Some("No next tool output".to_string()); - } - // `?` opens the searchable help overlay (#93). Gated on the - // composer being empty so typing `?` mid-question is treated - // as text. `Shift` is permitted because US layouts produce - // `?` as `Shift+/`. Help-modal toggling lives next to the - // F1 / Ctrl+/ branch above; here we only open. - KeyCode::Char('?') - if (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) - && app.input.is_empty() - && !slash_menu_open => - { - if app.view_stack.top_kind() != Some(ModalKind::Help) { - app.view_stack.push(HelpView::new()); - } - continue; - } - // Input handling - KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.insert_char('\n'); - } - KeyCode::Enter if key.modifiers.contains(KeyModifiers::ALT) => { - app.insert_char('\n'); - } - KeyCode::Enter - if mention_menu_open - && crate::tui::file_mention::apply_mention_menu_selection( - app, - &mention_menu_entries, - ) => - { - continue; - } - KeyCode::Enter => { - if let Some(input) = app.submit_input() { - if handle_plan_choice(app, &engine_handle, &input).await? { - continue; - } - if input.starts_with('/') { - if execute_command_input( - app, - &mut engine_handle, - &task_manager, - config, - &input, - ) - .await? - { - return Ok(()); - } - } else { - let queued = if let Some(mut draft) = app.queued_draft.take() { - draft.display = input; - draft - } else { - build_queued_message(app, input) - }; - submit_or_steer_message(app, &engine_handle, queued).await?; - } - } - } - KeyCode::Backspace => { - app.delete_char(); - } - KeyCode::Delete => { - app.delete_char_forward(); - } - KeyCode::Left => { - app.move_cursor_left(); - } - KeyCode::Right => { - app.move_cursor_right(); - } - KeyCode::Home if key.modifiers.is_empty() => { - if let Some(anchor) = - TranscriptScroll::anchor_for(app.transcript_cache.line_meta(), 0) - { - app.transcript_scroll = anchor; - } - } - KeyCode::End if key.modifiers.is_empty() => { - app.scroll_to_bottom(); - } - KeyCode::Home | KeyCode::Char('a') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - app.move_cursor_start(); - } - KeyCode::End => { - app.move_cursor_end(); - } - KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Ctrl+E: spawn $EDITOR on the composer contents (#91). - // Only fires when no modal is active (the !view_stack - // branch above already returns early in that case) and - // the composer is the focused input target. We accept the - // shortcut whether or not a model turn is streaming — - // editing the buffer never disturbs in-flight work. - let seed = app.input.clone(); - match super::external_editor::spawn_editor_for_input( - terminal, - app.use_alt_screen, - app.use_mouse_capture, - app.use_bracketed_paste, - &seed, - ) { - Ok(super::external_editor::EditorOutcome::Edited(new)) => { - app.input = new; - app.move_cursor_end(); - let editor = std::env::var("VISUAL") - .ok() - .filter(|s| !s.trim().is_empty()) - .or_else(|| { - std::env::var("EDITOR") - .ok() - .filter(|s| !s.trim().is_empty()) - }) - .unwrap_or_else(|| "vi".to_string()); - app.status_message = Some(format!("Edited in {editor}")); - } - Ok(super::external_editor::EditorOutcome::Unchanged) => { - app.status_message = Some("Editor closed (no changes)".to_string()); - } - Ok(super::external_editor::EditorOutcome::Cancelled) => { - app.status_message = Some("Editor cancelled".to_string()); - } - Err(err) => { - app.status_message = Some(format!("Editor error: {err}")); - } - } - app.needs_redraw = true; - } - KeyCode::Up => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - app.history_up(); - } else if should_scroll_with_arrows(app) { - app.scroll_up(1); - } else { - app.history_up(); - } - } - KeyCode::Down => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - app.history_down(); - } else if should_scroll_with_arrows(app) { - app.scroll_down(1); - } else { - app.history_down(); - } - } - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.clear_input(); - } - KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Emacs-style yank from the kill buffer at the cursor. - // No-op when the buffer is empty. - app.yank(); - } - KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { - let new_mode = match app.mode { - AppMode::Plan => AppMode::Agent, - _ => AppMode::Plan, - }; - app.set_mode(new_mode); - } - KeyCode::Char('v') if is_paste_shortcut(&key) => { - app.paste_from_clipboard(); - } - KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Agent); - continue; - } - KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Yolo); - continue; - } - KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Plan); - continue; - } - KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Agent); - continue; - } - KeyCode::Char('Y') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Yolo); - continue; - } - KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Plan); - continue; - } - KeyCode::Char('v') | KeyCode::Char('V') - if key.modifiers.contains(KeyModifiers::ALT) => - { - open_tool_details_pager(app); - continue; - } - KeyCode::Char(c) => { - app.insert_char(c); - } - _ => {} - } - - if !is_plain_char && !is_enter { - app.paste_burst.clear_window_after_non_char(); - } - } - } -} - -fn apply_alt_4_shortcut(app: &mut App, modifiers: KeyModifiers) { - if modifiers.contains(KeyModifiers::CONTROL) { - app.set_sidebar_focus(SidebarFocus::Agents); - app.status_message = Some("Sidebar focus: agents".to_string()); - } else { - app.set_mode(AppMode::Plan); - } -} - -async fn fetch_available_models(config: &Config) -> Result> { - use crate::client::DeepSeekClient; - - let client = DeepSeekClient::new(config)?; - let models = tokio::time::timeout(Duration::from_secs(20), client.list_models()).await??; - let mut ids = models.into_iter().map(|model| model.id).collect::>(); - ids.sort(); - ids.dedup(); - Ok(ids) -} - -fn format_available_models_message(current_model: &str, models: &[String]) -> String { - let mut lines = vec![format!("Available models ({})", models.len())]; - for model in models { - if model == current_model { - lines.push(format!("* {model} (current)")); - } else { - lines.push(format!(" {model}")); - } - } - lines.join("\n") -} - -fn build_session_snapshot(app: &App, manager: &SessionManager) -> SavedSession { - if let Some(ref existing_id) = app.current_session_id - && let Ok(existing) = manager.load_session(existing_id) - { - let mut updated = update_session( - existing, - &app.api_messages, - u64::from(app.total_tokens), - app.system_prompt.as_ref(), - ); - updated.metadata.mode = Some(app.mode.as_setting().to_string()); - updated.context_references = app.session_context_references.clone(); - updated - } else { - let mut session = create_saved_session_with_mode( - &app.api_messages, - &app.model, - &app.workspace, - u64::from(app.total_tokens), - app.system_prompt.as_ref(), - Some(app.mode.as_setting()), - ); - session.context_references = app.session_context_references.clone(); - session - } -} - -fn persist_session_snapshot(app: &mut App) { - if let Ok(manager) = SessionManager::default_location() { - let session = build_session_snapshot(app, &manager); - if let Err(err) = manager.save_session(&session) { - eprintln!("Failed to save session: {err}"); - } else { - app.current_session_id = Some(session.metadata.id.clone()); - } - } -} - -fn persist_checkpoint(app: &mut App) { - if let Ok(manager) = SessionManager::default_location() { - let session = build_session_snapshot(app, &manager); - if let Err(err) = manager.save_checkpoint(&session) { - eprintln!("Failed to save checkpoint: {err}"); - } - } -} - -fn clear_checkpoint() { - if let Ok(manager) = SessionManager::default_location() { - let _ = manager.clear_checkpoint(); - } -} - -fn queued_ui_to_session(msg: &QueuedMessage) -> QueuedSessionMessage { - QueuedSessionMessage { - display: msg.display.clone(), - skill_instruction: msg.skill_instruction.clone(), - } -} - -fn queued_session_to_ui(msg: QueuedSessionMessage) -> QueuedMessage { - QueuedMessage { - display: msg.display, - skill_instruction: msg.skill_instruction, - } -} - -/// Translate an `EngineEvent::Error` into UI state updates. -/// -/// The engine's `recoverable` flag (mirrored on `ErrorEnvelope`) decides -/// whether the session flips into offline mode: stream stalls, chunk -/// timeouts, transient network errors, and rate-limit/server hiccups arrive -/// recoverable and must NOT flip into offline. Hard failures (auth, billing, -/// invalid request) arrive non-recoverable; those flip offline so subsequent -/// messages get queued instead of silently lost mid-flight. -/// -/// `severity` drives transcript color: red for `Error`/`Critical`, amber for -/// `Warning`, dim for `Info`. -pub(crate) fn apply_engine_error_to_app( - app: &mut App, - envelope: crate::error_taxonomy::ErrorEnvelope, -) { - let recoverable = envelope.recoverable; - let message = envelope.message.clone(); - let severity = envelope.severity; - app.streaming_state.reset(); - app.streaming_message_index = None; - app.streaming_thinking_active_entry = None; - app.add_message(HistoryCell::Error { - message: message.clone(), - severity, - }); - app.is_loading = false; - if recoverable { - app.status_message = Some(format!("Connection interrupted: {message}")); - } else { - app.offline_mode = true; - app.status_message = Some(format!( - "Engine error; queued messages stay pending: {message}" - )); - } -} - -fn persist_offline_queue_state(app: &App) { - if let Ok(manager) = SessionManager::default_location() { - if app.queued_messages.is_empty() && app.queued_draft.is_none() { - let _ = manager.clear_offline_queue_state(); - return; - } - let state = OfflineQueueState { - messages: app - .queued_messages - .iter() - .map(queued_ui_to_session) - .collect(), - draft: app.queued_draft.as_ref().map(queued_ui_to_session), - ..OfflineQueueState::default() - }; - let _ = manager.save_offline_queue_state(&state); - } -} - -fn sanitize_stream_chunk(chunk: &str) -> String { - // Keep printable characters and common whitespace; drop control bytes. - chunk - .chars() - .filter(|c| *c == '\n' || *c == '\t' || !c.is_control()) - .collect() -} - -/// Ensure an in-flight streaming Assistant cell exists in history and return -/// its index. Thinking cells go through `ensure_streaming_thinking_active_entry` -/// (active cell) instead. -fn ensure_streaming_assistant_history_cell(app: &mut App) -> usize { - if let Some(index) = app.streaming_message_index { - return index; - } - app.add_message(HistoryCell::Assistant { - content: String::new(), - streaming: true, - }); - let index = app.history.len().saturating_sub(1); - app.streaming_message_index = Some(index); - index -} - -fn append_streaming_text(app: &mut App, index: usize, text: &str) { - if text.is_empty() { - return; - } - if let Some(HistoryCell::Assistant { content, .. }) = app.history.get_mut(index) { - content.push_str(text); - // Bump only the streaming cell's per-cell revision so the transcript - // cache re-renders just this cell. Without this, the cache would - // either skip the update entirely (now that the global - // history_version is no longer fanned out across every cell) or fall - // back to a full re-wrap of the entire transcript every chunk. - app.bump_history_cell(index); - } -} - -/// Ensure an in-flight Thinking entry exists in `active_cell` and return its -/// entry index. If no thinking entry is currently streaming, push a fresh one. -/// P2.3: thinking shares the active cell with subsequent tool calls so the -/// pair render as one logical "Working…" block. -fn ensure_streaming_thinking_active_entry(app: &mut App) -> usize { - if let Some(idx) = app.streaming_thinking_active_entry { - return idx; - } - 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_thinking(HistoryCell::Thinking { - content: String::new(), - streaming: true, - duration_secs: None, - }); - app.streaming_thinking_active_entry = Some(entry_idx); - app.bump_active_cell_revision(); - entry_idx -} - -/// Append text to a streaming Thinking entry inside `active_cell`. Bumps the -/// active-cell revision so the renderer re-draws the live tail. -fn append_streaming_thinking(app: &mut App, entry_idx: usize, text: &str) { - if text.is_empty() { - return; - } - let mutated = if let Some(active) = app.active_cell.as_mut() - && let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(entry_idx) - { - content.push_str(text); - true - } else { - false - }; - if mutated { - app.bump_active_cell_revision(); - } -} - -/// Finalize the in-flight thinking entry in `active_cell`: append the -/// collector's remaining buffered text, stop the spinner, and stamp the -/// duration. Returns `true` when a thinking entry was finalized (so the -/// dispatch loop knows the transcript was touched). No-op if no thinking -/// entry is currently streaming. -fn finalize_streaming_thinking_active_entry( - app: &mut App, - duration: Option, - remaining: &str, -) -> bool { - let Some(entry_idx) = app.streaming_thinking_active_entry.take() else { - return false; - }; - if !remaining.is_empty() { - append_streaming_thinking(app, entry_idx, remaining); - } - if let Some(active) = app.active_cell.as_mut() - && let Some(HistoryCell::Thinking { - streaming, - duration_secs, - .. - }) = active.entry_mut(entry_idx) - { - *streaming = false; - *duration_secs = duration; - } - app.bump_active_cell_revision(); - true -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EscapeAction { - CloseSlashMenu, - CancelRequest, - /// Composer non-empty during a running turn — capture the input as a - /// pending steer, abort the turn, and re-submit on TurnComplete (#122). - SteerAndAbort, - DiscardQueuedDraft, - ClearInput, - Noop, -} - -fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction { - if slash_menu_open { - EscapeAction::CloseSlashMenu - } else if app.is_loading { - if app.input.trim().is_empty() { - EscapeAction::CancelRequest - } else { - EscapeAction::SteerAndAbort - } - } else if app.queued_draft.is_some() && app.input.is_empty() { - EscapeAction::DiscardQueuedDraft - } else if !app.input.is_empty() { - EscapeAction::ClearInput - } else { - EscapeAction::Noop - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ApiKeyValidation { - Accept { warning: Option }, - Reject(String), -} - -fn validate_api_key_for_onboarding(api_key: &str) -> ApiKeyValidation { - let trimmed = api_key.trim(); - if trimmed.is_empty() { - return ApiKeyValidation::Reject("API key cannot be empty.".to_string()); - } - if trimmed.contains(char::is_whitespace) { - return ApiKeyValidation::Reject( - "API key appears malformed (contains whitespace).".to_string(), - ); - } - if trimmed.len() < 16 { - return ApiKeyValidation::Accept { - warning: Some( - "API key looks short. Double-check it, but unusual formats are allowed." - .to_string(), - ), - }; - } - if !trimmed.contains('-') { - return ApiKeyValidation::Accept { - warning: Some( - "API key format looks unusual. Check that the full key was copied.".to_string(), - ), - }; - } - ApiKeyValidation::Accept { warning: None } -} - -fn sync_api_key_validation_status(app: &mut App, show_empty_error: bool) { - if app.api_key_input.trim().is_empty() && !show_empty_error { - app.status_message = None; - return; - } - - match validate_api_key_for_onboarding(&app.api_key_input) { - ApiKeyValidation::Accept { warning } => { - app.status_message = warning; - } - ApiKeyValidation::Reject(message) => { - app.status_message = Some(message); - } - } -} - -fn build_queued_message(app: &mut App, input: String) -> QueuedMessage { - let skill_instruction = app.active_skill.take(); - QueuedMessage::new(input, skill_instruction) -} - -fn queued_message_content_for_app( - app: &App, - message: &QueuedMessage, - cwd: Option, -) -> String { - // Pass the process CWD explicitly so the resolver's two-pass logic can - // honor the user's launch directory when it differs from `--workspace` - // (issue #101 — file mentions silently routing to the wrong root). - let user_request = crate::tui::file_mention::user_request_with_file_mentions( - &message.display, - &app.workspace, - cwd, - ); - if let Some(skill_instruction) = message.skill_instruction.as_ref() { - format!("{skill_instruction}\n\n---\n\nUser request: {user_request}") - } else { - user_request - } -} - -async fn dispatch_user_message( - app: &mut App, - engine_handle: &EngineHandle, - message: QueuedMessage, -) -> Result<()> { - // Set immediately to prevent double-dispatch before TurnStarted event arrives. - app.is_loading = true; - app.last_send_at = Some(Instant::now()); - - let cwd = std::env::current_dir().ok(); - let references = crate::tui::file_mention::context_references_from_input( - &message.display, - &app.workspace, - cwd.clone(), - ); - let content = queued_message_content_for_app(app, &message, cwd); - let message_index = app.api_messages.len(); - app.system_prompt = Some(prompts::system_prompt_for_mode_with_context( - app.mode, - &app.workspace, - None, - )); - app.add_message(HistoryCell::User { - content: message.display.clone(), - }); - let history_cell = app.history.len().saturating_sub(1); - app.record_context_references(history_cell, message_index, references); - app.scroll_to_bottom(); - app.api_messages.push(Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: content.clone(), - cache_control: None, - }], - }); - maybe_warn_context_pressure(app); - if should_auto_compact_before_send(app) { - app.status_message = Some("Context critical; compacting before send...".to_string()); - let _ = engine_handle.send(Op::CompactContext).await; - } - app.last_prompt_tokens = None; - app.last_completion_tokens = None; - app.last_prompt_cache_hit_tokens = None; - app.last_prompt_cache_miss_tokens = None; - app.last_reasoning_replay_tokens = None; - // Persist immediately so abrupt termination can recover this in-flight turn. - persist_checkpoint(app); - - engine_handle - .send(Op::SendMessage { - content, - mode: app.mode, - model: app.model.clone(), - reasoning_effort: app.reasoning_effort.api_value().map(str::to_string), - allow_shell: app.allow_shell, - trust_mode: app.trust_mode, - auto_approve: app.mode == AppMode::Yolo, - }) - .await?; - - Ok(()) -} - -async fn apply_model_and_compaction_update( - engine_handle: &EngineHandle, - compaction: crate::compaction::CompactionConfig, -) { - let _ = engine_handle - .send(Op::SetModel { - model: compaction.model.clone(), - }) - .await; - let _ = engine_handle - .send(Op::SetCompaction { config: compaction }) - .await; -} - -/// Apply the choice made in the `/model` picker (#39): mutate App state so -/// the next turn uses the new model/effort, persist the selection to -/// `~/.deepseek/settings.toml` so it survives a restart, push the change to -/// the running engine via `Op::SetModel`/`Op::SetCompaction`, and surface -/// a one-line status describing what changed. -async fn apply_model_picker_choice( - app: &mut App, - engine_handle: &EngineHandle, - model: String, - effort: crate::tui::app::ReasoningEffort, - previous_model: String, - previous_effort: crate::tui::app::ReasoningEffort, -) { - let model_changed = model != previous_model; - let effort_changed = effort != previous_effort; - if !model_changed && !effort_changed { - app.status_message = Some(format!( - "Model unchanged: {model} · thinking {}", - effort.short_label() - )); - return; - } - - if model_changed { - app.model = model.clone(); - app.update_model_compaction_budget(); - app.last_prompt_tokens = None; - app.last_completion_tokens = None; - app.last_prompt_cache_hit_tokens = None; - app.last_prompt_cache_miss_tokens = None; - app.last_reasoning_replay_tokens = None; - } - if effort_changed { - app.reasoning_effort = effort; - } - - // Best-effort persist; surface a status warning if the settings file - // can't be written rather than aborting the in-memory change. - let mut persist_warning: Option = None; - match crate::settings::Settings::load() { - Ok(mut settings) => { - if model_changed { - let _ = settings.set("default_model", &model); - } - if effort_changed { - let _ = settings.set("reasoning_effort", effort.as_setting()); - } - if let Err(err) = settings.save() { - persist_warning = Some(format!("(not persisted: {err})")); - } - } - Err(err) => { - persist_warning = Some(format!("(not persisted: {err})")); - } - } - - if model_changed { - apply_model_and_compaction_update(engine_handle, app.compaction_config()).await; - } - - let mut summary = match (model_changed, effort_changed) { - (true, true) => format!( - "Model: {previous_model} → {model} · thinking: {} → {}", - previous_effort.short_label(), - effort.short_label() - ), - (true, false) => format!( - "Model: {previous_model} → {model} · thinking {}", - effort.short_label() - ), - (false, true) => format!( - "Thinking: {} → {} · model {model}", - previous_effort.short_label(), - effort.short_label() - ), - (false, false) => unreachable!(), - }; - if let Some(warning) = persist_warning { - summary.push(' '); - summary.push_str(&warning); - } - app.status_message = Some(summary); -} - -/// Apply a `/provider` switch by mutating the in-memory config, validating -/// that credentials exist for the new provider, then respawning the engine -/// so the API client picks up the new base URL/key. When `model_override` -/// is set, it replaces the active model post-switch (already normalized, -/// will be provider-prefixed by `Config::default_model`). -async fn switch_provider( - app: &mut App, - engine_handle: &mut EngineHandle, - config: &mut Config, - target: ApiProvider, - model_override: Option, -) { - let previous_provider = app.api_provider; - let previous_model = app.model.clone(); - let previous_provider_str = config.provider.clone(); - let previous_base_url = config.base_url.clone(); - let previous_default_text_model = config.default_text_model.clone(); - - config.provider = Some(target.as_str().to_string()); - if matches!(target, ApiProvider::NvidiaNim) - && config - .base_url - .as_deref() - .map(|base| !base.contains("integrate.api.nvidia.com")) - .unwrap_or(true) - { - config.base_url = Some(DEFAULT_NVIDIA_NIM_BASE_URL.to_string()); - } - if matches!(target, ApiProvider::Deepseek) - && config - .base_url - .as_deref() - .map(|base| base.contains("integrate.api.nvidia.com")) - .unwrap_or(false) - { - config.base_url = None; - } - if let Some(ref model) = model_override { - config.default_text_model = Some(model.clone()); - } - - if let Err(err) = DeepSeekClient::new(config) { - config.provider = previous_provider_str; - config.base_url = previous_base_url; - config.default_text_model = previous_default_text_model; - app.add_message(HistoryCell::System { - content: format!( - "Failed to switch provider to {}: {err}\nProvider unchanged ({}).", - target.as_str(), - previous_provider.as_str() - ), - }); - return; - } - - let new_model = config.default_model(); - app.api_provider = target; - app.model = new_model.clone(); - app.update_model_compaction_budget(); - app.last_prompt_tokens = None; - app.last_completion_tokens = None; - - let _ = engine_handle.send(Op::Shutdown).await; - let engine_config = build_engine_config(app, config); - *engine_handle = spawn_engine(engine_config, config); - - if !app.api_messages.is_empty() { - let _ = engine_handle - .send(Op::SyncSession { - messages: app.api_messages.clone(), - system_prompt: app.system_prompt.clone(), - model: app.model.clone(), - workspace: app.workspace.clone(), - }) - .await; - } - let _ = engine_handle - .send(Op::SetCompaction { - config: app.compaction_config(), - }) - .await; - - app.add_message(HistoryCell::System { - content: format!( - "Provider switched: {} → {}\nModel: {} → {}", - previous_provider.as_str(), - target.as_str(), - previous_model, - new_model - ), - }); - app.status_message = Some(format!("Provider: {}", target.as_str())); -} - -fn open_text_pager(app: &mut App, title: String, content: String) { - let width = app - .last_transcript_area - .map(|area| area.width) - .unwrap_or(80); - app.view_stack.push(PagerView::from_text( - title, - &content, - width.saturating_sub(2), - )); -} - -fn open_context_inspector(app: &mut App) { - let width = app - .last_transcript_area - .map(|area| area.width) - .unwrap_or(80); - let content = build_context_inspector_text(app); - app.view_stack.push(PagerView::from_text( - "Context inspector", - &content, - width.saturating_sub(2), - )); -} - -fn open_file_picker(app: &mut App) { - let relevance = build_file_picker_relevance(app); - app.view_stack - .push(crate::tui::file_picker::FilePickerView::new_with_relevance( - &app.workspace, - relevance, - )); -} - -fn build_file_picker_relevance(app: &App) -> crate::tui::file_picker::FilePickerRelevance { - let mut relevance = crate::tui::file_picker::FilePickerRelevance::default(); - - for path in modified_workspace_paths(&app.workspace) { - relevance.mark_modified(path); - } - - for record in app.session_context_references.iter().rev().take(64) { - let reference = &record.reference; - if reference.source != crate::tui::file_mention::ContextReferenceSource::AtMention { - continue; - } - if !matches!( - reference.kind, - crate::tui::file_mention::ContextReferenceKind::File - ) { - continue; - } - for raw in [&reference.target, &reference.label] { - if let Some(path) = workspace_file_candidate(raw, &app.workspace) { - relevance.mark_mentioned(path); - } - } - } - - let mut seen_tool_paths = HashSet::new(); - for detail in app.active_tool_details.values() { - mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance); - } - let mut rows: Vec<_> = app.tool_details_by_cell.iter().collect(); - rows.sort_by_key(|(idx, _)| std::cmp::Reverse(**idx)); - for (_, detail) in rows.into_iter().take(48) { - mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance); - } - - relevance -} - -fn modified_workspace_paths(workspace: &Path) -> Vec { - let Ok(output) = Command::new("git") - .arg("-C") - .arg(workspace) - .args(["status", "--short", "--untracked-files=normal"]) - .output() - else { - return Vec::new(); - }; - if !output.status.success() { - return Vec::new(); - } - - String::from_utf8_lossy(&output.stdout) - .lines() - .filter_map(parse_git_status_path) - .filter_map(|path| workspace_file_candidate(&path, workspace)) - .collect() -} - -fn parse_git_status_path(line: &str) -> Option { - if line.len() < 4 { - return None; - } - let raw = line.get(3..)?.trim(); - let raw = raw.rsplit(" -> ").next().unwrap_or(raw).trim(); - let raw = raw.trim_matches('"'); - if raw.is_empty() { - None - } else { - Some(raw.to_string()) - } -} - -fn mark_tool_detail_paths( - detail: &ToolDetailRecord, - workspace: &Path, - seen: &mut HashSet, - relevance: &mut crate::tui::file_picker::FilePickerRelevance, -) { - let mut budget = 256usize; - mark_tool_paths_from_value(&detail.input, workspace, seen, relevance, &mut budget); - if let Some(output) = detail - .output - .as_deref() - .filter(|output| output.len() <= 8_192) - { - mark_tool_paths_from_text(output, workspace, seen, relevance, &mut budget); - } -} - -fn mark_tool_paths_from_value( - value: &serde_json::Value, - workspace: &Path, - seen: &mut HashSet, - relevance: &mut crate::tui::file_picker::FilePickerRelevance, - budget: &mut usize, -) { - if *budget == 0 { - return; - } - match value { - serde_json::Value::String(text) => { - mark_tool_paths_from_text(text, workspace, seen, relevance, budget); - } - serde_json::Value::Array(items) => { - for item in items { - mark_tool_paths_from_value(item, workspace, seen, relevance, budget); - if *budget == 0 { - break; - } - } - } - serde_json::Value::Object(map) => { - for item in map.values() { - mark_tool_paths_from_value(item, workspace, seen, relevance, budget); - if *budget == 0 { - break; - } - } - } - _ => {} - } -} - -fn mark_tool_paths_from_text( - text: &str, - workspace: &Path, - seen: &mut HashSet, - relevance: &mut crate::tui::file_picker::FilePickerRelevance, - budget: &mut usize, -) { - if *budget == 0 || text.len() > 8_192 { - return; - } - if let Some(path) = workspace_file_candidate(text, workspace) - && seen.insert(path.clone()) - { - relevance.mark_tool(path); - *budget = (*budget).saturating_sub(1); - } - for token in text.split_whitespace().take(128) { - if *budget == 0 { - break; - } - if let Some(path) = workspace_file_candidate(token, workspace) - && seen.insert(path.clone()) - { - relevance.mark_tool(path); - *budget = (*budget).saturating_sub(1); - } - } -} - -fn workspace_file_candidate(raw: &str, workspace: &Path) -> Option { - let cleaned = clean_path_token(raw)?; - let path = Path::new(&cleaned); - let absolute = if path.is_absolute() { - PathBuf::from(path) - } else { - workspace.join(path) - }; - if !absolute.is_file() { - return None; - } - let rel = absolute.strip_prefix(workspace).ok()?; - workspace_path_to_picker_string(rel) -} - -fn clean_path_token(raw: &str) -> Option { - let mut trimmed = raw.trim().trim_matches(|ch: char| { - ch.is_ascii_whitespace() - || matches!( - ch, - '"' | '\'' | '`' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' - ) - }); - if let Some(stripped) = trimmed.strip_prefix("./") { - trimmed = stripped; - } - if let Some((before, after)) = trimmed.rsplit_once(':') - && !before.is_empty() - && after.chars().all(|ch| ch.is_ascii_digit()) - { - trimmed = before; - } - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -fn workspace_path_to_picker_string(path: &Path) -> Option { - let mut out = String::new(); - for (idx, component) in path.components().enumerate() { - if matches!( - component, - std::path::Component::ParentDir - | std::path::Component::RootDir - | std::path::Component::Prefix(_) - ) { - return None; - } - if idx > 0 { - out.push('/'); - } - out.push_str(&component.as_os_str().to_string_lossy()); - } - if out.is_empty() { None } else { Some(out) } -} - -async fn apply_command_result( - app: &mut App, - engine_handle: &mut EngineHandle, - task_manager: &SharedTaskManager, - config: &mut Config, - result: commands::CommandResult, -) -> Result { - if let Some(msg) = result.message { - app.add_message(HistoryCell::System { content: msg }); - } - - if let Some(action) = result.action { - match action { - AppAction::Quit => { - let _ = engine_handle.send(Op::Shutdown).await; - return Ok(true); - } - AppAction::SaveSession(path) => { - app.status_message = Some(format!("Session saved to {}", path.display())); - } - AppAction::LoadSession(path) => { - app.status_message = Some(format!("Session loaded from {}", path.display())); - } - AppAction::SyncSession { - messages, - system_prompt, - model, - workspace, - } => { - let is_full_reset = messages.is_empty() && system_prompt.is_none(); - let _ = engine_handle - .send(Op::SyncSession { - messages, - system_prompt, - model, - workspace, - }) - .await; - let _ = engine_handle - .send(Op::SetCompaction { - config: app.compaction_config(), - }) - .await; - if is_full_reset { - persist_session_snapshot(app); - clear_checkpoint(); - } - } - AppAction::SendMessage(content) => { - let queued = build_queued_message(app, content); - submit_or_steer_message(app, engine_handle, queued).await?; - } - AppAction::Rlm { - prompt, - model, - child_model, - max_depth, - } => { - app.status_message = Some("RLM turn starting...".to_string()); - let _ = engine_handle - .send(Op::Rlm { - content: prompt, - model, - child_model, - max_depth, - }) - .await; - } - AppAction::ListSubAgents => { - let _ = engine_handle.send(Op::ListSubAgents).await; - } - AppAction::FetchModels => { - app.status_message = Some("Fetching models...".to_string()); - match fetch_available_models(config).await { - Ok(models) => { - app.add_message(HistoryCell::System { - content: format_available_models_message(&app.model, &models), - }); - app.status_message = Some(format!("Found {} model(s)", models.len())); - } - Err(error) => { - app.add_message(HistoryCell::System { - content: format!("Failed to fetch models: {error}"), - }); - } - } - } - AppAction::SwitchProvider { provider, model } => { - switch_provider(app, engine_handle, config, provider, model).await; - } - AppAction::UpdateCompaction(compaction) => { - apply_model_and_compaction_update(engine_handle, compaction).await; - } - AppAction::OpenConfigView => { - if app.view_stack.top_kind() != Some(ModalKind::Config) { - app.view_stack.push(ConfigView::new_for_app(app)); - } - } - AppAction::OpenModelPicker => { - if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) { - app.view_stack - .push(crate::tui::model_picker::ModelPickerView::new(app)); - } - } - AppAction::OpenProviderPicker => { - if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) { - app.view_stack - .push(crate::tui::provider_picker::ProviderPickerView::new( - app.api_provider, - config, - )); - } - } - AppAction::OpenStatusPicker => { - if app.view_stack.top_kind() != Some(ModalKind::StatusPicker) { - app.view_stack - .push(crate::tui::views::status_picker::StatusPickerView::new( - &app.status_items, - )); - } - } - AppAction::OpenContextInspector => { - open_context_inspector(app); - } - AppAction::CompactContext => { - app.status_message = Some("Compacting context...".to_string()); - let _ = engine_handle.send(Op::CompactContext).await; - } - AppAction::TaskAdd { prompt } => { - let request = NewTaskRequest { - prompt: prompt.clone(), - model: Some(app.model.clone()), - workspace: Some(app.workspace.clone()), - mode: Some(task_mode_label(app.mode).to_string()), - allow_shell: Some(app.allow_shell), - trust_mode: Some(app.trust_mode), - auto_approve: Some(app.approval_mode == ApprovalMode::Auto), - }; - match task_manager.add_task(request).await { - Ok(task) => { - app.add_message(HistoryCell::System { - content: format!( - "Task queued: {} ({})", - task.id, - summarize_tool_output(&task.prompt) - ), - }); - app.status_message = Some(format!("Queued {}", task.id)); - } - Err(err) => { - app.add_message(HistoryCell::System { - content: format!("Failed to queue task: {err}"), - }); - } - } - app.task_panel = task_manager - .list_tasks(Some(10)) - .await - .into_iter() - .map(task_summary_to_panel_entry) - .collect(); - } - AppAction::TaskList => { - let tasks = task_manager.list_tasks(Some(30)).await; - app.task_panel = tasks - .iter() - .cloned() - .map(task_summary_to_panel_entry) - .collect(); - app.add_message(HistoryCell::System { - content: format_task_list(&tasks), - }); - } - AppAction::TaskShow { id } => match task_manager.get_task(&id).await { - Ok(task) => open_task_pager(app, &task), - Err(err) => { - app.add_message(HistoryCell::System { - content: format!("Task lookup failed: {err}"), - }); - } - }, - AppAction::TaskCancel { id } => { - match task_manager.cancel_task(&id).await { - Ok(task) => { - app.add_message(HistoryCell::System { - content: format!("Task {} status: {:?}", task.id, task.status), - }); - } - Err(err) => { - app.add_message(HistoryCell::System { - content: format!("Task cancel failed: {err}"), - }); - } - } - app.task_panel = task_manager - .list_tasks(Some(10)) - .await - .into_iter() - .map(task_summary_to_panel_entry) - .collect(); - } - } - } - - Ok(false) -} - -async fn execute_command_input( - app: &mut App, - engine_handle: &mut EngineHandle, - task_manager: &SharedTaskManager, - config: &mut Config, - input: &str, -) -> Result { - let result = commands::execute(input, app); - apply_command_result(app, engine_handle, task_manager, config, result).await -} - -async fn steer_user_message( - app: &mut App, - engine_handle: &EngineHandle, - message: QueuedMessage, -) -> Result<()> { - let cwd = std::env::current_dir().ok(); - let references = crate::tui::file_mention::context_references_from_input( - &message.display, - &app.workspace, - cwd.clone(), - ); - let content = queued_message_content_for_app(app, &message, cwd); - let message_index = app.api_messages.len(); - - // Mirror steer input in local transcript/session state. - app.add_message(HistoryCell::User { - content: format!("+ {}", message.display), - }); - let history_cell = app.history.len().saturating_sub(1); - app.record_context_references(history_cell, message_index, references); - app.api_messages.push(Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: content.clone(), - cache_control: None, - }], - }); - - engine_handle.steer(content).await?; - app.status_message = Some("Steering current turn...".to_string()); - Ok(()) -} - -async fn submit_or_steer_message( - app: &mut App, - engine_handle: &EngineHandle, - message: QueuedMessage, -) -> Result<()> { - match app.decide_submit_disposition() { - SubmitDisposition::Immediate => dispatch_user_message(app, engine_handle, message).await, - SubmitDisposition::Queue => { - app.queue_message(message); - app.status_message = Some(format!( - "Offline mode: queued {} message(s) - /queue to review", - app.queued_message_count() - )); - Ok(()) - } - SubmitDisposition::Steer => { - if let Err(err) = steer_user_message(app, engine_handle, message.clone()).await { - app.queue_message(message); - app.status_message = Some(format!( - "Steer failed ({err}); queued {} message(s) - /queue to view/edit", - app.queued_message_count() - )); - } - Ok(()) - } - } -} - -/// Drain `app.pending_steers` into a single `QueuedMessage` ready for -/// `dispatch_user_message`. Returns `None` if the queue was empty (caller -/// then falls back to `app.queued_messages`). Skill instruction is taken -/// from the first message that supplies one — multiple steers shouldn't -/// double-up the system framing. -fn merge_pending_steers(app: &mut App) -> Option { - let drained = app.drain_pending_steers(); - if drained.is_empty() { - return None; - } - if drained.len() == 1 { - return drained.into_iter().next(); - } - let mut skill_instruction: Option = None; - let mut bodies: Vec = Vec::with_capacity(drained.len()); - for msg in drained { - if skill_instruction.is_none() { - skill_instruction = msg.skill_instruction; - } - bodies.push(msg.display); - } - Some(QueuedMessage::new(bodies.join("\n\n"), skill_instruction)) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PlanChoice { - AcceptAgent, - AcceptYolo, - RevisePlan, - ExitPlan, -} - -fn plan_next_step_prompt() -> String { - [ - "Action required: choose the next step for this plan.", - " 1) Accept + implement in Agent mode", - " 2) Accept + implement in YOLO mode", - " 3) Revise the plan / ask follow-ups", - " 4) Return to Agent mode without implementing", - "", - "Use the plan confirmation popup, or type 1-4 and press Enter.", - ] - .join("\n") -} - -fn plan_choice_from_option(option: usize) -> Option { - match option { - 1 => Some(PlanChoice::AcceptAgent), - 2 => Some(PlanChoice::AcceptYolo), - 3 => Some(PlanChoice::RevisePlan), - 4 => Some(PlanChoice::ExitPlan), - _ => None, - } -} - -fn parse_plan_choice(input: &str) -> Option { - // Once the modal is dismissed, only the advertised 1-4 fallback remains active. - // Letter shortcuts stay modal-only so normal messages like "yolo" are not captured. - match input.trim() { - "1" => Some(PlanChoice::AcceptAgent), - "2" => Some(PlanChoice::AcceptYolo), - "3" => Some(PlanChoice::RevisePlan), - "4" => Some(PlanChoice::ExitPlan), - _ => None, - } -} - -async fn apply_plan_choice( - app: &mut App, - engine_handle: &EngineHandle, - choice: PlanChoice, -) -> Result<()> { - match choice { - PlanChoice::AcceptAgent => { - app.set_mode(AppMode::Agent); - app.add_message(HistoryCell::System { - content: "Plan accepted. Switching to Agent mode and starting implementation." - .to_string(), - }); - let followup = QueuedMessage::new("Proceed with the accepted plan.".to_string(), None); - if app.is_loading { - app.queue_message(followup); - app.status_message = - Some("Queued accepted plan execution (agent mode).".to_string()); - } else { - dispatch_user_message(app, engine_handle, followup).await?; - } - } - PlanChoice::AcceptYolo => { - app.set_mode(AppMode::Yolo); - app.add_message(HistoryCell::System { - content: "Plan accepted. Switching to YOLO mode and starting implementation." - .to_string(), - }); - let followup = QueuedMessage::new("Proceed with the accepted plan.".to_string(), None); - if app.is_loading { - app.queue_message(followup); - app.status_message = - Some("Queued accepted plan execution (YOLO mode).".to_string()); - } else { - dispatch_user_message(app, engine_handle, followup).await?; - } - } - PlanChoice::RevisePlan => { - let prompt = "Revise the plan: "; - app.input = prompt.to_string(); - app.cursor_position = prompt.chars().count(); - app.status_message = Some("Revise the plan and press Enter.".to_string()); - } - PlanChoice::ExitPlan => { - app.set_mode(AppMode::Agent); - app.add_message(HistoryCell::System { - content: "Exited Plan mode. Switched to Agent mode.".to_string(), - }); - } - } - - Ok(()) -} - -async fn handle_plan_choice( - app: &mut App, - engine_handle: &EngineHandle, - input: &str, -) -> Result { - if !app.plan_prompt_pending { - return Ok(false); - } - - let choice = parse_plan_choice(input); - app.plan_prompt_pending = false; - - let Some(choice) = choice else { - return Ok(false); - }; - - apply_plan_choice(app, engine_handle, choice).await?; - 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: -/// - `pending_steers` — typed during a running turn + Esc; held until the -/// abort lands and gets resubmitted as a fresh merged turn. -/// - `rejected_steers` — engine declined a mid-turn steer (scaffolding; -/// no engine path produces these yet but the bucket renders identically). -/// - `queued_messages` — Enter while busy (offline-mode FIFO); drained at -/// end-of-turn. -fn build_pending_input_preview(app: &App) -> PendingInputPreview { - let mut preview = PendingInputPreview::new(); - preview.context_items = crate::tui::file_mention::pending_context_previews( - &app.input, - &app.workspace, - std::env::current_dir().ok(), - ) - .into_iter() - .map(|item| ContextPreviewItem { - kind: item.kind, - label: item.label, - detail: item.detail, - included: item.included, - }) - .collect(); - preview.pending_steers = app - .pending_steers - .iter() - .map(|m| m.display.clone()) - .collect(); - preview.rejected_steers = app.rejected_steers.iter().cloned().collect(); - preview.queued_messages = app - .queued_messages - .iter() - .map(|m| m.display.clone()) - .collect(); - preview -} - -fn render(f: &mut Frame, app: &mut App) { - let size = f.area(); - - // Clear entire area with background color - let background = Block::default().style(Style::default().bg(app.ui_theme.header_bg)); - f.render_widget(background, size); - - // Show onboarding screen if needed - if app.onboarding != OnboardingState::None { - onboarding::render(f, size, app); - return; - } - - let header_height = 1; - let footer_height = 1; - let body_height = size.height.saturating_sub(header_height + footer_height); - let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT); - let mention_menu_entries = - crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT); - if !mention_menu_entries.is_empty() && app.mention_menu_selected >= mention_menu_entries.len() { - app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1); - } - let context_usage = context_usage_snapshot(app); - let composer_max_height = body_height - .saturating_sub(MIN_CHAT_HEIGHT) - .max(MIN_COMPOSER_HEIGHT); - let composer_height = { - let composer_widget = ComposerWidget::new( - app, - composer_max_height, - &slash_menu_entries, - &mention_menu_entries, - ); - composer_widget.desired_height(size.width) - }; - - // Pending-input preview (queued / steered messages). Empty when nothing's - // queued, so zero height when idle. Phase 2 of #85 — solves the - // "messages typed during a running turn vanish" complaint by giving the - // user immediate visible feedback above the composer. - let pending_preview = build_pending_input_preview(app); - let preview_height = pending_preview.desired_height(size.width); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(header_height), // Header - Constraint::Min(1), // Chat area - Constraint::Length(preview_height), // Pending input preview (0 if empty) - Constraint::Length(composer_height), // Composer - Constraint::Length(footer_height), // Footer - ]) - .split(size); - - // Render header - { - let sanitized_context_window = context_usage - .as_ref() - .map(|(_, max, _)| *max) - .or_else(|| crate::models::context_window_for_model(&app.model)); - let sanitized_prompt_tokens = context_usage - .as_ref() - .and_then(|(used, _, _)| u32::try_from(*used).ok()); - let workspace_name = app - .workspace - .file_name() - .and_then(|value| value.to_str()) - .filter(|value| !value.is_empty()) - .unwrap_or("workspace"); - let effort_label = app.reasoning_effort.short_label(); - let provider_label = match app.api_provider { - crate::config::ApiProvider::Deepseek => None, - crate::config::ApiProvider::NvidiaNim => Some("NIM"), - crate::config::ApiProvider::Openrouter => Some("OR"), - crate::config::ApiProvider::Novita => Some("Novita"), - }; - let header_data = HeaderData::new( - app.mode, - &app.model, - workspace_name, - app.is_loading, - app.ui_theme.header_bg, - ) - .with_usage( - app.total_conversation_tokens, - sanitized_context_window, - app.session_cost, - sanitized_prompt_tokens, - ) - .with_reasoning_effort(Some(effort_label)) - .with_provider(provider_label); - let header_widget = HeaderWidget::new(header_data); - let buf = f.buffer_mut(); - header_widget.render(chunks[0], buf); - } - - // Render chat + sidebar - { - let mut chat_area = chunks[1]; - let mut sidebar_area = None; - - if chunks[1].width >= SIDEBAR_VISIBLE_MIN_WIDTH { - let preferred_sidebar = (u32::from(chunks[1].width) - * u32::from(app.sidebar_width_percent.clamp(10, 50)) - / 100) as u16; - let sidebar_width = preferred_sidebar - .max(24) - .min(chunks[1].width.saturating_sub(40)); - if sidebar_width >= 20 { - let split = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(1), Constraint::Length(sidebar_width)]) - .split(chunks[1]); - chat_area = split[0]; - sidebar_area = Some(split[1]); - } - } - - let chat_widget = ChatWidget::new(app, chat_area); - let buf = f.buffer_mut(); - chat_widget.render(chat_area, buf); - - if let Some(sidebar_area) = sidebar_area { - super::sidebar::render_sidebar(f, sidebar_area, app); - } - } - - // Render pending-input preview (queued/steered messages, if any). - if preview_height > 0 { - let buf = f.buffer_mut(); - pending_preview.render(chunks[2], buf); - } - - // Render composer - let cursor_pos = { - let composer_widget = ComposerWidget::new( - app, - composer_max_height, - &slash_menu_entries, - &mention_menu_entries, - ); - let buf = f.buffer_mut(); - composer_widget.render(chunks[3], buf); - composer_widget.cursor_pos(chunks[3]) - }; - if let Some(cursor_pos) = cursor_pos { - f.set_cursor_position(cursor_pos); - } - - // Render footer - render_footer(f, chunks[4], app); - - if !app.view_stack.is_empty() { - // The live transcript overlay snapshots the app's history + active - // cell on each render so streaming mutations propagate. Other views - // are static and skip this refresh. - if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) { - refresh_live_transcript_overlay(app); - } - let buf = f.buffer_mut(); - app.view_stack.render(size, buf); - } -} - -/// Pull the latest snapshot of cells / revisions / render options into the -/// live transcript overlay sitting on top of the view stack. No-op if the -/// top view isn't a `LiveTranscriptOverlay`. -fn refresh_live_transcript_overlay(app: &mut App) { - // Pop+push lets us hold &mut to the overlay while also borrowing `app` - // mutably for the snapshot — direct re-borrow through `view_stack` - // would otherwise alias `app`. - let Some(mut overlay) = app.view_stack.pop() else { - return; - }; - if let Some(typed) = overlay.as_any_mut().downcast_mut::() { - typed.refresh_from_app(app); - } - app.view_stack.push_boxed(overlay); -} - -/// Open the live transcript overlay in backtrack-preview mode (#133). -/// The overlay starts highlighting the most recent user message -/// (`selected_idx = 0`) and routes Left/Right/Enter/Esc through -/// `ViewEvent::Backtrack*` so the main key dispatcher can advance the -/// `BacktrackState` and apply the rewind on confirm. -fn open_backtrack_overlay(app: &mut App) { - let mut overlay = LiveTranscriptOverlay::new(); - overlay.refresh_from_app(app); - overlay.set_backtrack_preview(0); - app.view_stack.push(overlay); - app.status_message = - Some("Backtrack: \u{2190}/\u{2192} step Enter rewind Esc cancel".to_string()); - app.needs_redraw = true; -} - -/// Toggle the live transcript overlay on `Ctrl+T`. Closes the overlay if it's -/// already on top; otherwise pushes a fresh one in sticky-tail mode. -fn toggle_live_transcript_overlay(app: &mut App) { - if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) { - app.view_stack.pop(); - app.needs_redraw = true; - return; - } - let mut overlay = LiveTranscriptOverlay::new(); - overlay.refresh_from_app(app); - app.view_stack.push(overlay); - app.status_message = Some("Live transcript: tailing (Esc to close)".to_string()); - app.needs_redraw = true; -} - -async fn handle_view_events( - app: &mut App, - config: &mut Config, - task_manager: &SharedTaskManager, - engine_handle: &mut EngineHandle, - events: Vec, -) -> Result { - for event in events { - match event { - ViewEvent::CommandPaletteSelected { action } => match action { - crate::tui::views::CommandPaletteAction::ExecuteCommand { command } => { - if execute_command_input(app, engine_handle, task_manager, config, &command) - .await? - { - return Ok(true); - } - } - crate::tui::views::CommandPaletteAction::InsertText { text } => { - app.input = text; - app.cursor_position = app.input.chars().count(); - app.status_message = Some( - "Inserted into composer. Finish the input or press Enter.".to_string(), - ); - } - crate::tui::views::CommandPaletteAction::OpenTextPager { title, content } => { - open_text_pager(app, title, content); - } - }, - ViewEvent::OpenTextPager { title, content } => { - open_text_pager(app, title, content); - } - ViewEvent::ApprovalDecision { - tool_id, - tool_name, - decision, - timed_out, - approval_key, - } => { - if decision == ReviewDecision::ApprovedForSession { - // Store both the tool name (backward compat) and the - // approval key (fingerprint-based). - app.approval_session_approved.insert(tool_name.clone()); - app.approval_session_approved.insert(approval_key); - } - - match decision { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => { - let _ = engine_handle.approve_tool_call(tool_id).await; - } - ReviewDecision::Denied | ReviewDecision::Abort => { - let _ = engine_handle.deny_tool_call(tool_id).await; - } - } - - if timed_out { - app.add_message(HistoryCell::System { - content: "Approval request timed out - denied".to_string(), - }); - } - } - ViewEvent::ElevationDecision { - tool_id, - tool_name, - option, - } => { - use crate::tui::approval::ElevationOption; - match option { - ElevationOption::Abort => { - let _ = engine_handle.deny_tool_call(tool_id).await; - app.add_message(HistoryCell::System { - content: format!("Sandbox elevation aborted for {tool_name}"), - }); - } - ElevationOption::WithNetwork => { - app.add_message(HistoryCell::System { - content: format!("Retrying {tool_name} with network access enabled"), - }); - let policy = option.to_policy(&app.workspace); - let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await; - } - ElevationOption::WithWriteAccess(_) => { - app.add_message(HistoryCell::System { - content: format!("Retrying {tool_name} with write access enabled"), - }); - let policy = option.to_policy(&app.workspace); - let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await; - } - ElevationOption::FullAccess => { - app.add_message(HistoryCell::System { - content: format!("Retrying {tool_name} with full access (no sandbox)"), - }); - let policy = option.to_policy(&app.workspace); - let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await; - } - } - } - ViewEvent::UserInputSubmitted { tool_id, response } => { - let _ = engine_handle.submit_user_input(tool_id, response).await; - } - ViewEvent::UserInputCancelled { tool_id } => { - let _ = engine_handle.cancel_user_input(tool_id).await; - app.add_message(HistoryCell::System { - content: "User input cancelled".to_string(), - }); - } - ViewEvent::PlanPromptSelected { option } => { - if app.plan_prompt_pending { - app.plan_prompt_pending = false; - if let Some(choice) = plan_choice_from_option(option) - && let Err(err) = apply_plan_choice(app, engine_handle, choice).await - { - app.status_message = Some(format!("Failed to apply plan selection: {err}")); - } - } - } - ViewEvent::PlanPromptDismissed => { - app.plan_prompt_pending = true; - app.status_message = - Some("Plan prompt closed. Type 1-4 and press Enter to choose.".to_string()); - } - ViewEvent::SessionSelected { session_id } => { - let manager = match SessionManager::default_location() { - Ok(manager) => manager, - Err(err) => { - app.status_message = - Some(format!("Failed to open sessions directory: {err}")); - continue; - } - }; - - match manager.load_session(&session_id) { - Ok(session) => { - apply_loaded_session(app, &session); - let _ = engine_handle - .send(Op::SyncSession { - messages: app.api_messages.clone(), - system_prompt: app.system_prompt.clone(), - model: app.model.clone(), - workspace: app.workspace.clone(), - }) - .await; - let _ = engine_handle - .send(Op::SetCompaction { - config: app.compaction_config(), - }) - .await; - app.status_message = Some(format!( - "Session loaded (ID: {})", - &session_id[..8.min(session_id.len())] - )); - } - Err(err) => { - app.status_message = - Some(format!("Failed to load session {session_id}: {err}")); - } - } - } - ViewEvent::SessionDeleted { session_id, title } => { - app.status_message = Some(format!( - "Deleted session {} ({})", - &session_id[..8.min(session_id.len())], - title - )); - } - ViewEvent::ConfigUpdated { - key, - value, - persist, - } => { - let result = commands::set_config_value(app, &key, &value, persist); - if let Some(msg) = result.message { - app.add_message(HistoryCell::System { content: msg }); - } - - if let Some(action) = result.action { - match action { - AppAction::UpdateCompaction(compaction) => { - apply_model_and_compaction_update(engine_handle, compaction).await; - } - AppAction::OpenConfigView => {} - _ => {} - } - } - - if app.view_stack.top_kind() == Some(ModalKind::Config) { - app.view_stack.pop(); - app.view_stack.push(ConfigView::new_for_app(app)); - } - } - ViewEvent::StatusItemsUpdated { items, final_save } => { - // Apply to the live App immediately so the footer reflects - // every keystroke (live preview). - app.status_items = items.clone(); - app.needs_redraw = true; - if final_save { - match commands::persist_status_items(&items) { - Ok(path) => { - app.status_message = - Some(format!("Status line saved to {}", path.display())); - } - Err(err) => { - app.add_message(HistoryCell::System { - content: format!("Failed to save status line: {err}"), - }); - } - } - } - } - ViewEvent::SubAgentsRefresh => { - app.status_message = Some("Refreshing sub-agents...".to_string()); - let _ = engine_handle.send(Op::ListSubAgents).await; - } - ViewEvent::FilePickerSelected { path } => { - // Insert `@` at the composer's cursor with surrounding - // whitespace so the existing `@`-mention parser picks it up. - let cursor = app.cursor_position; - let needs_leading_space = cursor > 0 - && !app - .input - .chars() - .nth(cursor.saturating_sub(1)) - .is_some_and(|c| c.is_whitespace()); - let mut insertion = String::new(); - if needs_leading_space { - insertion.push(' '); - } - insertion.push('@'); - insertion.push_str(&path); - insertion.push(' '); - app.insert_str(&insertion); - app.status_message = Some(format!("Attached @{path}")); - } - ViewEvent::ModelPickerApplied { - model, - effort, - previous_model, - previous_effort, - } => { - apply_model_picker_choice( - app, - engine_handle, - model, - effort, - previous_model, - previous_effort, - ) - .await; - } - ViewEvent::ProviderPickerApplied { provider } => { - switch_provider(app, engine_handle, config, provider, None).await; - } - ViewEvent::ProviderPickerApiKeySubmitted { provider, api_key } => { - apply_provider_picker_api_key(app, engine_handle, config, provider, api_key).await; - } - ViewEvent::BacktrackStep { direction } => { - app.backtrack.step(direction); - if let Some(idx) = app.backtrack.selected_idx() { - update_backtrack_overlay_selection(app, idx); - } - } - ViewEvent::BacktrackConfirm => { - if let Some(depth) = app.backtrack.confirm() { - apply_backtrack(app, depth); - } - } - ViewEvent::BacktrackCancel => { - app.backtrack.reset(); - app.status_message = Some("Backtrack canceled".to_string()); - app.needs_redraw = true; - } - } - } - - Ok(false) -} - -/// Push the new `selected_idx` into the live transcript overlay so the -/// highlight follows the user's Left/Right input. No-op if the overlay is -/// no longer on top (e.g. it was closed underneath us). -fn update_backtrack_overlay_selection(app: &mut App, selected_idx: usize) { - if app.view_stack.top_kind() != Some(ModalKind::LiveTranscript) { - return; - } - let Some(mut overlay) = app.view_stack.pop() else { - return; - }; - if let Some(typed) = overlay.as_any_mut().downcast_mut::() { - typed.set_backtrack_preview(selected_idx); - } - app.view_stack.push_boxed(overlay); - app.needs_redraw = true; -} - -/// Count how many `HistoryCell::User` entries currently live in the -/// transcript. Used by the backtrack state machine to decide whether -/// there's anything to rewind to. Walks `app.history` directly so it -/// stays accurate even mid-stream (the streaming Assistant cell never -/// counts as a user turn). -fn count_user_history_cells(app: &App) -> usize { - app.history - .iter() - .filter(|cell| matches!(cell, HistoryCell::User { .. })) - .count() -} - -/// Find the absolute index of the Nth-from-tail `HistoryCell::User` in -/// `app.history`. `depth` of 0 selects the most recent user cell. -/// Returns `None` if `depth` is out of range. -fn find_user_cell_index_from_tail(app: &App, depth: usize) -> Option { - let mut count = 0usize; - for (idx, cell) in app.history.iter().enumerate().rev() { - if matches!(cell, HistoryCell::User { .. }) { - if count == depth { - return Some(idx); - } - count += 1; - } - } - None -} - -/// Apply the user's backtrack selection: trim `app.history` and -/// `app.api_messages` so everything from the chosen user message onward -/// is dropped, populate the composer with the dropped user text, close -/// the overlay, and surface a status hint. The cycle counter is bumped -/// so any persistent indices clear; the engine's in-flight context is -/// re-synced via `Op::SyncSession` so the next turn starts fresh. -fn apply_backtrack(app: &mut App, depth: usize) { - let Some(history_idx) = find_user_cell_index_from_tail(app, depth) else { - app.status_message = Some("Backtrack target no longer present".to_string()); - return; - }; - - // Snapshot the user text before truncating so we can refill the - // composer. - let user_text = match app.history.get(history_idx) { - Some(HistoryCell::User { content }) => content.clone(), - _ => String::new(), - }; - - // Trim the visible transcript at the chosen user cell. Per-cell - // revisions and tool-cell maps are kept consistent through - // `App::truncate_history_to`. - app.truncate_history_to(history_idx); - - // Trim the API-message log at the matching user message. We - // re-walk `api_messages` from the tail, counting role=="user" - // boundaries so the depth aligns with what the model sees on the - // next turn. - let mut user_seen = 0usize; - let mut cut = None; - for (idx, msg) in app.api_messages.iter().enumerate().rev() { - if msg.role == "user" { - if user_seen == depth { - cut = Some(idx); - break; - } - user_seen += 1; - } - } - if let Some(idx) = cut { - app.api_messages.truncate(idx); - } - - // Hand the dropped text back to the user so they can edit + resend. - app.input = user_text; - app.cursor_position = app.input.chars().count(); - - // Close the overlay, refresh sticky-tail flag, and surface a hint. - if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) { - app.view_stack.pop(); - } - app.status_message = - Some("Rewound to previous user message — edit and Enter to resend".to_string()); - app.scroll_to_bottom(); - app.mark_history_updated(); - app.needs_redraw = true; -} - -/// Persist the typed API key to `~/.deepseek/config.toml`, refresh the -/// in-memory config so the engine can see it, then switch to the provider. -async fn apply_provider_picker_api_key( - app: &mut App, - engine_handle: &mut EngineHandle, - config: &mut Config, - provider: ApiProvider, - api_key: String, -) { - use crate::config::{ProviderConfig, ProvidersConfig, save_api_key_for}; - - match save_api_key_for(provider, &api_key) { - Ok(path) => { - app.status_message = Some(format!( - "Saved {} API key to {}", - provider.as_str(), - path.display() - )); - } - Err(err) => { - app.add_message(HistoryCell::System { - content: format!( - "Failed to save {} API key: {err}\nProvider unchanged.", - provider.as_str() - ), - }); - return; - } - } - - // Mirror the saved key into the in-memory config so the engine sees it - // immediately without a reload — `save_api_key_for` only touches disk. - if matches!(provider, ApiProvider::Deepseek) { - config.api_key = Some(api_key); - } else { - let providers = config - .providers - .get_or_insert_with(ProvidersConfig::default); - let entry: &mut ProviderConfig = match provider { - ApiProvider::Deepseek => unreachable!(), - ApiProvider::NvidiaNim => &mut providers.nvidia_nim, - ApiProvider::Openrouter => &mut providers.openrouter, - ApiProvider::Novita => &mut providers.novita, - }; - entry.api_key = Some(api_key); - } - - switch_provider(app, engine_handle, config, provider, None).await; -} - -fn apply_loaded_session(app: &mut App, session: &SavedSession) { - app.api_messages.clone_from(&session.messages); - app.clear_history(); - app.tool_cells.clear(); - app.tool_details_by_cell.clear(); - app.active_cell = None; - app.active_tool_details.clear(); - app.active_cell_revision = app.active_cell_revision.wrapping_add(1); - app.exploring_cell = None; - app.exploring_entries.clear(); - app.ignored_tool_calls.clear(); - app.pending_tool_uses.clear(); - app.last_exec_wait_command = None; - - let messages = app.api_messages.clone(); - let mut message_to_cell = std::collections::HashMap::new(); - for (message_index, msg) in messages.iter().enumerate() { - let mut cells = history_cells_from_message(msg); - if msg.role == "user" - && session - .context_references - .iter() - .any(|record| record.message_index == message_index) - { - for cell in &mut cells { - if let HistoryCell::User { content } = cell { - *content = compact_user_context_display(content); - } - } - } - let base = app.history.len(); - if msg.role == "user" - && let Some(offset) = cells - .iter() - .position(|cell| matches!(cell, HistoryCell::User { .. })) - { - message_to_cell.insert(message_index, base + offset); - } - app.extend_history(cells); - } - app.sync_context_references_from_session(&session.context_references, &message_to_cell); - app.mark_history_updated(); - app.transcript_selection.clear(); - app.model.clone_from(&session.metadata.model); - app.update_model_compaction_budget(); - app.workspace.clone_from(&session.metadata.workspace); - app.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX); - app.total_conversation_tokens = app.total_tokens; - app.last_prompt_tokens = None; - app.last_completion_tokens = None; - app.last_prompt_cache_hit_tokens = None; - app.last_prompt_cache_miss_tokens = None; - app.current_session_id = Some(session.metadata.id.clone()); - app.workspace_context = None; - app.workspace_context_refreshed_at = None; - if let Some(sp) = session.system_prompt.as_ref() { - app.system_prompt = Some(SystemPrompt::Text(sp.clone())); - } else { - app.system_prompt = None; - } - app.scroll_to_bottom(); -} - -fn compact_user_context_display(content: &str) -> String { - content - .split("\n\n---\n\nLocal context from @mentions:") - .next() - .unwrap_or(content) - .to_string() -} - -fn refresh_workspace_context_if_needed(app: &mut App, now: Instant, allow_blocking_refresh: bool) { - if app - .workspace_context_refreshed_at - .is_some_and(|refreshed_at| { - now.duration_since(refreshed_at) < Duration::from_secs(WORKSPACE_CONTEXT_REFRESH_SECS) - }) - { - return; - } - - if !allow_blocking_refresh { - return; - } - - app.workspace_context = collect_workspace_context(&app.workspace); - app.workspace_context_refreshed_at = Some(now); -} - -#[derive(Debug, Default, Clone, Copy)] -struct WorkspaceChangeSummary { - staged: usize, - modified: usize, - untracked: usize, - conflicts: usize, -} - -impl WorkspaceChangeSummary { - fn is_clean(&self) -> bool { - self.staged == 0 && self.modified == 0 && self.untracked == 0 && self.conflicts == 0 - } -} - -fn collect_workspace_context(workspace: &Path) -> Option { - let branch = workspace_git_branch(workspace)?; - let summary = workspace_git_change_summary(workspace)?; - - let mut parts = Vec::new(); - if summary.staged > 0 { - parts.push(format!("{} staged", summary.staged)); - } - if summary.modified > 0 { - parts.push(format!("{} modified", summary.modified)); - } - if summary.untracked > 0 { - parts.push(format!("{} untracked", summary.untracked)); - } - if summary.conflicts > 0 { - parts.push(format!("{} conflicts", summary.conflicts)); - } - - let status = if summary.is_clean() { - "clean".to_string() - } else { - parts.join(", ") - }; - - Some(format!("{branch} | {status}")) -} - -fn workspace_git_branch(workspace: &Path) -> Option { - let branch = run_git_query(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?; - let branch = branch.trim().to_string(); - if branch == "HEAD" || branch.is_empty() { - let short_hash = run_git_query(workspace, &["rev-parse", "--short", "HEAD"]).ok()?; - let short_hash = short_hash.trim(); - if short_hash.is_empty() { - return None; - } - return Some(format!("detached:{short_hash}")); - } - Some(branch) -} - -fn workspace_git_change_summary(workspace: &Path) -> Option { - let status = run_git_query( - workspace, - &["status", "--short", "--untracked-files=normal"], - ) - .ok()?; - - if status.trim().is_empty() { - return Some(WorkspaceChangeSummary::default()); - } - - let mut summary = WorkspaceChangeSummary::default(); - for line in status.lines() { - if line.trim().is_empty() { - continue; - } - - let mut chars = line.chars(); - let staged = chars.next()?; - let modified = chars.next().unwrap_or(' '); - - if staged == ' ' && modified == ' ' { - continue; - } - if staged == '?' && modified == '?' { - summary.untracked = summary.untracked.saturating_add(1); - continue; - } - - if staged == 'U' || modified == 'U' { - summary.conflicts = summary.conflicts.saturating_add(1); - } - if staged != ' ' && staged != '?' { - summary.staged = summary.staged.saturating_add(1); - } - if modified != ' ' && modified != '?' { - summary.modified = summary.modified.saturating_add(1); - } - } - - Some(summary) -} - -fn run_git_query(workspace: &Path, args: &[&str]) -> std::io::Result { - let output = Command::new("git") - .args(args) - .current_dir(workspace) - .output()?; - if !output.status.success() { - return Err(std::io::Error::other("git command failed")); - } - Ok(String::from_utf8_lossy(&output.stdout).to_string()) -} - -fn pause_terminal( - terminal: &mut Terminal>, - use_alt_screen: bool, - use_mouse_capture: bool, - use_bracketed_paste: bool, -) -> Result<()> { - disable_raw_mode()?; - if use_alt_screen { - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - } - if use_mouse_capture { - execute!(terminal.backend_mut(), DisableMouseCapture)?; - } - if use_bracketed_paste { - execute!(terminal.backend_mut(), DisableBracketedPaste)?; - } - Ok(()) -} - -fn resume_terminal( - terminal: &mut Terminal>, - use_alt_screen: bool, - use_mouse_capture: bool, - use_bracketed_paste: bool, -) -> Result<()> { - enable_raw_mode()?; - if use_alt_screen { - execute!(terminal.backend_mut(), EnterAlternateScreen)?; - } - if use_mouse_capture { - execute!(terminal.backend_mut(), EnableMouseCapture)?; - } - if use_bracketed_paste { - execute!(terminal.backend_mut(), EnableBracketedPaste)?; - } - terminal.clear()?; - Ok(()) -} - -fn status_color(level: StatusToastLevel) -> ratatui::style::Color { - match level { - StatusToastLevel::Info => palette::DEEPSEEK_SKY, - StatusToastLevel::Success => palette::STATUS_SUCCESS, - StatusToastLevel::Warning => palette::STATUS_WARNING, - StatusToastLevel::Error => palette::STATUS_ERROR, - } -} - -fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { - if area.width == 0 || area.height == 0 { - return; - } - - // Pull in the toast first so we don't re-borrow `app` mutably mid-build, - // then build the FooterProps once. The widget itself is a pure render — - // it owns no `App` knowledge; all width-aware layout lives in the widget. - // - // The quit-confirmation prompt takes precedence over normal status toasts - // because it represents a transient instruction the user must respond to - // within ~2s. Mirrors codex-rs's `FooterMode::QuitShortcutReminder`. - let quit_prompt = if app.quit_is_armed() { - Some(FooterToast { - text: "Press Ctrl+C again to quit".to_string(), - color: palette::STATUS_WARNING, - }) - } else { - None - }; - let toast = quit_prompt.or_else(|| { - app.active_status_toast().map(|toast| FooterToast { - text: toast.text, - color: status_color(toast.level), - }) - }); - - // Drive every cluster from the user's configured `status_items`. Mode - // and Model are always rendered by `FooterProps` itself (their position - // is structural — cluster gating is handled by the widget), so we only - // gate the optional clusters here. If a variant is missing from - // `status_items`, its span vec stays empty and the footer hides it. - let mut props = render_footer_from(app, &app.status_items, toast); - // FooterProps is mut so the working-strip animation can layer on top. - - // Animate the spacer between the left status line and the right-hand - // chips whenever a turn is live: model loading/streaming, compacting, or - // sub-agents in flight. Honors the `low_motion` setting — calm terminals - // get the plain whitespace gap. Strip frame counter ticks every 150 ms - // (crest A advances every 4 ticks ≈ 600 ms, B every 6 ticks ≈ 900 ms, - // jitter every 17 ticks ≈ 2.5 s). Dot-pulse counter ticks every 400 ms - // so `working` → `working...` reads at a calm pace. - if footer_working_strip_active(app) { - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - let dot_frame = now_ms / 400; - // Surface one compact live status row in the footer whenever a turn - // is live. Tool turns get the current action plus active/done counts; - // non-tool work falls back to the existing dot-pulse label. - props.state_label = active_tool_status_label(app) - .unwrap_or_else(|| crate::tui::widgets::footer_working_label(dot_frame)); - props.state_color = palette::DEEPSEEK_SKY; - - // Spout drift: only animate when low_motion is off. The textual - // `working...` pulse stays even in low-motion mode so the user still - // sees that something is happening. - if !app.low_motion { - let strip_frame = now_ms / 150; - props.working_strip_frame = Some(strip_frame); - } - } else if props.state_label == "ready" - && let Some(label) = selected_detail_footer_label(app) - { - props.state_label = label; - props.state_color = palette::TEXT_MUTED; - } - - let widget = FooterWidget::new(props); - let buf = f.buffer_mut(); - widget.render(area, buf); -} - -/// Whether the footer should animate the water-spout strip. Driven by the -/// underlying live-work flags so the strip stays visible for the *entire* -/// turn — not just the moments where bytes are streaming. `is_loading` can -/// flicker off between LLM rounds within a single turn (tool execution, -/// reasoning replay, capacity refresh, etc.), so we ALSO gate on the turn -/// itself still being in flight via `runtime_turn_status == "in_progress"`. -/// Without that, the user sees the strip vanish for seconds at a time even -/// though the agent is still working. -fn footer_working_strip_active(app: &App) -> bool { - let turn_in_progress = app.runtime_turn_status.as_deref() == Some("in_progress"); - app.is_loading || app.is_compacting || running_agent_count(app) > 0 || turn_in_progress -} - -#[derive(Default)] -struct ActiveToolStatusSnapshot { - primary_running: Option, - primary_any: Option, - running: usize, - completed: usize, - started_at: Option, -} - -impl ActiveToolStatusSnapshot { - fn record(&mut self, label: String, status: ToolStatus, started_at: Option) { - if self.primary_any.is_none() { - self.primary_any = Some(label.clone()); - } - if status == ToolStatus::Running { - self.running += 1; - if self.primary_running.is_none() { - self.primary_running = Some(label); - } - } else { - self.completed += 1; - } - if let Some(started) = started_at { - self.started_at = Some(match self.started_at { - Some(current) => current.min(started), - None => started, - }); - } - } - - fn total(&self) -> usize { - self.running + self.completed - } -} - -fn active_tool_status_label(app: &App) -> Option { - let active = app.active_cell.as_ref()?; - if active.is_empty() { - return None; - } - - let mut snapshot = ActiveToolStatusSnapshot::default(); - for cell in active.entries() { - collect_active_tool_status(cell, &mut snapshot); - } - if snapshot.total() == 0 { - return None; - } - - let primary = snapshot - .primary_running - .or(snapshot.primary_any) - .unwrap_or_else(|| "tools".to_string()); - let primary = truncate_line_to_width(&primary, 30); - let elapsed = snapshot - .started_at - .or(app.turn_started_at) - .map(|started| format!("{}s", started.elapsed().as_secs())); - - let mut parts = vec![ - primary, - format!("{} active", snapshot.running), - format!("{} done", snapshot.completed), - ]; - if let Some(elapsed) = elapsed { - parts.push(elapsed); - } - parts.push("Alt+V".to_string()); - Some(parts.join(" \u{00B7} ")) -} - -fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatusSnapshot) { - let HistoryCell::Tool(tool) = cell else { - return; - }; - match tool { - ToolCell::Exec(exec) => snapshot.record( - format!("run {}", one_line_summary(&exec.command, 80)), - exec.status, - exec.started_at, - ), - ToolCell::Exploring(explore) => { - for entry in &explore.entries { - snapshot.record( - format!("read {}", one_line_summary(&entry.label, 80)), - entry.status, - None, - ); - } - } - ToolCell::PlanUpdate(plan) => { - snapshot.record("update plan".to_string(), plan.status, None); - } - ToolCell::PatchSummary(patch) => { - snapshot.record(format!("patch {}", patch.path), patch.status, None); - } - ToolCell::Review(review) => { - let target = one_line_summary(&review.target, 80); - let label = if target.is_empty() { - "review".to_string() - } else { - format!("review {target}") - }; - snapshot.record(label, review.status, None); - } - ToolCell::DiffPreview(diff) => { - snapshot.record(format!("diff {}", diff.title), ToolStatus::Success, None); - } - ToolCell::Mcp(mcp) => snapshot.record(format!("tool {}", mcp.tool), mcp.status, None), - ToolCell::ViewImage(image) => snapshot.record( - format!("image {}", image.path.display()), - ToolStatus::Success, - None, - ), - ToolCell::WebSearch(search) => { - snapshot.record(format!("search {}", search.query), search.status, None); - } - ToolCell::Generic(generic) => { - snapshot.record(format!("tool {}", generic.name), generic.status, None); - } - } -} - -fn one_line_summary(text: &str, max_width: usize) -> String { - truncate_line_to_width( - &text.split_whitespace().collect::>().join(" "), - max_width, - ) -} - -/// Build [`FooterProps`] from a user-configured `status_items` slice. -/// -/// Variants are routed to their structural cluster: `Mode` and `Model` are -/// always emitted (the widget needs them to lay out the line correctly even -/// when the user toggled them off the picker — we honour the toggle by -/// blanking their visible content rather than collapsing the layout). -/// `Cost` and `Status` belong in the left cluster; the rest in the right. -/// -/// A variant absent from `items` produces an empty span vec, which the -/// footer widget already hides cleanly. This keeps the renderer fully -/// data-driven without changing `FooterProps`'s public shape. -fn render_footer_from( - app: &App, - items: &[crate::config::StatusItem], - toast: Option, -) -> FooterProps { - use crate::config::StatusItem as S; - let has = |item: S| items.contains(&item); - - let (state_label, state_color) = if has(S::Status) { - footer_state_label(app) - } else { - // "ready" is the sentinel the widget uses to skip the status segment; - // pair it with TEXT_MUTED for visual neutrality. - ("ready", palette::TEXT_MUTED) - }; - - let coherence = if has(S::Coherence) { - footer_coherence_spans(app) - } else { - Vec::new() - }; - let agents = if has(S::Agents) { - crate::tui::widgets::footer_agents_chip(running_agent_count(app)) - } else { - Vec::new() - }; - let reasoning_replay = if has(S::ReasoningReplay) { - footer_reasoning_replay_spans(app) - } else { - Vec::new() - }; - let cache = if has(S::Cache) { - footer_cache_spans(app) - } else { - Vec::new() - }; - let cost = if has(S::Cost) && app.session_cost > 0.001 { - vec![Span::styled( - format!("${:.2}", app.session_cost), - Style::default().fg(palette::TEXT_MUTED), - )] - } else { - Vec::new() - }; - - // Build the props; `Mode` and `Model` toggles modulate downstream by - // blanking the rendered text rather than restructuring the widget — the - // user is opting out of the chip, not destroying the bar. - let mut props = FooterProps::from_app( - app, - toast, - state_label, - state_color, - coherence, - agents, - reasoning_replay, - cache, - cost, - ); - if !has(S::Mode) { - props.mode_label = ""; - } - if !has(S::Model) { - props.model.clear(); - } - - // Right-cluster extension chips: append in `items` order so user - // ordering is preserved across the new variants. - let mut extra: Vec> = Vec::new(); - for item in items { - let chip = match *item { - S::ContextPercent => footer_context_percent_spans(app), - S::GitBranch | S::LastToolElapsed | S::RateLimit => Vec::new(), - _ => continue, - }; - if chip.is_empty() { - continue; - } - if !extra.is_empty() { - extra.push(Span::raw(" ")); - } - extra.extend(chip); - } - if !extra.is_empty() { - // Stack into the cache slot — last existing right-cluster pipe — so - // they appear adjacent without changing FooterProps's API. Keep - // existing cache spans first so cache hit rate stays before the - // user-added extras. - if !props.cache.is_empty() { - props.cache.push(Span::raw(" ")); - } - props.cache.extend(extra); - } - - props -} - -/// Spans for the "context %" footer chip. Mirrors the header colour ramp so -/// the two surfaces stay visually consistent when both are enabled. -fn footer_context_percent_spans(app: &App) -> Vec> { - let Some((_, _, percent)) = context_usage_snapshot(app) else { - return Vec::new(); - }; - let color = if percent >= 95.0 { - palette::STATUS_ERROR - } else if percent >= 85.0 { - palette::STATUS_WARNING - } else { - palette::TEXT_MUTED - }; - vec![Span::styled( - format!("ctx {percent:.0}%"), - Style::default().fg(color), - )] -} - -/// Test-only helper retained as a parity reference for `FooterWidget`'s -/// auxiliary-span composition. Production rendering is performed by the -/// widget itself; the existing footer parity tests still exercise this -/// function directly to guard against drift. -#[allow(dead_code)] -fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec> { - // Context % is already shown in the header signal bar — don't - // duplicate it in the footer. The footer carries unique info only: - // coherence, in-flight sub-agents, reasoning replay tokens, cache hit - // rate, and session cost. - let coherence_spans = footer_coherence_spans(app); - let agents_spans = crate::tui::widgets::footer_agents_chip(running_agent_count(app)); - let replay_spans = footer_reasoning_replay_spans(app); - let cache_spans = footer_cache_spans(app); - let cost_spans = if app.session_cost > 0.001 { - vec![Span::styled( - format!("${:.2}", app.session_cost), - Style::default().fg(palette::TEXT_MUTED), - )] - } else { - Vec::new() - }; - - let parts: Vec<&Vec>> = [ - &coherence_spans, - &agents_spans, - &replay_spans, - &cache_spans, - &cost_spans, - ] - .iter() - .filter(|spans| !spans.is_empty()) - .copied() - .collect(); - - // Try to fit as many parts as possible, dropping from the end. - for end in (0..=parts.len()).rev() { - let mut combined = Vec::new(); - for (i, part) in parts[..end].iter().enumerate() { - if i > 0 { - combined.push(Span::raw(" ")); - } - combined.extend(part.iter().cloned()); - } - if spans_width(&combined) <= max_width { - return combined; - } - } - Vec::new() -} - -fn footer_coherence_spans(app: &App) -> Vec> { - // Only surface coherence when the engine is actively intervening — the - // user-facing signal is "we're doing something different now," not - // "your conversation is getting complex," which the context-percent - // header already covers. `GettingCrowded` is just a soft hint, so we - // suppress it; the active interventions get their own visible label. - let (label, color) = match app.coherence_state { - CoherenceState::Healthy | CoherenceState::GettingCrowded => return Vec::new(), - CoherenceState::RefreshingContext => ("refreshing context", palette::STATUS_WARNING), - CoherenceState::VerifyingRecentWork => ("verifying", palette::DEEPSEEK_SKY), - CoherenceState::ResettingPlan => ("resetting plan", palette::STATUS_ERROR), - }; - - vec![Span::styled(label.to_string(), Style::default().fg(color))] -} - -fn footer_cache_spans(app: &App) -> Vec> { - let Some(hit_tokens) = app.last_prompt_cache_hit_tokens else { - return Vec::new(); - }; - let miss_tokens = app.last_prompt_cache_miss_tokens.unwrap_or(0); - let total = hit_tokens.saturating_add(miss_tokens); - if total == 0 { - return Vec::new(); - } - - let percent = (f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0); - vec![Span::styled( - format!("cache {:.0}%", percent), - Style::default().fg(palette::TEXT_MUTED), - )] -} - -/// Render a footer chip showing the size of the `reasoning_content` block -/// replayed on the most recent thinking-mode tool-calling turn (#30). -/// -/// Stays hidden when the count is zero (non-thinking models, first turn, or -/// turns with no tool calls). When replay tokens dominate the input budget -/// (>50%), the chip turns warning-coloured so users notice that thinking -/// replay is the main consumer of context. -fn footer_reasoning_replay_spans(app: &App) -> Vec> { - let Some(replay) = app.last_reasoning_replay_tokens else { - return Vec::new(); - }; - if replay == 0 { - return Vec::new(); - } - let label = format!("rsn {}", format_token_count_compact(u64::from(replay))); - let color = match app.last_prompt_tokens { - Some(input) if input > 0 && f64::from(replay) / f64::from(input) > 0.5 => { - palette::STATUS_WARNING - } - _ => palette::TEXT_MUTED, - }; - vec![Span::styled(label, Style::default().fg(color))] -} - -#[allow(dead_code)] -fn footer_toast_spans( - toast: &crate::tui::app::StatusToast, - max_width: usize, -) -> Vec> { - let truncated = truncate_line_to_width(&toast.text, max_width.max(1)); - vec![Span::styled( - truncated, - Style::default().fg(status_color(toast.level)), - )] -} - -#[allow(dead_code)] -fn footer_status_line_spans(app: &App, max_width: usize) -> Vec> { - if max_width == 0 { - return Vec::new(); - } - - let (mode_label, mode_color) = footer_mode_style(app); - let (status_label, status_color) = footer_state_label(app); - let sep = " \u{00B7} "; - let show_status = status_label != "ready"; - - let fixed_width = mode_label.width() - + sep.width() - + if show_status { - sep.width() + status_label.width() - } else { - 0 - }; - - if max_width <= mode_label.width() { - return vec![Span::styled( - truncate_line_to_width(mode_label, max_width), - Style::default().fg(mode_color), - )]; - } - - let model_budget = max_width.saturating_sub(fixed_width).max(1); - let model_label = truncate_line_to_width(&app.model, model_budget); - - let mut spans = vec![ - Span::styled(mode_label.to_string(), Style::default().fg(mode_color)), - Span::styled(sep.to_string(), Style::default().fg(palette::TEXT_DIM)), - Span::styled(model_label, Style::default().fg(palette::TEXT_HINT)), - ]; - - if show_status { - spans.push(Span::styled( - sep.to_string(), - Style::default().fg(palette::TEXT_DIM), - )); - spans.push(Span::styled( - status_label.to_string(), - Style::default().fg(status_color), - )); - } - - spans -} - -fn footer_state_label(app: &App) -> (&'static str, ratatui::style::Color) { - if app.is_compacting { - return ("compacting \u{238B}", palette::STATUS_WARNING); - } - // Note: we deliberately do NOT show a "thinking" label for `is_loading`. - // The animated water-spout strip in the footer's spacer is the visual - // signal that the model is live; "thinking" was misleading because it - // fired for every kind of in-flight work (tool calls, streaming, etc.), - // not strictly reasoning. Sub-agents still surface "working" because - // that's a distinct lifecycle the user can act on (open `/agents`). - if running_agent_count(app) > 0 { - return ("working", palette::DEEPSEEK_SKY); - } - if app.queued_draft.is_some() { - return ("draft", palette::TEXT_MUTED); - } - - if !app.view_stack.is_empty() { - return ("overlay", palette::TEXT_MUTED); - } - - if !app.input.is_empty() { - return ("draft", palette::TEXT_MUTED); - } - - ("ready", palette::TEXT_MUTED) -} - -#[allow(dead_code)] -fn footer_mode_style(app: &App) -> (&'static str, ratatui::style::Color) { - let label = app.mode.as_setting(); - let color = match app.mode { - crate::tui::app::AppMode::Agent => palette::MODE_AGENT, - crate::tui::app::AppMode::Yolo => palette::MODE_YOLO, - crate::tui::app::AppMode::Plan => palette::MODE_PLAN, - }; - (label, color) -} - -fn format_token_count_compact(tokens: u64) -> String { - if tokens >= 1_000_000 { - format!("{:.1}M", tokens as f64 / 1_000_000.0) - } else if tokens >= 1_000 { - format!("{:.1}k", tokens as f64 / 1_000.0) - } else { - tokens.to_string() - } -} - -#[allow(dead_code)] -fn format_context_budget(used: i64, max: u32) -> String { - let max_u64 = u64::from(max); - let max_i64 = i64::from(max); - - if used > max_i64 { - return format!( - ">{}/{}", - format_token_count_compact(max_u64), - format_token_count_compact(max_u64) - ); - } - - let used_u64 = u64::try_from(used.max(0)).unwrap_or(0); - format!( - "{}/{}", - format_token_count_compact(used_u64), - format_token_count_compact(max_u64) - ) -} - -#[allow(dead_code)] -fn spans_width(spans: &[Span<'_>]) -> usize { - spans.iter().map(|span| span.content.width()).sum() -} - -#[allow(dead_code)] -fn transcript_scroll_percent(top: usize, visible: usize, total: usize) -> Option { - if total <= visible { - return None; - } - - let max_top = total.saturating_sub(visible); - if max_top == 0 { - return None; - } - - let clamped_top = top.min(max_top); - let percent = ((clamped_top as f64 / max_top as f64) * 100.0).round() as u16; - Some(percent.min(100)) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SearchDirection { - Forward, - Backward, -} - -fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool { - let line_meta = app.transcript_cache.line_meta(); - if line_meta.is_empty() { - return false; - } - - let top = app - .last_transcript_top - .min(line_meta.len().saturating_sub(1)); - let current_cell = line_meta - .get(top) - .and_then(crate::tui::scrolling::TranscriptLineMeta::cell_line) - .map(|(cell_index, _)| cell_index); - - let mut scan_indices = Vec::new(); - match direction { - SearchDirection::Forward => { - scan_indices.extend((top.saturating_add(1))..line_meta.len()); - } - SearchDirection::Backward => { - scan_indices.extend((0..top).rev()); - } - } - - for idx in scan_indices { - let Some((cell_index, _)) = line_meta[idx].cell_line() else { - continue; - }; - if current_cell.is_some_and(|current| current == cell_index) { - continue; - } - if !matches!(app.history.get(cell_index), Some(HistoryCell::Tool(_))) { - continue; - } - if let Some(anchor) = TranscriptScroll::anchor_for(line_meta, idx) { - app.transcript_scroll = anchor; - app.pending_scroll_delta = 0; - app.needs_redraw = true; - return true; - } - } - - false -} - -fn estimated_context_tokens(app: &App) -> Option { - i64::try_from(estimate_input_tokens_conservative( - &app.api_messages, - app.system_prompt.as_ref(), - )) - .ok() -} - -fn context_usage_snapshot(app: &App) -> Option<(i64, u32, f64)> { - let max = context_window_for_model(&app.model)?; - let max_i64 = i64::from(max); - let reported = app - .last_prompt_tokens - .map(i64::from) - .map(|tokens| tokens.max(0)); - let estimated = estimated_context_tokens(app).map(|tokens| tokens.max(0)); - - // Always prefer the estimated current-context size (computed from - // `app.api_messages`) when we have it. Reported `last_prompt_tokens` - // comes from `Event::TurnComplete.usage`, which the engine builds with - // `turn.add_usage` — that SUMS input_tokens across every round in the - // turn, so a multi-round tool-call turn reports a value much larger - // than the actual context window state, then the next single-round - // turn drops back to a single round's input_tokens. User-visible % - // was bouncing 31% → 9% (#115) because of this. The estimate is - // monotonic wrt conversation growth, which is what a "context filling - // up" indicator should show. We still consult `reported` only as a - // fallback when no estimate is available (e.g., immediately after a - // session restore before the api_messages are populated). - let used = match (estimated, reported) { - (Some(estimated), _) => estimated.min(max_i64), - (None, Some(reported)) => reported.min(max_i64), - (None, None) => return None, - }; - - let max_f64 = f64::from(max); - let used_f64 = used as f64; - let percent = ((used_f64 / max_f64) * 100.0).clamp(0.0, 100.0); - Some((used, max, percent)) -} - -/// Retained as a callable utility — `context_usage_snapshot` no longer uses -/// it directly (#115 makes the estimate the primary signal), but tests in -/// `ui/tests.rs` still exercise it and a future heuristic may want to -/// distinguish "obviously inflated reported tokens" from healthy reports. -#[allow(dead_code)] -fn is_reported_context_inflated(reported: i64, estimated: i64) -> bool { - const MIN_ABSOLUTE_GAP: i64 = 4_096; - if estimated <= 0 || reported <= estimated { - return false; - } - - reported.saturating_sub(estimated) >= MIN_ABSOLUTE_GAP - && reported >= estimated.saturating_mul(4) -} - -fn maybe_warn_context_pressure(app: &mut App) { - let Some((used, max, percent)) = context_usage_snapshot(app) else { - return; - }; - - if percent < CONTEXT_WARNING_THRESHOLD_PERCENT { - return; - } - - let recommendation = if app.auto_compact { - "Auto-compaction is enabled." - } else { - "Consider /compact or /clear." - }; - - if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT { - app.status_message = Some(format!( - "Context critical: {:.0}% ({used}/{max} tokens). {recommendation}", - percent - )); - return; - } - - if app.status_message.is_none() { - app.status_message = Some(format!( - "Context high: {:.0}% ({used}/{max} tokens). {recommendation}", - percent - )); - } -} - -fn should_auto_compact_before_send(app: &App) -> bool { - if !app.auto_compact { - return false; - } - context_usage_snapshot(app) - .map(|(_, _, pct)| pct >= CONTEXT_CRITICAL_THRESHOLD_PERCENT) - .unwrap_or(false) -} - -fn status_animation_interval_ms(app: &App) -> u64 { - if app.low_motion { - 2_400 - } else { - UI_STATUS_ANIMATION_MS - } -} - -fn active_poll_ms(app: &App) -> u64 { - if app.low_motion { - 96 - } else { - UI_ACTIVE_POLL_MS - } -} - -fn idle_poll_ms(app: &App) -> u64 { - if app.low_motion { 120 } else { UI_IDLE_POLL_MS } -} - -fn history_has_live_motion(history: &[HistoryCell]) -> bool { - use crate::tui::history::SubAgentCell; - use crate::tui::widgets::agent_card::AgentLifecycle; - history.iter().any(|cell| match cell { - HistoryCell::Thinking { streaming, .. } => *streaming, - HistoryCell::Tool(tool) => match tool { - ToolCell::Exec(cell) => cell.status == ToolStatus::Running, - ToolCell::Exploring(cell) => cell - .entries - .iter() - .any(|entry| entry.status == ToolStatus::Running), - ToolCell::PlanUpdate(cell) => cell.status == ToolStatus::Running, - ToolCell::PatchSummary(cell) => cell.status == ToolStatus::Running, - ToolCell::Review(cell) => cell.status == ToolStatus::Running, - ToolCell::DiffPreview(_) => false, - ToolCell::Mcp(cell) => cell.status == ToolStatus::Running, - ToolCell::ViewImage(_) => false, - ToolCell::WebSearch(cell) => cell.status == ToolStatus::Running, - ToolCell::Generic(cell) => cell.status == ToolStatus::Running, - }, - HistoryCell::SubAgent(SubAgentCell::Delegate(card)) => matches!( - card.status, - AgentLifecycle::Pending | AgentLifecycle::Running - ), - HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => card - .workers - .iter() - .any(|w| matches!(w.status, AgentLifecycle::Pending | AgentLifecycle::Running)), - _ => false, - }) -} - -pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String { - if max_width == 0 { - return String::new(); - } - if UnicodeWidthStr::width(text) <= max_width { - return text.to_string(); - } - // For very small budgets, take chars until we exceed the *display* width. - // Counting characters instead of widths (the previous behavior) overran - // the budget for any double-width grapheme and contributed to mid-character - // sidebar artifacts on resize (issue #65). - if max_width <= 3 { - let mut out = String::new(); - let mut width = 0usize; - for ch in text.chars() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if width + ch_width > max_width { - break; - } - out.push(ch); - width += ch_width; - } - return out; - } - - let mut out = String::new(); - let mut width = 0usize; - let limit = max_width.saturating_sub(3); - for ch in text.chars() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if width + ch_width > limit { - break; - } - out.push(ch); - width += ch_width; - } - out.push_str("..."); - out -} - -fn handle_mouse_event(app: &mut App, mouse: MouseEvent) { - match mouse.kind { - MouseEventKind::ScrollUp => { - let update = app.mouse_scroll.on_scroll(ScrollDirection::Up); - app.pending_scroll_delta += update.delta_lines; - } - MouseEventKind::ScrollDown => { - let update = app.mouse_scroll.on_scroll(ScrollDirection::Down); - app.pending_scroll_delta += update.delta_lines; - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(point) = selection_point_from_mouse(app, mouse) { - app.transcript_selection.anchor = Some(point); - app.transcript_selection.head = Some(point); - app.transcript_selection.dragging = true; - - if app.is_loading - && app.transcript_scroll.is_at_tail() - && let Some(anchor) = TranscriptScroll::anchor_for( - app.transcript_cache.line_meta(), - app.last_transcript_top, - ) - { - app.transcript_scroll = anchor; - } - } else if app.transcript_selection.is_active() { - app.transcript_selection.clear(); - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if app.transcript_selection.dragging - && let Some(point) = selection_point_from_mouse(app, mouse) - { - app.transcript_selection.head = Some(point); - } - } - MouseEventKind::Up(MouseButton::Left) if app.transcript_selection.dragging => { - app.transcript_selection.dragging = false; - if selection_has_content(app) { - copy_active_selection(app); - } - } - _ => {} - } -} - -fn selection_point_from_mouse(app: &App, mouse: MouseEvent) -> Option { - selection_point_from_position( - app.last_transcript_area?, - mouse.column, - mouse.row, - app.last_transcript_top, - app.last_transcript_total, - app.last_transcript_padding_top, - ) -} - -fn selection_point_from_position( - area: Rect, - column: u16, - row: u16, - transcript_top: usize, - transcript_total: usize, - padding_top: usize, -) -> Option { - if column < area.x - || column >= area.x + area.width - || row < area.y - || row >= area.y + area.height - { - return None; - } - - if transcript_total == 0 { - return None; - } - - let row = row.saturating_sub(area.y) as usize; - if row < padding_top { - return None; - } - let row = row.saturating_sub(padding_top); - - let col = column.saturating_sub(area.x) as usize; - let line_index = transcript_top - .saturating_add(row) - .min(transcript_total.saturating_sub(1)); - - Some(TranscriptSelectionPoint { - line_index, - column: col, - }) -} - -fn selection_has_content(app: &App) -> bool { - match app.transcript_selection.ordered_endpoints() { - Some((start, end)) => start != end, - None => false, - } -} - -fn copy_active_selection(app: &mut App) { - if !app.transcript_selection.is_active() { - return; - } - if let Some(text) = selection_to_text(app) { - if app.clipboard.write_text(&text).is_ok() { - app.status_message = Some("Selection copied".to_string()); - } else { - app.status_message = Some("Copy failed".to_string()); - } - } -} - -fn selection_to_text(app: &App) -> Option { - let (start, end) = app.transcript_selection.ordered_endpoints()?; - let lines = app.transcript_cache.lines(); - if lines.is_empty() { - return None; - } - let end_index = end.line_index.min(lines.len().saturating_sub(1)); - let start_index = start.line_index.min(end_index); - - let mut out = String::new(); - #[allow(clippy::needless_range_loop)] - for line_index in start_index..=end_index { - let line_text = line_to_plain(&lines[line_index]); - let slice = if start_index == end_index { - slice_text(&line_text, start.column, end.column) - } else if line_index == start_index { - slice_text(&line_text, start.column, text_display_width(&line_text)) - } else if line_index == end_index { - slice_text(&line_text, 0, end.column) - } else { - line_text - }; - out.push_str(&slice); - if line_index != end_index { - out.push('\n'); - } - } - Some(out) -} - -fn open_pager_for_selection(app: &mut App) -> bool { - let Some(text) = selection_to_text(app) else { - return false; - }; - let width = app - .last_transcript_area - .map(|area| area.width) - .unwrap_or(80); - let pager = PagerView::from_text("Selection", &text, width.saturating_sub(2)); - app.view_stack.push(pager); - true -} - -fn open_pager_for_last_message(app: &mut App) -> bool { - let Some(cell) = app.history.last() else { - return false; - }; - let width = app - .last_transcript_area - .map(|area| area.width) - .unwrap_or(80); - let text = history_cell_to_text(cell, width); - let pager = PagerView::from_text("Message", &text, width.saturating_sub(2)); - app.view_stack.push(pager); - true -} - -/// Open a pager showing the full thinking block. Targets the cell at the -/// current selection if it's a Thinking cell; otherwise falls back to the -/// most recent Thinking cell in history. Bound to Ctrl+O so users can read -/// reasoning content that's been collapsed in calm-mode rendering. -fn open_thinking_pager(app: &mut App) -> bool { - let selected_cell = app - .transcript_selection - .ordered_endpoints() - .and_then(|(start, _)| { - app.transcript_cache - .line_meta() - .get(start.line_index) - .and_then(|meta| meta.cell_line()) - .map(|(cell_index, _)| cell_index) - }) - .filter(|&idx| { - matches!( - app.history.get(idx), - Some(crate::tui::history::HistoryCell::Thinking { .. }) - ) - }); - - let target_idx = selected_cell.or_else(|| { - app.history - .iter() - .enumerate() - .rev() - .find_map(|(idx, cell)| { - if matches!(cell, crate::tui::history::HistoryCell::Thinking { .. }) { - Some(idx) - } else { - None - } - }) - }); - - let Some(idx) = target_idx else { - app.status_message = Some("No thinking blocks to expand".to_string()); - return true; - }; - - let cell = &app.history[idx]; - let width = app - .last_transcript_area - .map(|area| area.width) - .unwrap_or(80); - let text = history_cell_to_text(cell, width); - app.view_stack.push(PagerView::from_text( - "Thinking", - &text, - width.saturating_sub(2), - )); - true -} - -fn open_tool_details_pager(app: &mut App) -> bool { - let target_cell = detail_target_cell_index(app); - - let Some(cell_index) = target_cell else { - return false; - }; - if let Some(detail) = app.tool_detail_record_for_cell(cell_index) { - let input = serde_json::to_string_pretty(&detail.input) - .unwrap_or_else(|_| detail.input.to_string()); - let output = detail.output.as_deref().map_or( - "(not available)".to_string(), - std::string::ToString::to_string, - ); - let content = format!( - "Tool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}", - detail.tool_id, detail.tool_name, input, output - ); - - let width = app - .last_transcript_area - .map(|area| area.width) - .unwrap_or(80); - app.view_stack.push(PagerView::from_text( - format!("Tool: {}", detail.tool_name), - &content, - width.saturating_sub(2), - )); - return true; - } - - let Some(cell) = app.cell_at_virtual_index(cell_index) else { - app.status_message = Some("No details available for the selected line".to_string()); - return false; - }; - let title = match cell { - HistoryCell::User { .. } => "You".to_string(), - HistoryCell::Assistant { .. } => "Assistant".to_string(), - HistoryCell::System { .. } => "Note".to_string(), - HistoryCell::Error { .. } => "Error".to_string(), - HistoryCell::Thinking { .. } => "Reasoning".to_string(), - HistoryCell::Tool(_) => "Message".to_string(), - HistoryCell::SubAgent(_) => "Sub-agent".to_string(), - }; - let width = app - .last_transcript_area - .map(|area| area.width) - .unwrap_or(80); - let content = history_cell_to_text(cell, width); - app.view_stack.push(PagerView::from_text( - title, - &content, - width.saturating_sub(2), - )); - true -} - -fn detail_target_cell_index(app: &App) -> Option { - if let Some((start, _)) = app.transcript_selection.ordered_endpoints() { - return app - .transcript_cache - .line_meta() - .get(start.line_index) - .and_then(|meta| meta.cell_line()) - .map(|(cell_index, _)| cell_index); - } - - app.detail_cell_index_for_viewport( - app.last_transcript_top, - app.last_transcript_visible.max(1), - app.transcript_cache.line_meta(), - ) - .or_else(|| app.history.len().checked_sub(1)) -} - -fn selected_detail_footer_label(app: &App) -> Option { - if app.transcript_selection.is_active() { - return None; - } - let cell_index = app.detail_cell_index_for_viewport( - app.last_transcript_top, - app.last_transcript_visible.max(1), - app.transcript_cache.line_meta(), - )?; - let label = detail_target_label(app, cell_index)?; - Some(format!( - "Alt+V details: {}", - truncate_line_to_width(&label, 34) - )) -} - -fn detail_target_label(app: &App, cell_index: usize) -> Option { - if let Some(detail) = app.tool_detail_record_for_cell(cell_index) { - return Some(detail.tool_name.clone()); - } - let cell = app.cell_at_virtual_index(cell_index)?; - match cell { - HistoryCell::Tool(ToolCell::Exec(exec)) => { - Some(format!("run {}", one_line_summary(&exec.command, 80))) - } - HistoryCell::Tool(ToolCell::Exploring(explore)) => Some(format!( - "workspace {} item{}", - explore.entries.len(), - if explore.entries.len() == 1 { "" } else { "s" } - )), - HistoryCell::Tool(ToolCell::PlanUpdate(_)) => Some("update plan".to_string()), - HistoryCell::Tool(ToolCell::PatchSummary(patch)) => Some(format!("patch {}", patch.path)), - HistoryCell::Tool(ToolCell::Review(review)) => { - let target = one_line_summary(&review.target, 80); - Some(if target.is_empty() { - "review".to_string() - } else { - format!("review {target}") - }) - } - HistoryCell::Tool(ToolCell::DiffPreview(diff)) => Some(format!("diff {}", diff.title)), - HistoryCell::Tool(ToolCell::Mcp(mcp)) => Some(format!("tool {}", mcp.tool)), - HistoryCell::Tool(ToolCell::ViewImage(image)) => { - Some(format!("image {}", image.path.display())) - } - HistoryCell::Tool(ToolCell::WebSearch(search)) => Some(format!("search {}", search.query)), - HistoryCell::Tool(ToolCell::Generic(generic)) => Some(format!("tool {}", generic.name)), - HistoryCell::SubAgent(_) => Some("sub-agent".to_string()), - _ => None, - } -} - -fn is_copy_shortcut(key: &KeyEvent) -> bool { - let is_c = matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')); - if !is_c { - return false; - } - - if key.modifiers.contains(KeyModifiers::SUPER) { - return true; - } - - key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT) -} - -fn details_shortcut_modifiers(modifiers: KeyModifiers) -> bool { - modifiers.is_empty() - || modifiers == KeyModifiers::SHIFT - || (modifiers.contains(KeyModifiers::ALT) - && !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::SUPER)) -} - -fn is_paste_shortcut(key: &KeyEvent) -> bool { - let is_v = matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V')); - if !is_v { - return false; - } - - // Cmd+V on macOS - if key.modifiers.contains(KeyModifiers::SUPER) { - return true; - } - - // Ctrl+V on Linux/Windows - key.modifiers.contains(KeyModifiers::CONTROL) -} - -fn should_scroll_with_arrows(_app: &App) -> bool { - false -} - -fn extract_reasoning_header(text: &str) -> Option { - let start = text.find("**")?; - let rest = &text[start + 2..]; - let end = rest.find("**")?; - let header = rest[..end].trim().trim_end_matches(':'); - if header.is_empty() { - None - } else { - Some(header.to_string()) - } -} - -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, - }; - - // 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/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 97e90460..43930ef7 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -700,10 +700,7 @@ mod tests { assert_eq!(a, b, "deterministic given the same frame"); // 750 ms → 5 ticks, crest A advances every 2 ticks → ≥2 steps. let c = super::footer_working_strip_string(40, 750); - assert_ne!( - a, c, - "advancing 4 ticks must change the strip", - ); + assert_ne!(a, c, "advancing 4 ticks must change the strip",); } #[test] diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index 3f9b18d6..005ae1f6 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,7 @@ { "name": "deepseek-tui", - "version": "0.7.1", - "deepseekBinaryVersion": "0.7.1", + "version": "0.7.2", + "deepseekBinaryVersion": "0.7.2", "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.", "author": "Hmbown", "license": "MIT",