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 11d64e53..00000000 Binary files a/assets/Screenshot 2026-04-28 at 21.12.34.png and /dev/null differ 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",