use crate::conversation::ContextUsageDisplay;
use crate::session::session_config_view::{LocalConfigOption, LocalConfigView};
use crate::session::workspace_status::WorkspaceStatus;
use crate::settings::{ResolvedStatusLineSettings, StatusLineSegmentConfig, StatusLineStyle};
use crate::view::reasoning_bar::{reasoning_bar, reasoning_color, slot_bar};
use crate::theme::Theme;
use crate::view::wrap::{truncate_spans, truncate_to_width};
use acp_utils::config_option_id::ConfigOptionId;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use utils::ReasoningEffort;
pub struct StatusLineModel<'a> {
pub settings: &'a ResolvedStatusLineSettings,
pub config_options: &'a [LocalConfigOption],
pub workspace: &'a WorkspaceStatus,
pub agent_name: &'a str,
pub content_padding: usize,
pub context_usage: Option<ContextUsageDisplay>,
pub unhealthy_servers: usize,
pub waiting_for_response: bool,
pub exit_confirmation: bool,
}
pub struct StatusLine {
left: Line<'static>,
right: Line<'static>,
}
impl StatusLine {
pub fn new(model: &StatusLineModel<'_>, theme: &Theme) -> Self {
let settings = model.settings;
let mut left = render_segments(model, &settings.left, theme);
left.insert(0, Span::raw(" ".repeat(model.content_padding)));
let right = if model.exit_confirmation {
vec![Span::styled("Ctrl-C again to exit", Style::new().fg(theme.warning))]
} else {
render_segments(model, &settings.right, theme)
};
Self { left: Line::from(left), right: Line::from(right).right_aligned() }
}
pub fn height(&self, width: u16) -> u16 {
if width == 0 {
return 1;
}
if self.fits_on_one_row(usize::from(width)) { 1 } else { 2 }
}
fn fits_on_one_row(&self, width: usize) -> bool {
self.right.width() == 0 || self.left.width() + 1 + self.right.width() <= width
}
}
impl Widget for &StatusLine {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let width = usize::from(area.width);
let clipped =
|line: &Line<'static>| Line { spans: truncate_spans(&line.spans, width, Style::default()), ..line.clone() };
if self.fits_on_one_row(width) {
if self.right.width() == 0 {
clipped(&self.left).render(area, buf);
} else {
(&self.left).render(area, buf);
(&self.right).render(area, buf);
}
return;
}
let [left_row, right_row] = Layout::vertical([Constraint::Length(1); 2]).areas(area);
clipped(&self.left).render(left_row, buf);
clipped(&self.right).render(right_row, buf);
}
}
fn render_segments(
model: &StatusLineModel<'_>,
segments: &[StatusLineSegmentConfig],
theme: &Theme,
) -> Vec<Span<'static>> {
let config = LocalConfigView::new(model.config_options);
let mut spans: Vec<Span<'static>> = Vec::new();
for segment in segments {
let segment_spans = render_segment(segment, model, &config, theme);
if segment_spans.is_empty() {
continue;
}
if !spans.is_empty() {
spans.push(Span::styled(model.settings.separator.clone(), Style::new().fg(theme.text_secondary)));
}
spans.extend(segment_spans);
}
spans
}
fn render_segment(
segment: &StatusLineSegmentConfig,
model: &StatusLineModel<'_>,
config: &LocalConfigView<'_>,
theme: &Theme,
) -> Vec<Span<'static>> {
match segment {
StatusLineSegmentConfig::Cwd { max_width } => {
vec![styled(clamp(&model.workspace.display_dir, *max_width), theme.text_secondary)]
}
StatusLineSegmentConfig::GitRef => model
.workspace
.git_ref
.as_deref()
.map(|git_ref| vec![styled(git_ref.to_string(), theme.success)])
.unwrap_or_default(),
StatusLineSegmentConfig::Agent => vec![styled(model.agent_name.to_string(), theme.info)],
StatusLineSegmentConfig::Mode => config
.current_display_name(ConfigOptionId::Mode)
.map(|mode| vec![styled(mode, theme.text_secondary)])
.unwrap_or_default(),
StatusLineSegmentConfig::Model { max_width } => config
.current_display_name(ConfigOptionId::Model)
.map(|model| vec![styled(clamp(&model, *max_width), theme.success)])
.unwrap_or_default(),
StatusLineSegmentConfig::Reasoning => {
let levels = config.reasoning_levels();
if levels.is_empty() {
return Vec::new();
}
let effort = config.reasoning_effort();
let label = effort.map_or("none", ReasoningEffort::as_str);
vec![styled(reasoning_bar(effort, &levels, label.len()), reasoning_color(effort, &levels, theme))]
}
StatusLineSegmentConfig::Context => model
.context_usage
.map(|usage| vec![styled(context_bar(usage), context_color(usage, theme))])
.unwrap_or_default(),
StatusLineSegmentConfig::ServerHealth => {
let count = model.unhealthy_servers;
if model.waiting_for_response || count == 0 {
return Vec::new();
}
let message =
if count == 1 { "1 server needs auth".to_string() } else { format!("{count} servers unhealthy") };
vec![styled(message, theme.warning)]
}
StatusLineSegmentConfig::Text { value, style } => {
vec![styled(value.clone(), style.map_or(theme.text_secondary, |style| semantic_color(style, theme)))]
}
}
}
fn styled(text: String, color: Color) -> Span<'static> {
Span::styled(text, Style::new().fg(color))
}
fn clamp(text: &str, max_width: Option<u16>) -> String {
max_width.map_or_else(|| text.to_string(), |width| truncate_to_width(text, usize::from(width)))
}
fn semantic_color(style: StatusLineStyle, theme: &Theme) -> Color {
match style {
StatusLineStyle::Primary => theme.text_primary,
StatusLineStyle::Secondary => theme.text_secondary,
StatusLineStyle::Muted => theme.muted,
StatusLineStyle::Info => theme.info,
StatusLineStyle::Success => theme.success,
StatusLineStyle::Warning => theme.warning,
StatusLineStyle::Error => theme.error,
}
}
fn context_bar(usage: ContextUsageDisplay) -> String {
const TOTAL: u32 = 3;
let filled = (usage.used_tokens.saturating_mul(TOTAL) + usage.limit_tokens / 2) / usage.limit_tokens.max(1);
format!(
"ctx {} {} / {}",
slot_bar((filled as usize).min(TOTAL as usize), TOTAL as usize),
format_tokens(usage.used_tokens),
format_tokens(usage.limit_tokens)
)
}
fn context_color(usage: ContextUsageDisplay, theme: &Theme) -> Color {
let used_pct = usage.used_ratio() * 100.0;
if used_pct >= 86.0 {
theme.error
} else if used_pct >= 71.0 {
theme.warning
} else {
theme.text_secondary
}
}
fn format_tokens(count: u32) -> String {
match count {
count if count < 1_000 => count.to_string(),
count if count < 1_000_000 => format_with_unit(f64::from(count) / 1_000.0, "k"),
count => format_with_unit(f64::from(count) / 1_000_000.0, "M"),
}
}
fn format_with_unit(value: f64, unit: &str) -> String {
let rounded = (value * 10.0).round() / 10.0;
if (rounded - rounded.trunc()).abs() < f64::EPSILON {
format!("{rounded:.0}{unit}")
} else {
format!("{rounded:.1}{unit}")
}
}