use crate::tui::{
state::MissionControlState,
usage_format::{USAGE_SEPARATOR, compact_tokens, usage_metric_spans},
};
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.compaction_settings.auto_for_provider(
&state.provider,
state
.context_usage
.map_or(usize::MAX, |usage| usage.max_tokens),
);
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 elapsed = state
.session_active_time
.elapsed(std::time::Instant::now())
.as_secs();
let mut spans = vec![Span::styled(
format!(" Session: {}m{:02}s • ", elapsed / 60, elapsed % 60),
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)
}