Merge remote-tracking branch 'origin/pr/1517' into work/v0.8.34

This commit is contained in:
Hunter Bown
2026-05-13 00:11:45 -05:00
8 changed files with 667 additions and 6 deletions
+12
View File
@@ -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
+43
View File
@@ -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(),
+18
View File
@@ -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 {
+7
View File
@@ -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<CycleBriefing>,
/// 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<PrefixStabilityManager>,
}
/// 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,
}
}
+1
View File
@@ -47,6 +47,7 @@ mod memory;
mod models;
mod network_policy;
mod palette;
mod prefix_cache;
mod pricing;
mod project_context;
mod project_doc;
+496
View File
@@ -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<PrefixFingerprint>,
/// The most recent fingerprint (computed during last check).
current: Option<PrefixFingerprint>,
/// The last detected change, if any.
last_change: Option<PrefixChange>,
/// 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<bool, PrefixChange> {
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), "");
}
}
+14
View File
@@ -1084,6 +1084,16 @@ pub struct App {
/// Used by `/cycles` and `/cycle <n>` slash commands.
pub cycle_briefings: Vec<CycleBriefing>,
// === 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<u32>,
/// Description of the last prefix change, if any.
pub last_prefix_change_desc: Option<String>,
/// 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(),
+76 -6
View File
@@ -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<Span<'static>> {
// 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<Span<'static>>> = [
&coherence_spans,
&agents_spans,
&replay_spans,
&cache_spans,
&prefix_spans,
&cost_spans,
]
.iter()