diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 291e2a26..be683200 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -536,6 +536,18 @@ impl Engine { session.last_system_prompt_hash = Some(system_prompt_hash(stable_prompt.as_ref())); session.system_prompt = stable_prompt; + // Initialize prefix-cache stability monitor (lazy-pin). + // The system prompt is available now but the tool catalog isn't + // fully built until the first turn, so we start unpinned. The + // first `check_and_update` call in the turn loop will pin the + // fingerprint automatically. + let _ = session.prefix_stability.get_or_insert_with(|| { + // Use the tool registry's spec names for fingerprinting. + // At this point tool spec builders may not be registered yet, + // so we start with None — fingerprint will pin on first request. + crate::prefix_cache::PrefixStabilityManager::new_unpinned() + }); + let subagent_manager = new_shared_subagent_manager(config.workspace.clone(), config.max_subagents); let shell_manager = config diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 7927246b..bfcbf3e2 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -235,6 +235,49 @@ impl Engine { &self.session.messages, ); + // Check prefix-cache stability before building the request. + // This detects system-prompt or tool-set drift that would + // invalidate DeepSeek's KV prefix cache for this turn. + // Sends an event on EVERY check so the TUI can maintain + // its own counter for the stable-checks tally. + if let Some(pm) = self.session.prefix_stability.as_mut() { + let system_text = + crate::prefix_cache::system_prompt_text(self.session.system_prompt.as_ref()); + let tools_ref: Option<&[crate::models::Tool]> = active_tools.as_deref(); + match pm.check_and_update(&system_text, tools_ref) { + Err(change) => { + tracing::debug!( + target: "prefix_cache", + "{}", + change.description() + ); + let _ = self + .tx_event + .send(Event::PrefixCacheChange { + description: change.description(), + system_prompt_changed: change.system_changed, + tools_changed: change.tools_changed, + stability_pct: (pm.stability_ratio() * 100.0).round() as u32, + changed: true, + }) + .await; + } + Ok(_) => { + // Stable check — keep the TUI counter in sync. + let _ = self + .tx_event + .send(Event::PrefixCacheChange { + description: String::new(), + system_prompt_changed: false, + tools_changed: false, + stability_pct: (pm.stability_ratio() * 100.0).round() as u32, + changed: false, + }) + .await; + } + } + } + let request = MessageRequest { model: self.session.model.clone(), messages: self.messages_with_turn_metadata(), diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 41ca417f..ea338409 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -261,6 +261,24 @@ pub enum Event { blocked_network: bool, blocked_write: bool, }, + + // === Prefix-Cache Stability Events === + /// The prefix (system prompt + tool specs) changed between turns, + /// which invalidates DeepSeek's KV prefix cache. Carries diagnostics + /// for the TUI to surface. + PrefixCacheChange { + /// Human-readable description of what changed. + description: String, + /// Whether the system prompt component changed. + system_prompt_changed: bool, + /// Whether the tool set component changed. + tools_changed: bool, + /// Overall prefix stability percentage (100 = fully stable). + stability_pct: u32, + /// True when the prefix actually changed (cache invalidated). + /// False for routine stable-check heartbeats. + changed: bool, + }, } impl Event { diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index 65ea5f16..60aa9187 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -4,6 +4,7 @@ use crate::cycle_manager::CycleBriefing; use crate::models::{Message, SystemPrompt, Usage}; +use crate::prefix_cache::PrefixStabilityManager; use crate::project_context::{ProjectContext, load_project_context_with_parents}; use crate::tui::approval::ApprovalMode; use crate::working_set::WorkingSet; @@ -82,6 +83,11 @@ pub struct Session { /// Briefings produced at past cycle boundaries, in chronological order. /// Bounded growth: one entry per cycle, briefing capped at ~3,000 tokens. pub cycle_briefings: Vec, + + /// Prefix-cache stability monitor (inspired by Reasonix's Pillar 1). + /// Tracks the immutable prefix fingerprint and detects drift across turns. + /// Set during engine construction; None until the first system prompt assembly. + pub prefix_stability: Option, } /// Cumulative usage statistics for a session. @@ -155,6 +161,7 @@ impl Session { cycle_count: 0, current_cycle_started: Utc::now(), cycle_briefings: Vec::new(), + prefix_stability: None, } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 5a84a625..9a90bee9 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -47,6 +47,7 @@ mod memory; mod models; mod network_policy; mod palette; +mod prefix_cache; mod pricing; mod project_context; mod project_doc; diff --git a/crates/tui/src/prefix_cache.rs b/crates/tui/src/prefix_cache.rs new file mode 100644 index 00000000..8d631ffa --- /dev/null +++ b/crates/tui/src/prefix_cache.rs @@ -0,0 +1,496 @@ +//! Prefix-cache stability manager (inspired by Reasonix's Pillar 1). +//! +//! DeepSeek's automatic prefix caching activates only when the *exact* +//! byte prefix of a request matches the prior request. Any system-prompt +//! drift, tool-list reordering, or message-rewriting busts the cache +//! for every token after the changed byte. +//! +//! This module provides a `PrefixStabilityManager` that: +//! +//! 1. **Fingerprints** the immutable prefix (system prompt + tool specs) +//! at session start, using SHA-256 for strong collision resistance. +//! 2. **Detects drift** by comparing the current prefix against the +//! pinned fingerprint before every request. +//! 3. **Diagnoses** the cause of drift — did the system prompt change? +//! Did the tool set change? Both? +//! 4. **Emits events** so the TUI can surface stability to the user. +//! +//! ## Three-region model (from Reasonix) +//! +//! ```text +//! ┌─────────────────────────────────────────┐ +//! │ IMMUTABLE PREFIX │ ← fixed for session +//! │ system + tool_specs │ cache hit candidate +//! ├─────────────────────────────────────────┤ +//! │ APPEND-ONLY HISTORY │ ← grows monotonically +//! │ [assistant₁][tool₁][assistant₂]... │ preserves prefix of prior turns +//! ├─────────────────────────────────────────┤ +//! │ LATEST USER TURN │ ← the only new content per request +//! └─────────────────────────────────────────┘ +//! ``` + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::models::{SystemPrompt, Tool}; + +/// A snapshot of the immutable prefix's fingerprint. +/// +/// Two snapshots with the same `combined` hash are guaranteed to +/// produce the same byte prefix when serialized for the API. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrefixFingerprint { + /// SHA-256 of the system prompt text. + pub system_sha256: String, + /// SHA-256 of the concatenated, sorted tool names. + pub tools_sha256: String, + /// SHA-256 of system_sha256 ++ tools_sha256 (combined). + pub combined_sha256: String, +} + +impl PrefixFingerprint { + /// Compute a fingerprint from system prompt text and tool list. + pub fn compute(system_text: &str, tools: Option<&[Tool]>) -> Self { + let system_sha256 = sha256_hex(system_text.as_bytes()); + + let tools_sha256 = match tools { + Some(tools) if !tools.is_empty() => { + // Sort tool names deterministically so the hash is + // stable regardless of registration order. + let mut tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); + tool_names.sort(); + let joined = tool_names.join(","); + sha256_hex(joined.as_bytes()) + } + _ => sha256_hex(b""), + }; + + let combined = format!("{}:{}", system_sha256, tools_sha256); + let combined_sha256 = sha256_hex(combined.as_bytes()); + + Self { + system_sha256, + tools_sha256, + combined_sha256, + } + } +} + +/// A change record describing what drifted in the prefix. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrefixChange { + /// The old fingerprint (before the change). + pub old: PrefixFingerprint, + /// The new fingerprint (after the change). + pub new: PrefixFingerprint, + /// Whether the system prompt component changed. + pub system_changed: bool, + /// Whether the tool set component changed. + pub tools_changed: bool, +} + +#[allow(dead_code)] +impl PrefixChange { + /// Returns a human-readable description of what changed. + pub fn description(&self) -> String { + let mut parts = Vec::new(); + if self.system_changed { + parts.push("system prompt"); + } + if self.tools_changed { + parts.push("tool set"); + } + if parts.is_empty() { + return "unknown (fingerprint mismatch but no component detected)".to_string(); + } + format!("prefix cache invalidated: {} changed", parts.join(" and ")) + } + + /// Returns a short label for TUI chip display. + pub fn label(&self) -> &'static str { + if self.system_changed && self.tools_changed { + "sys+tools" + } else if self.system_changed { + "sys" + } else if self.tools_changed { + "tools" + } else { + "prefix" + } + } +} + +/// Monitors and manages prefix-cache stability across turns. +/// +/// This is the core abstraction, mirroring Reasonix's `ImmutablePrefix` +/// concept but adapted to DeepSeek-TUI's existing architecture where the +/// system prompt is rebuilt each turn and tools are registered at startup. +/// +/// Usage: +/// ```ignore +/// let mgr = PrefixStabilityManager::new(system_text, tools); +/// if mgr.check_and_update(system_text, tools) { +/// println!("Prefix is stable (cache-friendly)"); +/// } else { +/// let change = mgr.last_change().unwrap(); +/// println!("Prefix drifted: {}", change.description()); +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct PrefixStabilityManager { + /// The pinned fingerprint from session start or last stabilization. + pinned: Option, + /// The most recent fingerprint (computed during last check). + current: Option, + /// The last detected change, if any. + last_change: Option, + /// Total number of prefix changes detected this session. + change_count: u64, + /// Total number of stability checks performed. + check_count: u64, +} + +#[allow(dead_code)] +impl PrefixStabilityManager { + /// Create a new manager and immediately pin the first fingerprint. + pub fn new(system_text: &str, tools: Option<&[Tool]>) -> Self { + let fp = PrefixFingerprint::compute(system_text, tools); + Self { + pinned: Some(fp.clone()), + current: Some(fp), + last_change: None, + change_count: 0, + check_count: 0, + } + } + + /// Create a manager in "unpinned" state — no initial fingerprint. + /// Call `pin()` or `check_and_update()` to establish the baseline. + pub fn new_unpinned() -> Self { + Self { + pinned: None, + current: None, + last_change: None, + change_count: 0, + check_count: 0, + } + } + + /// Explicitly pin a fingerprint, replacing any prior pinned state. + /// Returns `true` if this is the first pin, or `false` if replacing. + /// Note: does NOT increment `check_count` — that counter is reserved + /// for `check_and_update` calls so `stability_ratio()` stays accurate. + pub fn pin(&mut self, system_text: &str, tools: Option<&[Tool]>) -> bool { + let fp = PrefixFingerprint::compute(system_text, tools); + let was_unpinned = self.pinned.is_none(); + self.pinned = Some(fp.clone()); + self.current = Some(fp); + was_unpinned + } + + /// Check whether the current prefix matches the pinned fingerprint. + /// Updates internal state and returns: + /// - `Ok(true)` if the prefix is stable (fingerprint matches pinned). + /// - `Ok(false)` if the prefix changed but was automatically re-pinned. + /// - `Err(change)` if the prefix changed; caller should surface this. + /// + /// After calling this, `last_change()` returns the detected change. + pub fn check_and_update( + &mut self, + system_text: &str, + tools: Option<&[Tool]>, + ) -> Result { + let fp = PrefixFingerprint::compute(system_text, tools); + let old_fp = std::mem::replace(&mut self.current, Some(fp.clone())); + self.check_count += 1; + + let pinned = match &self.pinned { + Some(p) => p, + None => { + // First check: pin now. + self.pinned = Some(fp); + self.last_change = None; + return Ok(true); + } + }; + + if fp.combined_sha256 == pinned.combined_sha256 { + // Stable — no change. + Ok(true) + } else { + // Change detected. + let old = old_fp.unwrap_or_else(|| pinned.clone()); + let system_changed = fp.system_sha256 != pinned.system_sha256; + let tools_changed = fp.tools_sha256 != pinned.tools_sha256; + + let change = PrefixChange { + old, + new: fp.clone(), + system_changed, + tools_changed, + }; + + self.last_change = Some(change.clone()); + self.change_count += 1; + + // Re-pin to the new prefix so subsequent checks are + // against the latest baseline. Use the original fp + // (avoid recomputing the hash — clone was for the change record). + self.pinned = Some(fp); + + Err(change) + } + } + + /// Returns the most recent prefix change, if any. + pub fn last_change(&self) -> Option<&PrefixChange> { + self.last_change.as_ref() + } + + /// Returns the pinned fingerprint. + pub fn pinned_fingerprint(&self) -> Option<&PrefixFingerprint> { + self.pinned.as_ref() + } + + /// Returns the current (most recently computed) fingerprint. + pub fn current_fingerprint(&self) -> Option<&PrefixFingerprint> { + self.current.as_ref() + } + + /// Returns the total number of prefix changes detected. + pub fn change_count(&self) -> u64 { + self.change_count + } + + /// Returns the total number of stability checks performed. + pub fn check_count(&self) -> u64 { + self.check_count + } + + /// Returns the prefix stability rate as a fraction (0.0 – 1.0). + /// 1.0 means the prefix has never changed. Returns 1.0 when no + /// checks have been performed (to avoid division by zero). + pub fn stability_ratio(&self) -> f64 { + if self.check_count == 0 { + 1.0 + } else { + let stable_checks = self.check_count - self.change_count; + stable_checks as f64 / self.check_count as f64 + } + } + + /// Returns a human-readable stability summary. + pub fn summary(&self) -> String { + let pct = self.stability_ratio() * 100.0; + let pinned_short = self + .pinned + .as_ref() + .map(|fp| { + if fp.combined_sha256.len() >= 12 { + &fp.combined_sha256[..12] + } else { + &fp.combined_sha256 + } + }) + .unwrap_or("none"); + + format!( + "Prefix stability: {pct:.1}% ({stable}/{total} checks stable) | fingerprint: {pinned_short} | changes: {changes}", + pct = pct, + stable = self.check_count.saturating_sub(self.change_count), + total = self.check_count, + pinned_short = pinned_short, + changes = self.change_count, + ) + } +} + +/// Compute the SHA-256 hex digest of a byte slice. +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +/// Extract the system prompt text from an optional SystemPrompt, +/// returning an owned String. This is used for prefix fingerprinting +/// and avoids lifetime/leak issues with the rare SystemPrompt::Blocks case. +pub fn system_prompt_text(system: Option<&SystemPrompt>) -> String { + match system { + Some(SystemPrompt::Text(text)) => text.clone(), + Some(SystemPrompt::Blocks(blocks)) => { + let mut text = String::new(); + for block in blocks { + text.push_str(&block.text); + text.push('\n'); + } + text + } + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_tool(name: &str) -> Tool { + Tool { + name: name.to_string(), + description: String::new(), + input_schema: serde_json::Value::Null, + tool_type: None, + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + } + } + + #[test] + fn same_prefix_produces_same_fingerprint() { + let a = PrefixFingerprint::compute("hello world", None); + let b = PrefixFingerprint::compute("hello world", None); + assert_eq!(a.combined_sha256, b.combined_sha256); + } + + #[test] + fn different_system_produces_different_fingerprint() { + let a = PrefixFingerprint::compute("hello", None); + let b = PrefixFingerprint::compute("world", None); + assert_ne!(a.combined_sha256, b.combined_sha256); + } + + #[test] + fn tool_order_does_not_affect_fingerprint() { + let tools_a = vec![make_tool("read_file"), make_tool("write_file")]; + let tools_b = vec![make_tool("write_file"), make_tool("read_file")]; + let a = PrefixFingerprint::compute("system", Some(&tools_a)); + let b = PrefixFingerprint::compute("system", Some(&tools_b)); + assert_eq!(a.combined_sha256, b.combined_sha256); + } + + #[test] + fn different_tools_produce_different_fingerprint() { + let tools_a = vec![make_tool("read_file")]; + let tools_b = vec![make_tool("write_file")]; + let a = PrefixFingerprint::compute("system", Some(&tools_a)); + let b = PrefixFingerprint::compute("system", Some(&tools_b)); + assert_ne!(a.combined_sha256, b.combined_sha256); + } + + #[test] + fn manager_starts_stable() { + let mut mgr = PrefixStabilityManager::new("system prompt", None); + assert!(mgr.check_and_update("system prompt", None).unwrap()); + assert_eq!(mgr.change_count(), 0); + assert_eq!(mgr.check_count(), 1); + } + + #[test] + fn manager_detects_change() { + let mut mgr = PrefixStabilityManager::new("system prompt", None); + let result = mgr.check_and_update("different prompt", None); + assert!(result.is_err()); + assert_eq!(mgr.change_count(), 1); + let change = mgr.last_change().unwrap(); + assert!(change.system_changed); + assert!(!change.tools_changed); + } + + #[test] + fn manager_detects_tool_change() { + let tools_a = vec![make_tool("read_file")]; + let tools_b = vec![make_tool("write_file")]; + let mut mgr = PrefixStabilityManager::new("system", Some(&tools_a)); + let result = mgr.check_and_update("system", Some(&tools_b)); + assert!(result.is_err()); + let change = mgr.last_change().unwrap(); + assert!(!change.system_changed); + assert!(change.tools_changed); + } + + #[test] + fn manager_re_pins_after_change() { + let mut mgr = PrefixStabilityManager::new("old", None); + let _ = mgr.check_and_update("new", None); + // After re-pin, the new "new" should be stable. + assert!(mgr.check_and_update("new", None).unwrap()); + assert_eq!(mgr.change_count(), 1); + } + + #[test] + fn stability_ratio_is_one_for_no_changes() { + let mut mgr = PrefixStabilityManager::new("hello", None); + mgr.check_and_update("hello", None).unwrap(); + mgr.check_and_update("hello", None).unwrap(); + assert!((mgr.stability_ratio() - 1.0).abs() < f64::EPSILON); + assert_eq!(mgr.check_count(), 2); + assert_eq!(mgr.change_count(), 0); + } + + #[test] + fn stability_ratio_reflects_change_rate() { + let mut mgr = PrefixStabilityManager::new("hello", None); + mgr.check_and_update("hello", None).unwrap(); // check 1: stable + let _ = mgr.check_and_update("world", None); // check 2: changed + mgr.check_and_update("world", None).unwrap(); // check 3: stable + // 2 stable out of 3 checks = 0.666... + // (check_count=0 at start, so 3 checks: 3 checks - 1 change = 2 stable) + assert!((mgr.stability_ratio() - 2.0 / 3.0).abs() < 0.01); + assert_eq!(mgr.check_count(), 3); + assert_eq!(mgr.change_count(), 1); + } + + #[test] + fn empty_tools_and_none_tools_produce_same_hash() { + let empty = PrefixFingerprint::compute("system", Some(&[])); + let none = PrefixFingerprint::compute("system", None); + // Both should produce sha256(b"") for the tool component + assert_eq!(empty.tools_sha256, none.tools_sha256); + } + + #[test] + fn empty_system_produces_sha256_of_empty_string() { + let fp = PrefixFingerprint::compute("", None); + let expected = sha256_hex(b""); + assert_eq!(fp.system_sha256, expected); + } + + #[test] + fn prefix_change_description_is_informative() { + let old = PrefixFingerprint::compute("old", None); + let new = PrefixFingerprint::compute("new", None); + let change = PrefixChange { + old, + new, + system_changed: true, + tools_changed: false, + }; + assert_eq!( + change.description(), + "prefix cache invalidated: system prompt changed" + ); + assert_eq!(change.label(), "sys"); + } + + #[test] + fn new_unpinned_has_no_change_history() { + let mut mgr = PrefixStabilityManager::new_unpinned(); + assert!(mgr.pinned_fingerprint().is_none()); + assert!(mgr.current_fingerprint().is_none()); + assert!(mgr.last_change().is_none()); + assert_eq!(mgr.change_count(), 0); + assert_eq!(mgr.check_count(), 0); + // First check should pin automatically and count as a check. + assert!(mgr.check_and_update("hello", None).unwrap()); + assert!(mgr.pinned_fingerprint().is_some()); + assert_eq!(mgr.check_count(), 1); + } + + #[test] + fn system_prompt_text_returns_empty_for_none() { + assert_eq!(system_prompt_text(None), ""); + } +} diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 0ff37b51..73fdb97d 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1084,6 +1084,16 @@ pub struct App { /// Used by `/cycles` and `/cycle ` slash commands. pub cycle_briefings: Vec, + // === Prefix-Cache Stability Tracking === + /// Number of times the prefix (system prompt + tool specs) has changed. + pub prefix_change_count: u64, + /// Total number of prefix stability checks performed. + pub prefix_checks_total: u64, + /// Current prefix stability percentage, if known. + pub prefix_stability_pct: Option, + /// Description of the last prefix change, if any. + pub last_prefix_change_desc: Option, + /// Active cycle configuration (token threshold, briefing cap, per-model /// overrides). Loaded from config and forwarded to the engine. pub cycle: CycleConfig, @@ -1582,6 +1592,10 @@ impl App { quit_armed_until: None, cycle_count: 0, cycle_briefings: Vec::new(), + prefix_change_count: 0, + prefix_checks_total: 0, + prefix_stability_pct: None, + last_prefix_change_desc: None, cycle: CycleConfig::default(), collapsed_cells: HashSet::new(), collapsed_cell_map: Vec::new(), diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 42ec28d0..4d24426f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1449,6 +1449,21 @@ async fn run_event_loop( EngineEvent::CoherenceState { state, .. } => { app.coherence_state = state; } + EngineEvent::PrefixCacheChange { + description, + stability_pct, + changed, + .. + } => { + app.prefix_checks_total = app.prefix_checks_total.saturating_add(1); + app.prefix_stability_pct = Some(stability_pct); + if changed { + app.prefix_change_count = app.prefix_change_count.saturating_add(1); + if !description.is_empty() { + app.last_prefix_change_desc = Some(description); + } + } + } EngineEvent::CapacityDecision { .. } => { // Telemetry-only event. Surface actual interventions and failures // instead of replacing the footer with no-op guardrail chatter. @@ -3472,6 +3487,37 @@ fn format_cache_warmup_result(usage: &Usage) -> String { ) } +/// Format prefix stability info for the TUI footer chip. +fn format_prefix_stability_chip(app: &App) -> Option<(String, ratatui::style::Color)> { + let pct = app.prefix_stability_pct?; + let changes = app.prefix_change_count; + + let color = if changes == 0 { + // Perfect stability: green + ratatui::style::Color::Green + } else if pct >= 95 { + // Excellent: green + ratatui::style::Color::Green + } else if pct >= 80 { + // Good: yellow + ratatui::style::Color::Yellow + } else { + // Poor: red — cache is churning + ratatui::style::Color::Red + }; + + let label = if changes == 0 { + format!("P {pct}%") + } else { + format!( + "P {pct}% ({changes} change{})", + if changes == 1 { "" } else { "s" } + ) + }; + + Some((label, color)) +} + fn format_available_models_message(current_model: &str, models: &[String]) -> String { let mut lines = vec![format!("Available models ({})", models.len())]; for model in models { @@ -5241,10 +5287,25 @@ async fn apply_command_result( app.status_message = Some("Warming DeepSeek cache...".to_string()); match run_cache_warmup(app, config).await { Ok(usage) => { - let message = format_cache_warmup_result(&usage); - app.add_message(HistoryCell::System { - content: message.clone(), - }); + let mut message = format_cache_warmup_result(&usage); + // Append prefix-cache stability info. + if app.prefix_checks_total > 0 { + let changes = app.prefix_change_count; + let total = app.prefix_checks_total; + let stable = total.saturating_sub(changes); + let pct = app + .prefix_stability_pct + .map(|p| format!("{p}%")) + .unwrap_or_else(|| "--".to_string()); + message.push_str(&format!( + "\n\nPrefix stability: {pct} ({stable}/{total} checks stable, {changes} change{})", + if changes == 1 { "" } else { "s" } + )); + if let Some(ref desc) = app.last_prefix_change_desc { + message.push_str(&format!("\nLast prefix change: {desc}")); + } + } + app.add_message(HistoryCell::System { content: message }); app.status_message = Some("Cache warmup complete".to_string()); } Err(error) => { @@ -8038,20 +8099,29 @@ fn should_show_footer_cost(displayed_cost: f64) -> bool { 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. + // prefix stability, 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), app.ui_locale); let replay_spans = footer_reasoning_replay_spans(app); let cache_spans = footer_cache_spans(app); let cost_spans = footer_cost_spans(app); + let prefix_spans = app + .prefix_stability_pct + .map(|_| { + let (label, color) = format_prefix_stability_chip(app) + .unwrap_or(("P --".to_string(), ratatui::style::Color::DarkGray)); + vec![Span::styled(label, Style::default().fg(color))] + }) + .unwrap_or_default(); let parts: Vec<&Vec>> = [ &coherence_spans, &agents_spans, &replay_spans, &cache_spans, + &prefix_spans, &cost_spans, ] .iter()