use std::io::{self, IsTerminal, Write};
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use crate::context::{Activity, ContextStats, SharedStats, format_rate, format_tokens};
pub struct StatusLine {
stats: SharedStats,
size: Mutex<Option<(u16, u16)>>,
input: Mutex<Option<String>>,
}
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 CURSOR_REPORT: (Mutex<(bool, Option<u16>)>, Condvar) = (Mutex::new((false, None)), Condvar::new());
const CURSOR_REPORT_WAIT: Duration = Duration::from_millis(150);
pub fn cursor_reported(row: u16) -> bool {
let (lock, signal) = &CURSOR_REPORT;
let mut report = lock.lock().unwrap_or_else(|e| e.into_inner());
if !report.0 {
return false;
}
report.1 = Some(row);
signal.notify_all();
true
}
fn parse_cursor_report(bytes: &[u8]) -> Option<(u16, u16)> {
let start = bytes.windows(2).rposition(|w| w == b"\x1b[")? + 2;
let body = std::str::from_utf8(&bytes[start..]).ok()?.strip_suffix('R')?;
let (row, col) = body.split_once(';')?;
Some((row.parse().ok()?, col.parse().ok()?))
}
pub fn cursor_report_row(seq: &[u8]) -> Option<u16> {
parse_cursor_report(seq).map(|(row, _)| row)
}
fn query_cursor_direct() -> Option<(u16, u16)> {
let mut original: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut original) } != 0 {
return None;
}
let mut raw = original;
raw.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
raw.c_cc[libc::VMIN] = 0;
raw.c_cc[libc::VTIME] = 0;
if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) } != 0 {
return None;
}
emit("\x1b[6n");
let deadline = std::time::Instant::now() + CURSOR_REPORT_WAIT * 2;
let mut reply = Vec::new();
while !reply.ends_with(b"R") {
let left = deadline.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
break;
}
let mut fd = libc::pollfd { fd: libc::STDIN_FILENO, events: libc::POLLIN, revents: 0 };
if unsafe { libc::poll(&mut fd, 1, left.as_millis() as i32) } <= 0 {
break;
}
let mut byte = 0u8;
if unsafe { libc::read(libc::STDIN_FILENO, (&mut byte as *mut u8).cast(), 1) } != 1 {
break;
}
reply.push(byte);
}
unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &original) };
parse_cursor_report(&reply)
}
pub fn anchor_sequence(gap: u16) -> String {
if gap == 0 {
return String::new();
}
format!("\x1b7\x1b[1;1H\x1b[{gap}L\x1b8\x1b[{gap}B")
}
fn install_sequence(rows: u16, cursor: Option<(u16, u16)>) -> String {
let bottom = scroll_region_bottom(rows);
match cursor {
Some((row, col)) if row < bottom => {
format!("\x1b[1;{bottom}r\x1b[{row};{col}H{}", anchor_sequence(bottom - row))
}
_ => format!("\n\x1b[1A\x1b7\x1b[1;{bottom}r\x1b8"),
}
}
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 scroll_region_bottom(rows: u16) -> u16 {
rows.max(2) - 1
}
fn resize_sequence(rows: u16) -> String {
format!("\x1b7\x1b[r\x1b8\x1b[J\x1b7\x1b[1;{}r\x1b8", scroll_region_bottom(rows))
}
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)?;
write_raw(&install_sequence(rows, query_cursor_direct()));
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
write_raw("\x1b7\x1b[r\x1b8");
previous(info);
}));
let status = Arc::new(Self { stats, size: Mutex::new(Some((rows, cols))), input: Mutex::new(None) });
status.draw();
Some(status)
}
pub fn draw(&self) {
let Some((rows, cols)) = terminal_size() else { return };
let mut size = self.size.lock().unwrap();
let prefix = match *size {
None => return, Some((old_rows, old_cols)) if (old_rows, old_cols) != (rows, cols) => {
*size = Some((rows, cols));
resize_sequence(rows)
}
Some(_) => String::new(),
};
let line = match self.input.lock().unwrap().as_deref() {
Some(text) => render_input(text, cols as usize),
None => render(&self.stats.lock().unwrap().clone(), cols as usize),
};
write_raw(&format!("{prefix}\x1b7\x1b[{rows};1H\x1b[2K{line}\x1b8"));
}
pub fn set_input(&self, text: Option<&str>) {
*self.input.lock().unwrap() = text.map(str::to_string);
self.draw();
}
pub fn resize(&self) {
self.draw();
self.anchor();
}
fn anchor(&self) {
if !crate::lineedit::key_mode_active() {
return;
}
let Some((rows, _)) = *self.size.lock().unwrap() else { return };
with_term_lock(|| {
let (lock, signal) = &CURSOR_REPORT;
let mut report = lock.lock().unwrap_or_else(|e| e.into_inner());
*report = (true, None);
emit("\x1b[6n");
let (mut report, _) = signal
.wait_timeout_while(report, CURSOR_REPORT_WAIT, |r| r.1.is_none())
.unwrap_or_else(|e| e.into_inner());
let row = report.1.take();
report.0 = false;
drop(report);
if let Some(row) = row {
emit(&anchor_sequence(scroll_region_bottom(rows).saturating_sub(row)));
}
});
}
pub fn clear(&self) {
{
let size = self.size.lock().unwrap();
let Some((rows, _cols)) = *size else { return };
let bottom = scroll_region_bottom(rows);
write_raw(&format!("\x1b[r\x1b[H\x1b[2J\x1b[3J\x1b[1;{bottom}r\x1b[{bottom};1H"));
}
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\x1b[r\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,
}
fn render_input(text: &str, cols: usize) -> String {
let prefix = " ✎ steer › ";
let hint = " Enter to send ";
let room = cols.saturating_sub(prefix.chars().count() + hint.len() + 1);
let count = text.chars().count();
let shown: String = if count > room { text.chars().skip(count - room).collect() } else { text.to_string() };
let used = prefix.chars().count() + shown.chars().count() + 1;
let pad = cols.saturating_sub(used + hint.len());
format!(
"{BG}\x1b[1;38;5;117m{prefix}\x1b[0;48;5;236;38;5;255m{shown}█{}\x1b[38;5;244m{hint}{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 });
}
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,
});
}
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,
compactions: 1,
auto_compact: Some(0.8),
activity: Activity::Tool("bash".into()),
plan: Some((2, 5)),
cwd: "/tmp/project".into(),
tokens_per_sec: None,
}
}
#[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 marks_uncalibrated_estimates() {
let line = visible(&render(&ContextStats { calibrated: false, ..stats() }, 140));
assert!(line.contains("ctx ~96.5k"), "{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 resize_sequence_erases_below_cursor_and_repins() {
let seq = resize_sequence(40);
assert!(seq.starts_with("\x1b7"), "cursor not saved first: {seq:?}");
assert!(seq.contains("\x1b[r"), "region not reset to full screen: {seq:?}");
assert!(seq.contains("\x1b[r\x1b8\x1b[J"), "erase not from the restored cursor: {seq:?}");
assert!(seq.ends_with("\x1b[1;39r\x1b8"), "region not re-pinned / cursor not restored: {seq:?}");
}
#[test]
fn resize_sequence_addresses_no_absolute_row() {
for rows in [40, 24, 70, 110, 2, 1] {
let seq = resize_sequence(rows);
assert!(!seq.contains(";1H"), "addressed an absolute row for {rows} rows: {seq:?}");
}
}
#[test]
fn parses_cursor_position_reports() {
assert_eq!(parse_cursor_report(b"\x1b[12;5R"), Some((12, 5)));
assert_eq!(parse_cursor_report(b"typed\x1b[3;1R"), Some((3, 1)), "takes the report after typeahead");
assert_eq!(parse_cursor_report(b"\x1b[12;5"), None, "incomplete");
assert_eq!(parse_cursor_report(b"\x1b[A"), None, "an arrow key is not a report");
assert_eq!(cursor_report_row(b"\x1b[7;40R"), Some(7));
}
#[test]
fn anchor_scrolls_the_region_down_and_follows_with_the_cursor() {
assert_eq!(anchor_sequence(0), "");
assert_eq!(anchor_sequence(3), "\x1b7\x1b[1;1H\x1b[3L\x1b8\x1b[3B");
}
#[test]
fn install_bottom_anchors_the_conversation() {
assert_eq!(install_sequence(24, Some((8, 1))), format!("\x1b[1;23r\x1b[8;1H{}", anchor_sequence(15)));
let plain = "\n\x1b[1A\x1b7\x1b[1;23r\x1b8";
assert_eq!(install_sequence(24, Some((23, 1))), plain);
assert_eq!(install_sequence(24, Some((24, 1))), plain);
assert_eq!(install_sequence(24, None), plain);
}
#[test]
fn scroll_region_never_underflows_on_tiny_terminals() {
assert_eq!(scroll_region_bottom(1), 1);
assert_eq!(scroll_region_bottom(2), 1);
assert_eq!(scroll_region_bottom(24), 23);
}
}