use crate::localization::truncate_to_width;
use std::borrow::Cow;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph, Widget},
};
use unicode_width::UnicodeWidthStr;
use crate::localization::{MessageId, tr};
use crate::palette::ChromeInk;
use crate::tui::{
app::App,
underwater::{LiveActivity, ShellPhase, ShellTier, phase_marker_with_activity},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhaseStripPlacement {
AboveComposer,
BelowComposer,
}
impl PhaseStripPlacement {
#[must_use]
pub fn for_phase(phase: ShellPhase) -> Self {
match phase {
ShellPhase::Working
| ShellPhase::Verifying
| ShellPhase::Waiting
| ShellPhase::Approval
| ShellPhase::Failed
| ShellPhase::Done => Self::AboveComposer,
ShellPhase::Idle | ShellPhase::Typing => Self::BelowComposer,
}
}
#[must_use]
pub fn is_above_composer(self) -> bool {
matches!(self, Self::AboveComposer)
}
}
#[must_use]
pub fn height() -> u16 {
1
}
fn span_width(spans: &[Span<'_>]) -> usize {
spans.iter().map(|span| span.content.width()).sum()
}
fn working_detail(app: &App, activity: LiveActivity) -> Option<String> {
let running = activity.running_tool_count();
let secs = app
.turn_started_at
.map(|started| started.elapsed().as_secs());
match (running, secs) {
(0, Some(secs)) if secs > 0 => Some(crate::elapsed::format_elapsed_secs(secs)),
(n, Some(_)) if n > 0 => Some(format!("×{n}")),
(n, None) if n > 0 => Some(format!("×{n}")),
_ => None,
}
}
fn session_cache_hit_percentage(app: &App) -> Option<u8> {
let hit = u64::from(app.session.total_cache_hit_tokens);
let miss = u64::from(app.session.total_cache_miss_tokens);
let total = hit + miss;
if total == 0 {
return None;
}
Some(((hit * 100 + total / 2) / total) as u8)
}
fn route_identity_label(app: &App, tier: ShellTier) -> String {
let (provider, model) = app.effective_route_identity_display();
let effort = app.reasoning_effort_display_label();
let label = match tier {
ShellTier::Compact => format!("{model} · {effort}"),
ShellTier::Normal | ShellTier::Wide => format!("{provider} · {model} · {effort}"),
};
let budget = match tier {
ShellTier::Compact => 24,
ShellTier::Normal => 44,
ShellTier::Wide => 64,
};
truncate_to_width(&label, budget)
}
fn status_toast_ink(level: crate::tui::app::StatusToastLevel) -> ChromeInk {
match level {
crate::tui::app::StatusToastLevel::Info => ChromeInk::Info,
crate::tui::app::StatusToastLevel::Success => ChromeInk::Outcome,
crate::tui::app::StatusToastLevel::Warning => ChromeInk::Attention,
crate::tui::app::StatusToastLevel::Error => ChromeInk::Failure,
}
}
pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) {
if area.width == 0 || area.height == 0 {
return;
}
let status_toast = app.active_status_toast();
let activity = LiveActivity::from_app(app);
let phase = ShellPhase::from_app_with_activity(app, activity);
let tier = ShellTier::for_chrome_width(area.width);
Block::default()
.style(Style::default().bg(app.ui_theme.footer_bg))
.render(area, buf);
let rail_color = phase.color(app);
let (marker, phase_label) = phase_marker_with_activity(app, phase, activity);
let phase_style = Style::default().fg(rail_color).add_modifier(
if matches!(phase, ShellPhase::Waiting | ShellPhase::Approval) {
Modifier::BOLD
} else {
Modifier::empty()
},
);
let mut left = vec![
Span::styled("▌", phase_style),
Span::styled(marker, phase_style),
Span::raw(" "),
Span::styled(phase_label.clone(), phase_style),
];
if tier != ShellTier::Compact && matches!(phase, ShellPhase::Working | ShellPhase::Verifying) {
if let Some(detail) = working_detail(app, activity) {
left.push(Span::styled(
" · ",
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
left.push(Span::styled(
detail,
Style::default().fg(ChromeInk::Active.color(&app.ui_theme)),
));
}
left.push(Span::styled(
format!(
" · {}",
tr(app.ui_locale, MessageId::FooterHintEscInterrupt)
),
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
}
let route_label = route_identity_label(app, tier);
if !route_label.is_empty() {
left.push(Span::styled(
" · ",
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
left.push(Span::styled(
route_label,
Style::default().fg(ChromeInk::Metadata.color(&app.ui_theme)),
));
}
let mut tail: Vec<Span<'static>> = Vec::new();
let chip = app.cumulative_usage_chip();
if tier != ShellTier::Compact
&& let Some(amount) = match &chip {
crate::route_billing::UsageChip::Money(amount) => Some(amount.clone()),
crate::route_billing::UsageChip::PricedSubtotal { .. } => {
crate::route_billing::format_usage_chip(&chip)
}
_ => None,
}
{
tail.push(Span::styled(
" · ",
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
tail.push(Span::styled(
amount,
Style::default().fg(ChromeInk::Metadata.color(&app.ui_theme)),
));
}
let metrics_enabled = app
.status_items
.contains(&crate::config::StatusItem::SessionMetrics);
if !metrics_enabled
&& tier != ShellTier::Compact
&& app.status_items.contains(&crate::config::StatusItem::Cache)
&& let Some(pct) = session_cache_hit_percentage(app)
{
tail.push(Span::styled(
" · ",
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
tail.push(Span::styled(
format!("cache {pct}%"),
Style::default().fg(ChromeInk::Metadata.color(&app.ui_theme)),
));
}
let right_text: Cow<'static, str> = if PhaseStripPlacement::for_phase(phase).is_above_composer()
{
Cow::Borrowed("")
} else {
use crate::tui::shell_key_routing::{ShellBindingId, binding, footer_action_hints};
let hint_keys = tr(app.ui_locale, MessageId::FooterHintKeys);
let hint_output = tr(app.ui_locale, MessageId::FooterHintOutput);
let hint_context = tr(app.ui_locale, MessageId::FooterHintContext);
Cow::Owned(match tier {
ShellTier::Compact => {
format!("{}:{hint_keys}", binding(ShellBindingId::Help).footer_chord)
}
ShellTier::Normal => footer_action_hints(false)
.replace("{output}", hint_output.as_ref())
.replace("{keys}", hint_keys.as_ref()),
ShellTier::Wide => footer_action_hints(true)
.replace("{output}", hint_output.as_ref())
.replace("{context}", hint_context.as_ref())
.replace("{keys}", hint_keys.as_ref()),
})
};
let agent_hints = (tier != ShellTier::Compact
&& crate::tui::agent_focus::shell_shortcuts_available(app, false))
.then(|| crate::tui::agent_focus::footer_agent_hints(app));
let right_text: Cow<'static, str> = match agent_hints {
Some(hints) if !right_text.is_empty() => Cow::Owned(format!("{hints} · {right_text}")),
Some(hints) if phase == ShellPhase::Done => Cow::Owned(hints),
_ => right_text,
};
let right_width = right_text.width();
let available = usize::from(area.width);
if metrics_enabled && tier != ShellTier::Compact {
let snapshot = crate::tui::session_metrics::snapshot_from_app(app);
if !snapshot.is_empty() {
let toast_reserve = status_toast
.as_ref()
.filter(|toast| !toast.text.trim().is_empty())
.map(|toast| toast.text.trim().width().min(TOAST_MIN_WIDTH) + TOAST_SEPARATOR_WIDTH)
.unwrap_or(0);
let budget = available.saturating_sub(
span_width(&left)
+ span_width(&tail)
+ toast_reserve
+ right_width
+ TOAST_RIGHT_GAP
+ METRICS_SEPARATOR_WIDTH,
);
let ascii = crate::tui::color_compat::ascii_safe_enabled();
let strip = crate::tui::session_metrics::fit_to_width(
crate::tui::session_metrics::build_groups(snapshot, app.ui_locale),
budget,
crate::tui::session_metrics::Separators::for_ascii(ascii),
);
if !strip.is_empty() {
tail.push(Span::styled(
if ascii { " | " } else { " │ " },
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
tail.extend(crate::tui::session_metrics::spans(&strip, &app.ui_theme));
}
}
}
if tier != ShellTier::Compact
&& let Some(toast) = status_toast.filter(|toast| {
let survives_completion = matches!(
toast.level,
crate::tui::app::StatusToastLevel::Warning
| crate::tui::app::StatusToastLevel::Error
);
(phase != ShellPhase::Done || survives_completion)
&& !toast.text.trim().is_empty()
&& toast.text.trim() != phase_label.as_ref()
})
{
let toast_budget = available
.saturating_sub(
span_width(&left)
+ TOAST_SEPARATOR_WIDTH
+ span_width(&tail)
+ right_width
+ TOAST_RIGHT_GAP,
)
.max(TOAST_MIN_WIDTH);
left.push(Span::styled(
" · ",
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
left.push(Span::styled(
truncate_to_width(toast.text.trim(), toast_budget),
Style::default().fg(status_toast_ink(toast.level).color(&app.ui_theme)),
));
}
left.extend(tail);
let left_width = span_width(&left);
if right_width > 0 && left_width + right_width < available {
left.push(Span::raw(" ".repeat(available - left_width - right_width)));
left.push(Span::styled(
right_text.into_owned(),
Style::default().fg(ChromeInk::MetadataHint.color(&app.ui_theme)),
));
}
Paragraph::new(Line::from(left)).render(area, buf);
}
const TOAST_SEPARATOR_WIDTH: usize = 3;
const METRICS_SEPARATOR_WIDTH: usize = 3;
const TOAST_RIGHT_GAP: usize = 2;
const TOAST_MIN_WIDTH: usize = 24;
#[cfg(test)]
mod tests {
use super::*;
use crate::{
config::Config,
tui::active_cell::ActiveCell,
tui::app::TuiOptions,
tui::history::{ExecCell, ExecSource, HistoryCell, ToolCell, ToolStatus},
};
use ratatui::{Terminal, backend::TestBackend};
use std::{
path::PathBuf,
time::{Duration, Instant},
};
fn test_app() -> App {
App::new(
TuiOptions {
model: "deepseek-v4-flash".to_string(),
..crate::test_support::test_tui_options(PathBuf::from("."))
},
&Config::default(),
)
}
#[test]
fn live_phases_sit_above_composer_idle_stays_below() {
assert_eq!(
PhaseStripPlacement::for_phase(ShellPhase::Working),
PhaseStripPlacement::AboveComposer
);
assert_eq!(
PhaseStripPlacement::for_phase(ShellPhase::Waiting),
PhaseStripPlacement::AboveComposer
);
assert_eq!(
PhaseStripPlacement::for_phase(ShellPhase::Approval),
PhaseStripPlacement::AboveComposer
);
assert_eq!(
PhaseStripPlacement::for_phase(ShellPhase::Failed),
PhaseStripPlacement::AboveComposer
);
assert_eq!(
PhaseStripPlacement::for_phase(ShellPhase::Done),
PhaseStripPlacement::AboveComposer
);
assert_eq!(
PhaseStripPlacement::for_phase(ShellPhase::Idle),
PhaseStripPlacement::BelowComposer
);
assert_eq!(
PhaseStripPlacement::for_phase(ShellPhase::Typing),
PhaseStripPlacement::BelowComposer
);
}
#[test]
fn footer_toasts_stay_inside_the_closed_color_grammar() {
use crate::tui::app::StatusToastLevel;
for (level, expected) in [
(StatusToastLevel::Info, ChromeInk::Info),
(StatusToastLevel::Success, ChromeInk::Outcome),
(StatusToastLevel::Warning, ChromeInk::Attention),
(StatusToastLevel::Error, ChromeInk::Failure),
] {
assert_eq!(status_toast_ink(level), expected, "{level:?}");
let mut app = test_app();
app.ui_theme = crate::palette::ThemeId::Dracula.ui_theme();
app.push_status_toast("toast proof", level, None);
let area = Rect::new(0, 0, 160, 1);
let mut buf = Buffer::empty(area);
render(area, &mut buf, &mut app);
let rendered = (0..area.width)
.map(|x| buf[(x, 0)].symbol())
.collect::<String>();
let byte = rendered
.find("toast proof")
.unwrap_or_else(|| panic!("{level:?} toast should render: {rendered:?}"));
let x = rendered[..byte].width() as u16;
assert_eq!(
buf[(x, 0)].fg,
expected.color(&app.ui_theme),
"{level:?} must use the active theme's grammar slot"
);
}
}
#[test]
fn working_marker_uses_the_live_work_status_role() {
let app = test_app();
assert_eq!(ShellPhase::Working.color(&app), app.ui_theme.status_working);
assert_ne!(ShellPhase::Working.color(&app), app.ui_theme.info);
assert_eq!(
crate::tui::underwater::phase_ink(ShellPhase::Working),
ChromeInk::Active
);
assert_eq!(
crate::tui::underwater::phase_ink(ShellPhase::Failed),
ChromeInk::Failure
);
assert_ne!(
crate::tui::underwater::phase_ink(ShellPhase::Working).family(),
crate::palette::SemanticFamily::Failure
);
}
#[test]
fn working_band_names_tool_use_and_bounded_count_without_key_chorus() {
let mut app = test_app();
app.ui_locale = crate::localization::Locale::En;
app.is_loading = true;
app.turn_started_at = Some(Instant::now() - Duration::from_secs(12));
let mut active = ActiveCell::new();
active.push_tool(
"exec-1",
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command: "cargo build -p tui".to_string(),
status: ToolStatus::Running,
output: None,
live_output: None,
shell_task_id: None,
owner_agent_id: None,
owner_agent_name: None,
started_at: app.turn_started_at,
duration_ms: None,
stale_elapsed_since_output_ms: None,
source: ExecSource::Assistant,
interaction: None,
output_summary: None,
})),
);
app.active_cell = Some(active);
let backend = TestBackend::new(80, 1);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app))
.expect("draw");
let text = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(text.contains("using tool"), "{text}");
assert!(text.contains("×1"), "{text}");
assert!(
!text.contains("12s"),
"tool elapsed time belongs to the live tool row: {text}"
);
assert!(
!text.contains("run ×1"),
"detail repeated the tool verb: {text}"
);
assert!(
!text.contains("Alt+?") && !text.contains("F1:"),
"live phase strip stays quiet: {text}"
);
assert!(text.contains("Esc to interrupt"), "{text}");
}
#[test]
fn compact_activity_band_keeps_only_the_semantic_label() {
let mut app = test_app();
app.ui_locale = crate::localization::Locale::En;
app.turn_started_at = Some(Instant::now() - Duration::from_secs(12));
let mut active = ActiveCell::new();
active.push_tool(
"exec-compact",
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command: "cargo build -p tui".to_string(),
status: ToolStatus::Running,
output: None,
live_output: None,
shell_task_id: None,
owner_agent_id: None,
owner_agent_name: None,
started_at: app.turn_started_at,
duration_ms: None,
stale_elapsed_since_output_ms: None,
source: ExecSource::Assistant,
interaction: None,
output_summary: None,
})),
);
app.active_cell = Some(active);
let backend = TestBackend::new(50, 1);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app))
.expect("draw");
let text = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(text.contains("using tool"), "{text}");
assert!(
!text.contains('×'),
"compact strip leaked count detail: {text}"
);
assert!(
!text.contains("12s"),
"compact strip leaked timing detail: {text}"
);
}
fn strip_text(app: &mut App, width: u16) -> String {
let backend = TestBackend::new(width, 1);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| render(frame.area(), frame.buffer_mut(), app))
.expect("draw");
terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>()
}
#[test]
fn working_band_keeps_elapsed_time_when_model_is_thinking() {
let mut app = test_app();
app.is_loading = true;
app.turn_started_at = Some(Instant::now() - Duration::from_secs(12));
assert_eq!(
working_detail(&app, LiveActivity::from_app(&app)).as_deref(),
Some("12s")
);
let backend = TestBackend::new(80, 1);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app))
.expect("draw");
let text = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(text.contains("Esc to interrupt"), "{text}");
}
#[test]
fn completed_band_keeps_unresolved_warning_visible() {
let mut app = test_app();
app.runtime_turn_status = Some("completed".to_string());
app.push_status_toast(
"Auto-denied exec_shell: denied earlier; restart Codewhale",
crate::tui::app::StatusToastLevel::Warning,
Some(12_000),
);
let backend = TestBackend::new(100, 1);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app))
.expect("draw");
let text = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(text.contains("done"), "completion phase missing: {text}");
assert!(
text.contains("Auto-denied exec_shell"),
"completion hid unresolved warning: {text}"
);
}
#[test]
fn cache_percentage_uses_wide_arithmetic_and_rounds() {
let mut app = test_app();
assert_eq!(session_cache_hit_percentage(&app), None);
app.session.total_cache_hit_tokens = 2;
app.session.total_cache_miss_tokens = 1;
assert_eq!(session_cache_hit_percentage(&app), Some(67));
app.session.total_cache_hit_tokens = u32::MAX;
app.session.total_cache_miss_tokens = u32::MAX;
assert_eq!(session_cache_hit_percentage(&app), Some(50));
}
#[test]
fn cache_chip_is_labeled_configurable_and_hidden_when_compact() {
let mut app = test_app();
app.status_items = vec![crate::config::StatusItem::Cache];
app.session.total_cache_hit_tokens = 7;
app.session.total_cache_miss_tokens = 3;
let backend = TestBackend::new(80, 1);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app))
.expect("draw");
let text = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(text.contains("cache 70%"), "{text}");
app.status_items.clear();
terminal
.draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app))
.expect("draw without cache");
let text = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(!text.contains("cache"), "{text}");
app.status_items = vec![crate::config::StatusItem::Cache];
let backend = TestBackend::new(50, 1);
let mut compact = Terminal::new(backend).expect("compact terminal");
compact
.draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app))
.expect("compact draw");
let text = compact
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(!text.contains("cache"), "compact strip: {text}");
}
fn app_with_session_metrics() -> App {
let mut app = test_app();
app.status_items = vec![crate::config::StatusItem::SessionMetrics];
app.turn_counter = 4;
app.session_metrics
.record_model_call(100, 2_000, Some(500), Some(2_400));
app.session_metrics
.record_model_call(20, 1_000, Some(300), Some(1_100));
app.session_metrics.record_tool_started("t1");
app.session_metrics.record_tool_completed("t1");
app.session.total_input_tokens = 9_300_000;
app.session.total_cache_hit_tokens = 99;
app.session.total_cache_miss_tokens = 1;
app
}
#[test]
fn session_metrics_strip_paints_every_group_when_the_row_has_room() {
let mut app = app_with_session_metrics();
let text = strip_text(&mut app, 190);
assert!(text.contains("4 turns · 3 steps"), "{text}");
assert!(text.contains("LLM 3.5s · Tool call"), "{text}");
assert!(text.contains("TTFT avg 400ms · 40 tok/s"), "{text}");
assert!(text.contains("Cache hit 99%"), "{text}");
assert!(text.contains("Input 9.3M"), "{text}");
assert!(text.contains("keys"), "{text}");
let text = strip_text(&mut app, 150);
assert!(
text.contains("DeepSeek · deepseek-v4-flash · max"),
"{text}"
);
assert!(text.contains("4 turns"), "{text}");
assert!(text.contains("LLM 3.5s"), "{text}");
assert!(text.contains("Cache hit 99%"), "{text}");
assert!(text.contains("Input 9.3M"), "{text}");
assert!(text.contains("keys"), "{text}");
}
#[test]
fn session_metrics_strip_sheds_groups_on_narrow_rows_and_never_truncates() {
let mut app = app_with_session_metrics();
let normal = strip_text(&mut app, 100);
assert!(normal.contains("Input 9.3M"), "{normal}");
assert!(normal.contains("Cache hit 99%"), "{normal}");
assert!(!normal.contains("tok/s"), "{normal}");
assert!(normal.contains("keys"), "{normal}");
let compact = strip_text(&mut app, 60);
for cell in ["9.3M", "99%", "3.5s", "4 turns"] {
if compact.contains(cell) {
assert!(
compact.contains(&format!("Input {}", "9.3M"))
|| compact.contains(&format!("Cache hit {}", "99%"))
|| compact.contains("LLM 3.5s")
|| compact.contains("4 turns"),
"{compact}"
);
}
}
assert!(!compact.contains("Tool call"), "{compact}");
assert!(!compact.contains("tok/s"), "{compact}");
}
#[test]
fn session_metrics_strip_is_hidden_when_compact() {
let mut app = app_with_session_metrics();
let text = strip_text(&mut app, 59);
assert!(!text.contains("turns"), "compact strip: {text}");
assert!(!text.contains("LLM"), "compact strip: {text}");
assert!(!text.contains('│'), "compact strip: {text}");
}
#[test]
fn session_metrics_strip_is_hidden_when_the_status_item_is_off_or_nothing_happened() {
let mut app = app_with_session_metrics();
app.status_items = vec![crate::config::StatusItem::Cache];
let text = strip_text(&mut app, 120);
assert!(!text.contains("turns"), "{text}");
assert!(text.contains("cache 99%"), "{text}");
let mut fresh = test_app();
fresh.status_items = vec![crate::config::StatusItem::SessionMetrics];
let text = strip_text(&mut fresh, 120);
assert!(!text.contains("turns"), "{text}");
assert!(!text.contains("│"), "{text}");
}
#[test]
fn session_metrics_strip_is_on_by_default() {
assert!(
crate::config::StatusItem::default_footer()
.contains(&crate::config::StatusItem::SessionMetrics)
);
assert_eq!(
crate::config::StatusItem::from_key("session_metrics"),
Some(crate::config::StatusItem::SessionMetrics)
);
}
}