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};
use unicode_width::UnicodeWidthChar;
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 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)
}
#[cfg(test)]
pub fn for_test() -> Self {
Self { stats: SharedStats::default(), size: Mutex::new(Some((24, 80))), input: Mutex::new(None) }
}
pub fn stats_line(&self, cols: usize) -> String {
render(&self.stats.lock().unwrap().clone(), cols)
}
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 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),
};
write_raw(&format!("{prefix}\x1b7\x1b[{rows};1H\x1b[2K{line}\x1b8"));
}
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();
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,
}
pub fn render_input(text: &str, cursor: usize, queued: usize, cols: usize) -> String {
let prefix = " ✎ steer › ";
let hint_text = " Enter steers · Ctrl-Enter queues ";
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 + 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.chars().count() + 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.chars().count() + 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.chars().count() + 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)
)
}
const CWD_SEGMENT: usize = 1;
const CWD_MIN_CELLS: usize = 16;
pub fn tilde_path(path: &std::path::Path, home: Option<&std::path::Path>) -> String {
let home = home.filter(|h| h.components().count() > 1);
let resolved = home.and_then(|h| h.canonicalize().ok()).filter(|h| h.components().count() > 1);
let rest = home
.and_then(|h| path.strip_prefix(h).ok())
.or_else(|| resolved.as_deref().and_then(|h| path.strip_prefix(h).ok()));
match rest {
Some(rest) if rest.as_os_str().is_empty() => "~".to_string(),
Some(rest) => format!("~/{}", rest.display()),
None => path.display().to_string(),
}
}
fn elide_path(path: &str, budget: usize) -> String {
if cell_width(path) <= budget {
return path.to_string();
}
let parts: Vec<&str> = path.split('/').collect();
let head_len = if parts.first() == Some(&"") { 2 } else { 1 };
if parts.len() > head_len + 1 {
let head = parts[..head_len].join("/");
let mut keep = 0;
while head_len + keep + 1 < parts.len() {
let tail = parts[parts.len() - keep - 1..].join("/");
if cell_width(&format!("{head}/…/{tail}")) > budget {
break;
}
keep += 1;
}
if keep > 0 {
return format!("{head}/…/{}", parts[parts.len() - keep..].join("/"));
}
}
if budget == 0 {
return String::new();
}
let mut kept: Vec<char> = Vec::new();
let mut used = 1; for c in path.chars().rev() {
let w = UnicodeWidthChar::width(c).unwrap_or(0);
if used + w > budget {
break;
}
kept.push(c);
used += w;
}
std::iter::once('…').chain(kept.into_iter().rev()).collect()
}
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 label = if stats.smart_compact { "smart-compact" } else { "auto-compact" };
let compact = match threshold {
Some(t) => format!(" {label} {t:.0}%"),
None => format!(" {label} off"),
};
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| cell_width(&s.text)).sum::<usize>() + segments.len().saturating_sub(1)
};
let overflow = width(&segments).saturating_sub(cols);
if overflow > 0 {
let cwd_width = cell_width(&stats.cwd);
let budget = cwd_width.saturating_sub(overflow).max(CWD_MIN_CELLS.min(cwd_width));
segments[CWD_SEGMENT].text = format!(" {} ", elide_path(&stats.cwd, budget));
}
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 = fit_cells(&segment.text, cols.saturating_sub(used));
used += cell_width(&text);
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
}
fn cell_width(text: &str) -> usize {
text.chars().map(|c| UnicodeWidthChar::width(c).unwrap_or(0)).sum()
}
fn fit_cells(text: &str, budget: usize) -> String {
let mut out = String::new();
let mut used = 0;
for c in text.chars() {
if c.is_control() {
continue;
}
let w = UnicodeWidthChar::width(c).unwrap_or(0);
if used + w > budget {
break;
}
out.push(c);
used += w;
}
out
}
#[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),
smart_compact: false,
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 names_smart_compaction() {
let mut stats = stats();
stats.smart_compact = true;
let line = visible(&render(&stats, 140));
assert!(line.contains("smart-compact 80% (1×)"), "{line:?}");
assert!(!line.contains("auto-compact"), "{line:?}");
stats.auto_compact = None;
assert!(visible(&render(&stats, 140)).contains("smart-compact off"));
}
#[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 strips_control_chars_from_segments() {
let evil = ContextStats { cwd: "/tmp/a\nb\r\x1b[2Jc\td".into(), ..stats() };
let line = render(&evil, 140);
assert!(!line.contains('\n'), "newline leaked: {line:?}");
assert!(!line.contains('\r'), "carriage return leaked: {line:?}");
assert!(!line.contains('\t'), "tab leaked: {line:?}");
assert!(
!line.contains('\x1b') || visible(&line).chars().all(|c| !c.is_control()),
"control leaked into visible text: {:?}",
visible(&line)
);
assert!(visible(&line).contains("abcd") || visible(&line).contains("/tmp/a"), "{line:?}");
}
#[test]
fn home_is_shown_as_tilde() {
use std::path::Path;
let home = Some(Path::new("/Users/joshua"));
assert_eq!(tilde_path(Path::new("/Users/joshua"), home), "~");
assert_eq!(tilde_path(Path::new("/Users/joshua/workspace/nano"), home), "~/workspace/nano");
assert_eq!(tilde_path(Path::new("/Users/joshua2/x"), home), "/Users/joshua2/x");
assert_eq!(tilde_path(Path::new("/tmp/project"), home), "/tmp/project");
assert_eq!(tilde_path(Path::new("/tmp"), None), "/tmp");
assert_eq!(tilde_path(Path::new("/tmp"), Some(Path::new("/"))), "/tmp");
let dir = tempfile::tempdir().unwrap();
let real = dir.path().canonicalize().unwrap().join("real-home");
std::fs::create_dir_all(real.join("proj")).unwrap();
let link = dir.path().join("link-home");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert_eq!(tilde_path(&real.join("proj"), Some(&link)), "~/proj");
let root_link = dir.path().join("root-home");
std::os::unix::fs::symlink("/", &root_link).unwrap();
assert_eq!(tilde_path(Path::new("/tmp"), Some(&root_link)), "/tmp");
assert_eq!(tilde_path(Path::new("/any/where"), Some(&root_link)), "/any/where");
}
#[test]
fn elide_path_drops_middle_directories_first() {
let path = "~/workspace/rusty-harness/src/providers";
assert_eq!(elide_path(path, 100), path, "fits: unchanged");
assert_eq!(elide_path(path, 31), "~/…/rusty-harness/src/providers");
assert_eq!(elide_path(path, 30), "~/…/src/providers");
assert_eq!(elide_path(path, 20), "~/…/src/providers");
assert_eq!(elide_path(path, 15), "~/…/providers");
assert_eq!(elide_path(path, 8), "…oviders");
assert_eq!(elide_path(path, 1), "…");
assert_eq!(elide_path(path, 0), "");
assert_eq!(elide_path("/var/lib/docker/volumes/data", 22), "/var/…/volumes/data");
assert_eq!(elide_path("/verylongdirectory/name", 10), "…tory/name");
let wide = elide_path("/项目/工作目录/深层/路径", 12);
assert!(cell_width(&wide) <= 12, "{wide:?}");
assert!(wide.ends_with("路径"), "{wide:?}");
}
#[test]
fn a_long_cwd_is_elided_before_other_segments_are_dropped() {
let long = ContextStats { cwd: "~/workspace/clients/acme/monorepo/services/billing/api".into(), ..stats() };
let full = visible(&render(&long, 400));
assert!(full.contains(&long.cwd), "wide: shown whole {full:?}");
let wide_enough = cell_width(visible(&render(&stats(), 400)).trim_end()) + 20;
let line = visible(&render(&long, wide_enough));
assert!(line.contains("~/…/"), "{line:?}");
assert!(line.ends_with("api ") || line.contains("/api "), "the current directory stays: {line:?}");
for part in ["work/llama-b", "ctx 96.5k/128k 75%", "42 msgs", "auto-compact 80% (1×)", "▶ bash", "plan 2/5"]
{
assert!(line.contains(part), "{part} dropped instead of eliding the cwd: {line:?}");
}
assert!(cell_width(&line) <= wide_enough);
}
#[test]
fn status_row_never_exceeds_cols_with_wide_glyphs() {
let wide = ContextStats { cwd: "/项目/工作目录/深层/路径".into(), ..stats() };
for cols in 10..=140 {
let line = visible(&render(&wide, cols));
assert!(
cell_width(&line) <= cols,
"row overflows at {cols} cols: cell_width={} {line:?}",
cell_width(&line)
);
}
}
#[test]
fn input_row_shows_the_queue_count() {
let line = visible(&render_input("fix the typo", 12, 2, 100));
assert!(line.contains("✎ steer › 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("✎ steer › 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("✎ steer › 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 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);
}
}