use super::theme::{indicators, Theme};
use crate::runtime::{usage_for, AppState, AppStatus, ContextUsage};
use crate::terminal::{AnimationLevel, Rgb, TerminalPalette};
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Wrap};
use ratatui::Frame;
use unicode_width::UnicodeWidthStr;
const CONTEXT_MIN_WIDTH: u16 = 60;
const MODE_MIN_WIDTH: u16 = 80;
const TOKENS_MIN_WIDTH: u16 = 100;
const CONTEXT_WARN_PCT: f32 = 80.0;
const CONTEXT_CAUTION_PCT: f32 = 60.0;
pub struct StatusLine {
pub left: Vec<Span<'static>>,
pub right: Vec<Span<'static>>,
}
impl StatusLine {
pub fn new(left: Vec<Span<'static>>, right: Vec<Span<'static>>) -> Self {
Self { left, right }
}
}
pub fn render_status(frame: &mut Frame<'_>, area: Rect, line: StatusLine, theme: &Theme) {
let left_width = spans_width(&line.left);
let right_width = spans_width(&line.right);
let filler_width = area.width.saturating_sub(left_width + right_width);
let filler = Span::styled(" ".repeat(filler_width as usize), theme.status);
let mut spans = Vec::with_capacity(line.left.len() + line.right.len() + 1);
spans.extend(line.left);
spans.push(filler);
spans.extend(line.right);
let paragraph = Paragraph::new(Line::from(spans)).wrap(Wrap { trim: true });
frame.render_widget(paragraph, area);
}
fn spans_width(spans: &[Span<'static>]) -> u16 {
spans.iter().map(|span| span.content.width() as u16).sum()
}
pub fn build_status_line(state: &AppState, theme: &Theme) -> StatusLine {
let width = state.terminal_size.0;
let left = build_left_spans(state, theme, width);
let right = build_right_spans(state, theme, width);
StatusLine::new(left, right)
}
fn build_left_spans(state: &AppState, theme: &Theme, width: u16) -> Vec<Span<'static>> {
let (provider, model) = state
.active_conversation()
.map(|conv| {
(
conv.provider_id.to_string(),
conv.model.clone().unwrap_or_else(|| "default".to_string()),
)
})
.unwrap_or_else(|| ("-".to_string(), "-".to_string()));
let label = if width < CONTEXT_MIN_WIDTH {
provider
} else {
format!("{provider} · {model}")
};
vec![Span::styled(label, theme.status)]
}
fn build_right_spans(state: &AppState, theme: &Theme, width: u16) -> Vec<Span<'static>> {
let mut spans = Vec::new();
if width >= CONTEXT_MIN_WIDTH {
if let Some(usage) = state
.active_conversation()
.map(|conv| usage_for(conv, &state.config))
{
push_context_spans(&mut spans, usage, theme, width);
}
}
if width >= MODE_MIN_WIDTH {
push_mode_spans(&mut spans, state, theme);
}
let status_spans = build_status_spans(state, theme);
if !status_spans.is_empty() {
if !spans.is_empty() {
spans.push(Span::styled(" · ", theme.status));
}
spans.extend(status_spans);
}
spans
}
fn push_context_spans(
spans: &mut Vec<Span<'static>>,
usage: ContextUsage,
theme: &Theme,
width: u16,
) {
let percent = usage.percent().round() as u32;
let style = context_style(usage.percent(), theme);
spans.push(Span::styled(format!("{percent}% context"), style));
if width >= TOKENS_MIN_WIDTH {
spans.push(Span::styled(
format!(" · {}/{} tokens", usage.used_tokens, usage.max_tokens),
theme.status,
));
if usage.percent() >= CONTEXT_WARN_PCT {
spans.push(Span::styled(" · Context filling up", theme.status_warn));
}
}
}
fn push_mode_spans(spans: &mut Vec<Span<'static>>, state: &AppState, theme: &Theme) {
let label = match state.config.ui.navigation_mode {
crate::config::NavigationMode::Simple => "simple",
crate::config::NavigationMode::Vi => "vi",
};
if !spans.is_empty() {
spans.push(Span::styled(" · ", theme.status));
}
spans.push(Span::styled(format!("mode {label}"), theme.status));
}
fn context_style(percent: f32, theme: &Theme) -> ratatui::style::Style {
if percent >= CONTEXT_WARN_PCT {
theme.status_error
} else if percent >= CONTEXT_CAUTION_PCT {
theme.status_warn
} else {
theme.status_ok
}
}
fn build_status_spans(state: &AppState, theme: &Theme) -> Vec<Span<'static>> {
match &state.status {
AppStatus::Idle => idle_spans(theme),
AppStatus::Thinking => thinking_spans(state, theme),
AppStatus::Streaming => streaming_spans(state, theme),
AppStatus::Error(err) => error_spans(err, theme),
}
}
fn idle_spans(theme: &Theme) -> Vec<Span<'static>> {
vec![
Span::styled(format!("{} ", indicators::BULLET), theme.status),
Span::styled("idle".to_string(), theme.status),
]
}
fn thinking_spans(state: &AppState, theme: &Theme) -> Vec<Span<'static>> {
let mut spans = build_dynamic_indicator(state, theme, "Thinking...");
append_elapsed(&mut spans, state, theme);
spans
}
fn streaming_spans(state: &AppState, theme: &Theme) -> Vec<Span<'static>> {
let mut spans = build_dynamic_indicator(state, theme, "Streaming");
if let Some(tokens) = state.status_metrics.tokens() {
spans.push(Span::styled(format!(" · {tokens} tok"), theme.status));
}
append_elapsed(&mut spans, state, theme);
spans
}
fn error_spans(err: &str, theme: &Theme) -> Vec<Span<'static>> {
vec![
Span::styled(format!("{} ", indicators::CROSS), theme.status_error),
Span::styled(format!("error: {err}"), theme.error),
]
}
fn build_dynamic_indicator(state: &AppState, theme: &Theme, label: &str) -> Vec<Span<'static>> {
let palette = TerminalPalette::new(state.terminal_caps.color_level);
match state.animation.level(&state.terminal_caps) {
AnimationLevel::Shimmer => shimmer_indicator(label, state.animation.frame(), &palette),
AnimationLevel::Spinner => spinner_indicator(label, state.animation.frame(), theme),
AnimationLevel::Static => static_indicator(label, theme),
}
}
fn shimmer_indicator(label: &str, frame: u64, palette: &TerminalPalette) -> Vec<Span<'static>> {
let bullet_color = palette.blend(
Rgb::new(217, 119, 87), Rgb::new(255, 180, 140), pulse_intensity(frame),
);
let mut spans = vec![Span::styled(
format!("{} ", indicators::BULLET),
ratatui::style::Style::default().fg(bullet_color),
)];
let chars: Vec<char> = label.chars().collect();
let len = chars.len().max(1);
let center = (frame as usize) % len;
let base = Rgb::new(140, 135, 130); let highlight = Rgb::new(217, 119, 87);
for (idx, ch) in chars.into_iter().enumerate() {
let dist = idx.abs_diff(center);
let t = match dist {
0 => 1.0,
1 => 0.6,
2 => 0.3,
_ => 0.1,
};
let color = palette.blend(base, highlight, t);
spans.push(Span::styled(
ch.to_string(),
ratatui::style::Style::default().fg(color),
));
}
spans
}
fn spinner_indicator(label: &str, frame: u64, theme: &Theme) -> Vec<Span<'static>> {
let spinner = spinner_frame(frame);
vec![
Span::styled(format!("{spinner} "), theme.status_indicator),
Span::styled(label.to_string(), theme.status),
]
}
fn static_indicator(label: &str, theme: &Theme) -> Vec<Span<'static>> {
vec![
Span::styled(format!("{} ", indicators::BULLET), theme.status_indicator),
Span::styled(label.to_string(), theme.status),
]
}
fn append_elapsed(spans: &mut Vec<Span<'static>>, state: &AppState, theme: &Theme) {
let Some(ms) = state.status_metrics.elapsed_ms() else {
return;
};
let elapsed = format_elapsed(ms);
spans.push(Span::styled(format!(" · {elapsed}"), theme.status));
}
fn format_elapsed(ms: u128) -> String {
let secs = ms as f32 / 1000.0;
format!("{secs:.1}s")
}
fn spinner_frame(frame: u64) -> &'static str {
const FRAMES: [&str; 4] = ["◐", "◓", "◑", "◒"];
let idx = (frame % FRAMES.len() as u64) as usize;
FRAMES[idx]
}
fn pulse_intensity(frame: u64) -> f32 {
let cycle = (frame % 20) as f32 / 20.0;
let intensity = (cycle * std::f32::consts::PI * 2.0).sin();
(intensity + 1.0) / 2.0 }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pulse_intensity_range() {
for frame in 0..100 {
let intensity = pulse_intensity(frame);
assert!((0.0..=1.0).contains(&intensity));
}
}
#[test]
fn spinner_cycles() {
let frames: Vec<&str> = (0..8).map(spinner_frame).collect();
assert_eq!(frames[0], frames[4]);
assert_eq!(frames[1], frames[5]);
}
#[test]
fn format_elapsed_formats_correctly() {
assert_eq!(format_elapsed(1500), "1.5s");
assert_eq!(format_elapsed(10000), "10.0s");
}
}