use crate::tui::{
state::MissionControlState,
usage_format::{USAGE_SEPARATOR, compact_tokens, usage_metric_spans},
};
#[cfg(test)]
use crate::{
output::NormalizedUsageSnapshot, tui::usage_format::usage_fields as session_usage_fields,
};
use ratatui::{
Frame,
layout::Rect,
text::{Line, Span},
widgets::Paragraph,
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
pub(super) fn draw_prompt_footer(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
if area.width <= 2 || area.height < 2 {
return;
}
let available = usize::from(area.width - 2);
let full_left = footer_text(state, true);
let right = super::super::header::version_title(state);
let right_width = right.width().min(available / 2);
let left_width = available.saturating_sub(right_width + usize::from(right_width > 0));
let left = if full_left.width() <= left_width {
full_left
} else {
footer_text(state, false)
};
let y = area.bottom() - 1;
frame.render_widget(
Paragraph::new(truncate_line(left, left_width)),
Rect::new(area.x + 1, y, left_width as u16, 1),
);
frame.render_widget(
Paragraph::new(truncate_line(right, right_width)).right_aligned(),
Rect::new(
area.right() - 1 - right_width as u16,
y,
right_width as u16,
1,
),
);
}
fn footer_text(state: &MissionControlState, show_bar: bool) -> Line<'static> {
let auto = &state.auto_compaction;
let policy = state
.context_usage
.and_then(|usage| crate::agent::runner::auto_compaction_policy(auto, usage.max_tokens));
let auto_label = auto_compaction_label(auto, state.context_usage.map(|usage| usage.max_tokens));
let usage = state.context_usage;
let context = usage.map_or_else(
|| "—%".to_string(),
|usage| context_usage_label(usage.current_tokens, usage.max_tokens),
);
let muted = state.theme.muted(state.is_prompt_focused());
let session = usage_metric_spans(
state.session_usage_totals(),
state.session_cache_percent(),
muted,
state.theme,
);
let mut spans = vec![Span::styled(" Session: ", muted)];
for (index, field) in session.into_iter().enumerate() {
if index > 0 {
spans.push(Span::styled(USAGE_SEPARATOR, muted));
}
spans.push(field);
}
spans.push(Span::styled(" • Current Ctx: ", muted));
spans.push(Span::styled(context, muted.fg(state.theme.text_status())));
spans.push(Span::styled(" ", muted));
if show_bar {
let bar = context_bar(
usage.map_or(0, |usage| usage.current_tokens),
usage.map_or(0, |usage| usage.max_tokens),
policy.as_ref().map(|(tokens, _)| *tokens),
);
spans.push(Span::styled(bar, muted.fg(state.theme.text_accent())));
spans.push(Span::styled(" ", muted));
}
let total = usage.map_or_else(
|| "—".to_string(),
|usage| compact_tokens(usage.max_tokens as u64),
);
spans.push(Span::styled(total, muted.fg(state.theme.text_status())));
spans.push(Span::styled(" • ", muted));
spans.push(Span::styled(
auto_label,
muted.fg(state.theme.text_command()),
));
spans.push(Span::styled(" ", muted));
Line::from(spans)
}
fn auto_compaction_label(
auto: &crate::config::AutoCompactionSettings,
max_tokens: Option<usize>,
) -> String {
if !auto.enabled {
return "Auto off".to_string();
}
let threshold = match (auto.threshold_percent, auto.threshold_tokens, max_tokens) {
(Some(percent), Some(tokens), Some(max_tokens)) => {
let percent_tokens = (max_tokens as u128 * u128::from(percent)).div_ceil(100);
if u128::from(tokens) < percent_tokens {
format!("{} tokens", compact_tokens(tokens))
} else {
format!("{percent}%")
}
}
(Some(percent), None, _) => format!("{percent}%"),
(None, Some(tokens), _) => format!("{} tokens", compact_tokens(tokens)),
_ => match auto.threshold_display() {
Some(threshold) => threshold,
None => return "Auto off".to_string(),
},
};
format!("Compact at {threshold}")
}
fn context_usage_label(current_tokens: usize, max_tokens: usize) -> String {
let percent = if max_tokens == 0 {
"—".to_string()
} else {
((current_tokens as u128 * 100 / max_tokens as u128).min(999)).to_string()
};
format!("{percent}%")
}
fn context_bar(current_tokens: usize, max_tokens: usize, threshold: Option<usize>) -> String {
const CELLS: usize = 10;
let filled = if max_tokens == 0 {
0
} else {
((current_tokens as u128 * CELLS as u128 / max_tokens as u128).min(CELLS as u128)) as usize
};
let marker = threshold.filter(|_| max_tokens > 0).map(|tokens| {
((tokens as u128 * CELLS as u128 / max_tokens as u128).min((CELLS - 1) as u128)) as usize
});
let mut bar = String::from("[");
for index in 0..CELLS {
bar.push(if marker == Some(index) {
'|'
} else if index < filled {
'#'
} else {
'.'
});
}
bar.push(']');
bar
}
pub(super) fn truncate_line(line: Line<'static>, width: usize) -> Line<'static> {
if line.width() <= width {
return line;
}
if width == 0 {
return Line::default();
}
let mut remaining = width - 1;
let mut spans = Vec::new();
'spans: for span in line.spans {
let mut text = String::new();
for grapheme in span.content.graphemes(true) {
let cells = grapheme.width();
if cells > remaining {
spans.push(Span::styled(text, span.style));
break 'spans;
}
text.push_str(grapheme);
remaining -= cells;
}
spans.push(Span::styled(text, span.style));
}
spans.push(Span::raw("…"));
Line::from(spans).style(line.style)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{Terminal, backend::TestBackend};
fn footer_state() -> MissionControlState {
MissionControlState {
provider_ready: true,
provider: "provider".to_string(),
model: "model".to_string(),
context_usage: Some(crate::tui::state::ContextUsageState {
current_tokens: 124_000,
max_tokens: 200_000,
reasoning_tokens: None,
source: crate::output::ContextUsageSource::ProviderExact,
request_sequence: 1,
}),
auto_compaction: crate::config::AutoCompactionSettings {
enabled: true,
threshold_percent: Some(80),
..Default::default()
},
..Default::default()
}
}
fn footer_row(width: u16, state: &MissionControlState) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, 6)).unwrap();
terminal
.draw(|frame| super::super::draw_prompt(frame, frame.area(), state))
.unwrap();
(0..width)
.map(|x| terminal.backend().buffer()[(x, 5)].symbol())
.collect()
}
#[test]
fn wide_footer_places_usage_and_full_context_left_of_version() {
let row = footer_row(160, &footer_state());
assert!(
row.contains(" Session: 0(in)│ 0(out)│ —(cache) • Current Ctx: 62% [######..|.] 200k • Compact at 80% "),
"{row}"
);
assert!(
row.contains(&format!("MAGI-CODE v{}", env!("CARGO_PKG_VERSION"))),
"{row}"
);
assert!(row.find("Compact at 80%").unwrap() < row.find("MAGI-CODE").unwrap());
assert!(!row.contains("[Alt-M]"));
assert!(!row.contains("Prompt [Alt-P]"));
}
#[test]
fn narrow_footer_reserves_version_space_and_clips_without_overlap() {
let state = footer_state();
let row = footer_row(80, &state);
assert!(
row.contains("Session: 0(in)│ 0(out)│ —(cache) • Current Ctx: 62%…"),
"{row}"
);
assert!(
row.ends_with(&format!("MAGI-CODE v{}┘", env!("CARGO_PKG_VERSION"))),
"{row}"
);
for width in [0, 1, 2, 3, 12, 20, 40, 60] {
let row = footer_row(width, &state);
assert_eq!(row.width(), usize::from(width));
if width >= 3 {
assert!(row.ends_with('┘'), "{width}: {row}");
}
}
let row = footer_row(40, &state);
assert!(row.contains('…'), "{row}");
assert!(
row.ends_with(&format!("MAGI-CODE v{}┘", env!("CARGO_PKG_VERSION"))),
"{row}"
);
}
#[test]
fn cache_display_distinguishes_unknown_from_zero_and_retains_last_known_value() {
let mut state = footer_state();
assert!(footer_row(160, &state).contains(" —(cache)"));
state.last_known_session_cache_percent = Some(76);
assert!(footer_row(160, &state).contains(" 76%(cache)"));
state.last_known_session_cache_percent = Some(0);
assert!(footer_row(160, &state).contains(" 0%(cache)"));
}
#[test]
fn usage_fields_keep_their_width_across_updates() {
let mut state = footer_state();
state.context_usage = None;
assert!(
footer_text(&state, true)
.to_string()
.contains("Current Ctx: —% [..........] —")
);
for tokens in [
0,
9,
99,
999,
1_000,
9_000,
99_000,
999_499,
999_999,
1_000_000,
99_000_000,
999_000_000,
9_000_000_000,
99_000_000_000,
999_000_000_000,
u64::MAX,
] {
let usage = NormalizedUsageSnapshot {
effective_input: tokens,
output: tokens,
cache_read: tokens,
cache_known: true,
};
for cache in [None, Some(0), Some(76), Some(100)] {
let label = session_usage_fields(usage, cache).join("│");
assert_eq!(label.width(), 30, "{label}");
assert!(!label.contains("partial"));
}
}
}
#[test]
fn footer_uses_theme_colors_for_each_metric() {
let state = footer_state();
let mut terminal = Terminal::new(TestBackend::new(160, 6)).unwrap();
terminal
.draw(|frame| super::super::draw_prompt(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
let row = footer_row(160, &state);
for (label, color) in [
("0(in)", state.theme.transcript_user_color()),
("0(out)", state.theme.text_accent()),
(
"—(cache)",
state
.theme
.activity_status_color(crate::output::ActivityStatus::Success),
),
("[######", state.theme.text_accent()),
("62%", state.theme.text_status()),
] {
let byte = row.find(label).unwrap();
let x = row[..byte].width() as u16;
assert_eq!(buffer[(x, 5)].fg, color, "{label}");
}
}
#[test]
fn bar_retains_configured_marker_before_at_and_after_threshold() {
assert_eq!(context_bar(124_000, 200_000, Some(160_000)), "[######..|.]");
assert_eq!(context_bar(160_000, 200_000, Some(160_000)), "[########|.]");
assert_eq!(context_bar(180_000, 200_000, Some(160_000)), "[########|.]");
assert_eq!(context_bar(240_000, 200_000, Some(160_000)), "[########|#]");
assert_eq!(context_usage_label(240_000, 200_000), "120%");
assert_eq!(context_bar(0, 200_000, Some(200_000)), "[.........|]");
assert_eq!(context_bar(0, 200_000, Some(0)), "[|.........]");
assert_eq!(context_bar(0, 0, Some(0)), "[..........]");
assert_eq!(context_usage_label(0, 0), "—%");
}
#[test]
fn auto_label_and_marker_choose_earliest_configured_threshold() {
let mut state = footer_state();
state.auto_compaction.threshold_tokens = Some(100_000);
let text = footer_text(&state, true).to_string();
assert!(
text.contains("Current Ctx: 62% [#####|....] 200k • Compact at 100k tokens"),
"{text}"
);
state.auto_compaction.threshold_tokens = Some(180_000);
let text = footer_text(&state, true).to_string();
assert!(
text.contains("Current Ctx: 62% [######..|.] 200k • Compact at 80%"),
"{text}"
);
state.auto_compaction.enabled = false;
let text = footer_text(&state, true).to_string();
assert!(
text.contains("Current Ctx: 62% [######....] 200k • Auto off"),
"{text}"
);
let auto = crate::config::AutoCompactionSettings {
enabled: true,
threshold_percent: Some(50),
threshold_tokens: Some(501),
..Default::default()
};
assert_eq!(auto_compaction_label(&auto, Some(1001)), "Compact at 50%");
}
#[test]
fn clipping_preserves_unicode_cell_boundaries() {
let clipped = truncate_line(Line::from("ab界cd"), 4);
assert_eq!(clipped.to_string(), "ab…");
assert!(clipped.width() <= 4);
}
}