use std::io::{self, IsTerminal, Write};
use std::sync::{Arc, Mutex};
use crate::context::{Activity, ContextStats, SharedStats, format_rate, format_tokens};
pub struct StatusLine {
stats: SharedStats,
size: Mutex<Option<(u16, u16)>>,
input: Mutex<Option<(String, usize, usize)>>,
}
pub fn terminal_size() -> Option<(u16, u16)> {
let mut size: libc::winsize = unsafe { std::mem::zeroed() };
let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut size) } == 0;
(ok && size.ws_row > 0 && size.ws_col > 0).then_some((size.ws_row, size.ws_col))
}
fn write_raw(bytes: &str) {
with_term_lock(|| emit(bytes));
}
fn emit(bytes: &str) {
let mut out = io::stdout().lock();
let _ = out.write_all(bytes.as_bytes());
let _ = out.flush();
}
static TERM_LOCK: Mutex<()> = Mutex::new(());
pub fn with_term_lock<R>(f: impl FnOnce() -> R) -> R {
let _guard = TERM_LOCK.lock().unwrap_or_else(|e| e.into_inner());
f()
}
fn draw_sequence(line: &str, rows: u16) -> String {
format!("\x1b7\x1b[{rows};1H\x1b[2K{line}\x1b8")
}
fn resize_erase_sequence(old_rows: u16, new_rows: u16) -> String {
if old_rows < new_rows { format!("\x1b7\x1b[{old_rows};1H\x1b[2K\x1b8") } else { String::new() }
}
pub fn reserve_bottom_row() -> &'static str {
"\n\x1b[1A\r\x1b[2K"
}
impl StatusLine {
pub fn install(stats: SharedStats) -> Option<Arc<Self>> {
if !io::stdout().is_terminal() || !io::stdin().is_terminal() || std::env::var_os("AGENTIC_NO_STATUS").is_some() {
return None;
}
let (rows, cols) = terminal_size().filter(|(rows, _)| *rows >= 5)?;
let status = Arc::new(Self { stats, size: Mutex::new(Some((rows, cols))), input: Mutex::new(None) });
status.draw();
Some(status)
}
pub(crate) fn draw_seq(&self) -> Option<String> {
let (rows, cols) = terminal_size()?;
let old_rows = {
let mut size = self.size.lock().unwrap();
let Some((old_rows, _)) = *size else {
return None; };
*size = Some((rows, cols));
old_rows
};
let input = self.input.lock().unwrap().clone();
let line = match input {
Some((text, cursor, queued)) => render_input(&text, cursor, queued, cols as usize),
None => render(&self.stats.lock().unwrap().clone(), cols as usize),
};
Some(format!("{}{}", resize_erase_sequence(old_rows, rows), draw_sequence(&line, rows)))
}
pub fn draw(&self) {
if let Some(seq) = self.draw_seq() {
write_raw(&seq);
}
}
pub fn set_input(&self, text: Option<(&str, usize)>, queued: usize) {
*self.input.lock().unwrap() = text.map(|(t, c)| (t.to_string(), c, queued));
self.draw();
}
pub fn resize(&self) {
self.draw();
}
pub fn clear(&self) {
write_raw("\x1b[H\x1b[2J\x1b[3J");
self.draw();
}
pub fn teardown(&self) {
let mut size = self.size.lock().unwrap();
if let Some((rows, _)) = size.take() {
write_raw(&format!("\x1b7\x1b[{rows};1H\x1b[2K\x1b8"));
}
}
}
const BG: &str = "\x1b[0;48;5;236;38;5;250m";
const RESET: &str = "\x1b[0m";
struct Segment {
text: String,
color: Option<&'static str>,
priority: u8,
}
pub fn render_input(text: &str, cursor: usize, queued: usize, cols: usize) -> String {
let prefix = " ✎ queue › ";
let hint_text = " Enter to queue ";
let full = (queued > 0).then(|| format!("⏸{queued} queued · /queue to edit"));
let short = (queued > 0).then(|| format!("⏸{queued}"));
let fits = |i: &Option<String>| {
let extra = i.as_deref().map_or(0, |i| i.chars().count() + 3);
prefix.chars().count() + text.chars().count().min(1) + 1 + hint_text.len() + extra <= cols
};
let indicator = match (full, short) {
(Some(f), Some(_)) if fits(&Some(f.clone())) => Some(f),
(Some(_), Some(s)) if fits(&Some(s.clone())) => Some(s),
_ => None,
};
let extra = indicator.as_deref().map_or(0, |i| i.chars().count() + 3);
let hint = if prefix.chars().count() + 1 + hint_text.len() + extra <= cols { hint_text } else { "" };
let flat: String = text.chars().map(|c| if c == '\n' { '⏎' } else { c }).collect();
let cursor = cursor.min(flat.chars().count());
let room = cols.saturating_sub(prefix.chars().count() + hint.len() + extra + 1);
let start = if flat.chars().count() > room {
cursor.saturating_sub(room / 2).min(flat.chars().count() - room)
} else {
0
};
let shown: String = flat.chars().skip(start).take(room).collect();
let cursor_col = cursor - start;
let before: String = shown.chars().take(cursor_col).collect();
let under: String = shown.chars().skip(cursor_col).take(1).collect();
let after: String = shown.chars().skip(cursor_col + 1).collect();
let cursor_glyph = if under.is_empty() { "█".to_string() } else { format!("\x1b[7m{under}\x1b[27m") };
let used = prefix.chars().count() + shown.chars().count() + 1;
let pad = cols.saturating_sub(used + hint.len() + extra);
let indicator = indicator
.map(|i| format!("\x1b[38;5;222m · {i}\x1b[38;5;244m"))
.unwrap_or_default();
format!(
"{BG}\x1b[1;38;5;117m{prefix}\x1b[0;48;5;236;38;5;255m{before}{cursor_glyph}{after}{}\x1b[38;5;244m{hint}{indicator}{RESET}",
" ".repeat(pad)
)
}
fn render(stats: &ContextStats, cols: usize) -> String {
let percent = stats.percent();
let threshold = stats.auto_compact.map(|t| t * 100.0);
let bar_color = match threshold {
Some(t) if percent >= t => "\x1b[38;5;203m",
_ if percent >= 90.0 => "\x1b[38;5;203m",
_ if percent >= 60.0 => "\x1b[38;5;221m",
_ => "\x1b[38;5;114m",
};
let filled = ((percent / 10.0).round() as usize).min(10);
let bar = format!("{}{}", "█".repeat(filled), "░".repeat(10 - filled));
let approx = if stats.calibrated { "" } else { "~" };
let model = if stats.model.is_empty() { stats.provider.clone() } else { format!("{}/{}", stats.provider, stats.model) };
let mut segments = vec![
Segment { text: format!(" {model} "), color: Some("\x1b[1;38;5;255m"), priority: 9 },
Segment {
text: format!(" {} ", stats.cwd),
color: None,
priority: 6,
},
Segment {
text: format!(" ctx {approx}{}/{} {percent:.0}% ", format_tokens(stats.tokens), format_tokens(stats.window)),
color: None,
priority: 8,
},
Segment { text: bar, color: Some(bar_color), priority: 5 },
Segment { text: format!(" {} msgs ", stats.messages), color: None, priority: 3 },
];
if let Some((done, total)) = stats.plan {
segments.push(Segment { text: format!(" plan {done}/{total} "), color: None, priority: 4 });
}
let mode_segment = match stats.mode {
crate::mode::AgentMode::Normal => None,
crate::mode::AgentMode::Plan => Some((" plan ", "\x1b[1;38;5;221m")),
crate::mode::AgentMode::Auto => Some((" auto ", "\x1b[1;38;5;114m")),
};
if let Some((text, color)) = mode_segment {
segments.push(Segment { text: text.to_string(), color: Some(color), priority: 10 });
}
if stats.session_input_tokens + stats.session_output_tokens > 0 {
segments.push(Segment {
text: format!(
" ↑{} ↓{} ",
format_tokens(stats.session_input_tokens as usize),
format_tokens(stats.session_output_tokens as usize)
),
color: None,
priority: 2,
});
}
if let Some(aic) = stats.session_aic {
segments.push(Segment { text: format!(" {aic:.1} AIC "), color: Some("\x1b[38;5;222m"), priority: 2 });
}
if stats.history_searches + stats.history_reads > 0 {
segments.push(Segment {
text: format!(" hist {}s {}r ", stats.history_searches, stats.history_reads),
color: None,
priority: 1,
});
}
let compact = match threshold {
Some(t) => format!(" auto-compact {t:.0}%"),
None => " auto-compact off".to_string(),
};
let compacted = if stats.compactions > 0 { format!(" ({}×)", stats.compactions) } else { String::new() };
segments.push(Segment { text: format!("{compact}{compacted} "), color: None, priority: 1 });
let activity = match &stats.activity {
Activity::Idle => None,
Activity::Thinking => Some(("● thinking…".to_string(), "\x1b[38;5;117m")),
Activity::Tool(name) => Some((format!("▶ {name}"), "\x1b[38;5;180m")),
Activity::Compacting => Some(("⟳ compacting…".to_string(), "\x1b[38;5;221m")),
};
if let Some((text, color)) = activity {
segments.push(Segment { text: format!(" {text} "), color: Some(color), priority: 7 });
}
if matches!(stats.activity, Activity::Thinking)
&& let Some(rate) = stats.tokens_per_sec
{
segments.push(Segment { text: format!(" {} ", format_rate(rate)), color: Some("\x1b[38;5;108m"), priority: 0 });
}
let width = |segments: &[Segment]| -> usize {
segments.iter().map(|s| s.text.chars().count()).sum::<usize>() + segments.len().saturating_sub(1)
};
while width(&segments) > cols && segments.len() > 1 {
let lowest = segments.iter().enumerate().min_by_key(|(_, s)| s.priority).map(|(i, _)| i).unwrap();
segments.remove(lowest);
}
let mut line = String::from(BG);
let mut used = 0;
for (i, segment) in segments.iter().enumerate() {
if i > 0 && used < cols {
line.push('│');
used += 1;
}
let text: String = segment.text.chars().take(cols.saturating_sub(used)).collect();
used += text.chars().count();
match segment.color {
Some(color) => {
line.push_str(color);
line.push_str(&text);
line.push_str(BG);
}
None => line.push_str(&text),
}
}
line.push_str(&" ".repeat(cols.saturating_sub(used)));
line.push_str(RESET);
line
}
#[cfg(test)]
mod tests {
use super::*;
fn visible(line: &str) -> String {
regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap().replace_all(line, "").to_string()
}
fn stats() -> ContextStats {
ContextStats {
provider: "work".into(),
model: "llama-b".into(),
tokens: 96_500,
calibrated: true,
window: 128_000,
messages: 42,
session_input_tokens: 310_000,
session_output_tokens: 12_400,
session_aic: None,
compactions: 1,
history_searches: 0,
history_reads: 0,
auto_compact: Some(0.8),
activity: Activity::Tool("bash".into()),
plan: Some((2, 5)),
cwd: "/tmp/project".into(),
tokens_per_sec: None,
mode: crate::mode::AgentMode::Normal,
}
}
#[test]
fn renders_all_segments_when_wide() {
let line = visible(&render(&stats(), 140));
assert_eq!(line.chars().count(), 140);
for part in ["work/llama-b", "ctx 96.5k/128k 75%", "42 msgs", "↑310k ↓12.4k", "auto-compact 80% (1×)", "▶ bash", "plan 2/5"] {
assert!(line.contains(part), "{part} missing from {line:?}");
}
}
#[test]
fn drops_low_priority_segments_when_narrow() {
let line = visible(&render(&stats(), 50));
assert_eq!(line.chars().count(), 50);
assert!(line.contains("work/llama-b"), "{line:?}");
assert!(line.contains("ctx 96.5k/128k"), "{line:?}");
assert!(!line.contains("auto-compact"), "{line:?}");
}
#[test]
fn shows_aic_only_when_reported() {
assert!(!visible(&render(&stats(), 140)).contains("AIC"));
let with_aic = ContextStats { session_aic: Some(0.0541), ..stats() };
let line = visible(&render(&with_aic, 140));
assert!(line.contains("0.1 AIC"), "{line:?}");
}
#[test]
fn shows_history_usage_only_when_used() {
assert!(!visible(&render(&stats(), 140)).contains("hist "));
let used = ContextStats { history_searches: 3, history_reads: 1, ..stats() };
assert!(visible(&render(&used, 200)).contains("hist 3s 1r"), "{used:?}");
}
#[test]
fn marks_uncalibrated_estimates() {
let line = visible(&render(&ContextStats { calibrated: false, ..stats() }, 140));
assert!(line.contains("ctx ~96.5k"), "{line:?}");
}
#[test]
fn input_row_shows_the_queue_count() {
let line = visible(&render_input("fix the typo", 12, 2, 100));
assert!(line.contains("✎ queue › fix the typo"), "{line:?}");
assert!(line.contains("⏸2 queued · /queue to edit"), "{line:?}");
assert_eq!(line.chars().count(), 100, "fills the width: {line:?}");
let line = visible(&render_input("fix the typo", 12, 0, 100));
assert!(line.contains("✎ queue › fix the typo"), "{line:?}");
assert!(!line.contains("queued"), "{line:?}");
let line = visible(&render_input("", 0, 1, 100));
assert!(line.contains("⏸1 queued"), "{line:?}");
let line = visible(&render_input("a very long line being typed here", 33, 3, 40));
assert_eq!(line.chars().count(), 40, "{line:?}");
assert!(line.contains("⏸3"), "{line:?}");
for cols in 20..=30 {
let line = visible(&render_input("typing here", 11, 0, cols));
assert!(line.chars().count() <= cols, "row overflows at {cols} cols: {line:?}");
}
}
#[test]
fn input_row_marks_the_cursor_in_a_multiline_queued_line() {
let line = visible(&render_input("first line\nsecond line", 4, 2, 100));
assert!(line.contains("✎ queue › firs"), "{line:?}");
assert!(line.contains("⏎"), "newline shown as a glyph: {line:?}");
assert!(line.contains("⏸2 queued · /queue to edit"), "{line:?}");
assert!(line.chars().count() <= 100, "never overflows the width: {line:?}");
}
#[test]
fn shows_output_rate_only_while_generating() {
let generating = ContextStats { activity: Activity::Thinking, tokens_per_sec: Some(2.0), ..stats() };
assert!(visible(&render(&generating, 160)).contains("2.0 tok/s"), "rate should show while thinking");
let fast = ContextStats { activity: Activity::Thinking, tokens_per_sec: Some(12.4), ..stats() };
assert!(visible(&render(&fast, 160)).contains("12 tok/s"), "fast rate rounds to integer");
let tooling = ContextStats { tokens_per_sec: Some(2.0), ..stats() };
assert!(!visible(&render(&tooling, 160)).contains("tok/s"), "rate hidden outside generation");
let idle = ContextStats { activity: Activity::Idle, tokens_per_sec: None, ..stats() };
assert!(!visible(&render(&idle, 160)).contains("tok/s"), "rate hidden when idle");
}
#[test]
fn drops_output_rate_first_on_narrow_widths() {
let generating = ContextStats { activity: Activity::Thinking, tokens_per_sec: Some(2.0), ..stats() };
let line = visible(&render(&generating, 50));
assert_eq!(line.chars().count(), 50);
assert!(line.contains("work/llama-b"), "{line:?}");
assert!(!line.contains("tok/s"), "rate should be dropped first: {line:?}");
}
#[test]
fn draw_sequence_paints_the_bottom_row_and_restores_the_cursor() {
let seq = draw_sequence("STATUS", 24);
assert_eq!(seq, "\x1b7\x1b[24;1H\x1b[2KSTATUS\x1b8");
assert!(!seq.contains('r'), "no scroll region may be set: {seq:?}");
}
#[test]
fn resize_erases_a_stale_bar_only_when_the_terminal_grew() {
assert_eq!(resize_erase_sequence(24, 30), "\x1b7\x1b[24;1H\x1b[2K\x1b8");
assert_eq!(resize_erase_sequence(30, 24), "");
assert_eq!(resize_erase_sequence(24, 24), "");
assert!(!resize_erase_sequence(24, 30).contains('r'), "no scroll region may be set");
}
#[test]
fn draw_seq_folds_the_stale_bar_cleanup_before_the_repaint() {
let seq = format!("{}{}", resize_erase_sequence(24, 30), draw_sequence("STATUS", 30));
let erase_at = seq.find("\x1b[24;1H\x1b[2K").expect("old row erased");
let draw_at = seq.find("\x1b[30;1H").expect("bar painted at the new bottom");
assert!(erase_at < draw_at, "stale bar must be erased before the repaint: {seq:?}");
assert_eq!(
format!("{}{}", resize_erase_sequence(24, 24), draw_sequence("STATUS", 24)),
draw_sequence("STATUS", 24)
);
}
#[test]
fn reserve_bottom_row_keeps_the_prompt_one_row_up() {
let seq = reserve_bottom_row();
assert_eq!(seq, "\n\x1b[1A\r\x1b[2K");
assert!(!seq.contains('r'), "no scroll region may be set: {seq:?}");
}
}