use crossterm::terminal;
use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
static STICKY_ACTIVE: AtomicBool = AtomicBool::new(false);
pub fn is_active() -> bool {
STICKY_ACTIVE.load(Ordering::Relaxed)
}
static ACTIVE_BASH: AtomicU64 = AtomicU64::new(0);
pub fn bash_started() {
ACTIVE_BASH.fetch_add(1, Ordering::Relaxed);
}
pub fn bash_finished() {
let prev = ACTIVE_BASH.fetch_sub(1, Ordering::Relaxed);
if prev == 0 {
ACTIVE_BASH.store(0, Ordering::Relaxed);
}
}
pub fn active_bash_count() -> u64 {
ACTIVE_BASH.load(Ordering::Relaxed)
}
pub struct BashGuard;
impl Default for BashGuard {
fn default() -> Self {
Self::new()
}
}
impl BashGuard {
pub fn new() -> Self {
bash_started();
Self
}
}
impl Drop for BashGuard {
fn drop(&mut self) {
bash_finished();
}
}
#[derive(Clone)]
pub struct StickyState {
pub activity: Arc<std::sync::Mutex<String>>,
pub started: Instant,
pub tokens: Arc<AtomicU64>,
pub thinking_secs: Arc<AtomicU64>,
pub is_thinking: Arc<AtomicBool>,
pub mode: String,
pub model: String,
pub active_processes: Arc<AtomicU64>,
pub active_bash: Arc<AtomicU64>,
pub queued_count: Arc<AtomicU64>,
}
impl StickyState {
pub fn new(mode: &str, model: &str) -> Self {
Self {
activity: Arc::new(std::sync::Mutex::new("Working...".to_string())),
started: Instant::now(),
tokens: Arc::new(AtomicU64::new(0)),
thinking_secs: Arc::new(AtomicU64::new(0)),
is_thinking: Arc::new(AtomicBool::new(false)),
mode: mode.to_string(),
model: if model.len() > 25 {
model.chars().take(25).collect()
} else {
model.to_string()
},
active_processes: Arc::new(AtomicU64::new(0)),
active_bash: Arc::new(AtomicU64::new(0)),
queued_count: Arc::new(AtomicU64::new(0)),
}
}
pub fn set_activity(&self, msg: &str) {
if let Ok(mut a) = self.activity.lock() {
*a = msg.to_string();
}
}
pub fn add_tokens(&self, n: u64) {
self.tokens.fetch_add(n, Ordering::Relaxed);
}
}
pub fn fmt_elapsed(d: std::time::Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}
pub fn fmt_tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
format!("{}", n)
}
}
fn render_top(state: &StickyState, width: usize) -> String {
let activity = state.activity.lock().map(|a| a.clone()).unwrap_or_default();
let elapsed = fmt_elapsed(state.started.elapsed());
let tokens = fmt_tokens(state.tokens.load(Ordering::Relaxed));
let mut parts = vec![
format!(" ✱ {}", activity),
format!("({})", elapsed),
format!("↓ {} tokens", tokens),
];
let think_secs = state.thinking_secs.load(Ordering::Relaxed);
if think_secs > 0 || state.is_thinking.load(Ordering::Relaxed) {
let label = if state.is_thinking.load(Ordering::Relaxed) {
format!("thinking for {}s", state.started.elapsed().as_secs())
} else {
format!("thought for {}s", think_secs)
};
parts.push(label);
}
let content = parts.join(" · ");
if content.len() < width {
format!("{}{}", content, " ".repeat(width - content.len()))
} else {
content.chars().take(width).collect()
}
}
fn render_bottom(state: &StickyState, width: usize) -> String {
let procs = state.active_processes.load(Ordering::Relaxed);
let bash = state.active_bash.load(Ordering::Relaxed);
let queued = state.queued_count.load(Ordering::Relaxed);
let mut parts: Vec<String> = Vec::new();
let mode_str = match state.mode.as_str() {
"YOLO" => "▸▸ auto-approve on".to_string(),
"auto-edit" => "▸ auto-edit on".to_string(),
"daemon" => "▸▸ daemon mode".to_string(),
_ => "▸ confirm mode".to_string(),
};
parts.push(mode_str);
if bash > 0 {
parts.push(format!("{} bash", bash));
}
if procs > 0 {
parts.push(format!(
"{} process{}",
procs,
if procs == 1 { "" } else { "es" }
));
}
if queued > 0 {
parts.push(format!("↑ to edit queued ({})", queued));
}
parts.push("esc to interrupt".to_string());
let content = format!(" {}", parts.join(" · "));
if content.len() < width {
format!("{}{}", content, " ".repeat(width - content.len()))
} else {
content.chars().take(width).collect()
}
}
pub struct StickyBar {
state: StickyState,
height: u16,
width: u16,
}
impl StickyBar {
pub fn activate(state: StickyState) -> Option<Self> {
let (width, height) = terminal::size().ok()?;
if height < 3 {
return None;
}
STICKY_ACTIVE.store(true, Ordering::Relaxed);
Some(Self {
state,
height,
width,
})
}
pub fn update(&self) {
self.state
.active_bash
.store(active_bash_count(), Ordering::Relaxed);
let w = terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(self.width as usize);
let bottom = render_bottom(&self.state, w);
let _top = render_top(&self.state, w);
let mut err = io::stderr().lock();
write!(
err,
"\x1b7\x1b[{};1H\x1b[48;5;236m\x1b[38;5;245m{}\x1b[0m\x1b8",
self.height, bottom
)
.ok();
err.flush().ok();
}
pub fn finish(&self) {
self.state
.active_bash
.store(active_bash_count(), Ordering::Relaxed);
let w = terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(self.width as usize);
let top = render_top(&self.state, w);
let mut err = io::stderr().lock();
write!(err, "\x1b[{};1H\x1b[2K\x1b[A", self.height).ok();
err.flush().ok();
let mut out = io::stdout();
write!(out, "\x1b[90m{}\x1b[0m", top.trim()).ok();
out.flush().ok();
}
pub fn state(&self) -> &StickyState {
&self.state
}
fn teardown(&self) {
let mut err = io::stderr().lock();
write!(err, "\x1b[{};1H\x1b[2K", self.height).ok();
err.flush().ok();
STICKY_ACTIVE.store(false, Ordering::Relaxed);
}
}
impl Drop for StickyBar {
fn drop(&mut self) {
self.teardown();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fmt_elapsed_seconds() {
assert_eq!(fmt_elapsed(std::time::Duration::from_secs(5)), "5s");
assert_eq!(fmt_elapsed(std::time::Duration::from_secs(59)), "59s");
}
#[test]
fn fmt_elapsed_minutes() {
assert_eq!(fmt_elapsed(std::time::Duration::from_secs(60)), "1m 0s");
assert_eq!(fmt_elapsed(std::time::Duration::from_secs(125)), "2m 5s");
}
#[test]
fn fmt_elapsed_hours() {
assert_eq!(fmt_elapsed(std::time::Duration::from_secs(3600)), "1h 0m");
assert_eq!(fmt_elapsed(std::time::Duration::from_secs(3661)), "1h 1m");
}
#[test]
fn fmt_tokens_small() {
assert_eq!(fmt_tokens(0), "0");
assert_eq!(fmt_tokens(500), "500");
assert_eq!(fmt_tokens(999), "999");
}
#[test]
fn fmt_tokens_thousands() {
assert_eq!(fmt_tokens(1000), "1.0k");
assert_eq!(fmt_tokens(1500), "1.5k");
assert_eq!(fmt_tokens(42300), "42.3k");
}
#[test]
fn fmt_tokens_millions() {
assert_eq!(fmt_tokens(1_000_000), "1.0M");
assert_eq!(fmt_tokens(2_500_000), "2.5M");
}
#[test]
fn render_top_contains_activity() {
let state = StickyState::new("normal", "qwen3.5");
state.set_activity("Planning...");
let top = render_top(&state, 80);
assert!(
top.contains("Planning..."),
"top bar should contain activity"
);
assert!(top.contains("tokens"), "top bar should mention tokens");
}
#[test]
fn render_top_contains_elapsed() {
let state = StickyState::new("normal", "qwen3.5");
let top = render_top(&state, 80);
assert!(
top.contains("0s") || top.contains("1s"),
"should show elapsed time"
);
}
#[test]
fn render_top_shows_thinking_when_active() {
let state = StickyState::new("normal", "qwen3.5");
state.is_thinking.store(true, Ordering::Relaxed);
let top = render_top(&state, 120);
assert!(top.contains("thinking for"), "should show thinking status");
}
#[test]
fn render_top_shows_thought_duration() {
let state = StickyState::new("normal", "qwen3.5");
state.thinking_secs.store(5, Ordering::Relaxed);
let top = render_top(&state, 120);
assert!(
top.contains("thought for 5s"),
"should show completed thinking duration"
);
}
#[test]
fn render_bottom_normal_mode() {
let state = StickyState::new("normal", "qwen3.5");
let bottom = render_bottom(&state, 80);
assert!(
bottom.contains("confirm mode"),
"normal mode should say confirm"
);
assert!(bottom.contains("esc to interrupt"));
}
#[test]
fn render_bottom_yolo_mode() {
let state = StickyState::new("YOLO", "qwen3.5");
let bottom = render_bottom(&state, 80);
assert!(
bottom.contains("auto-approve on"),
"YOLO should say auto-approve"
);
}
#[test]
fn render_bottom_with_processes() {
let state = StickyState::new("normal", "qwen3.5");
state.active_processes.store(2, Ordering::Relaxed);
let bottom = render_bottom(&state, 80);
assert!(bottom.contains("2 processes"), "should show process count");
}
#[test]
fn render_bottom_single_process() {
let state = StickyState::new("normal", "qwen3.5");
state.active_processes.store(1, Ordering::Relaxed);
let bottom = render_bottom(&state, 80);
assert!(bottom.contains("1 process"), "single should not be plural");
assert!(!bottom.contains("1 processes"));
}
#[test]
fn sticky_state_add_tokens() {
let state = StickyState::new("normal", "test");
state.add_tokens(100);
state.add_tokens(200);
assert_eq!(state.tokens.load(Ordering::Relaxed), 300);
}
#[test]
fn sticky_state_model_truncation() {
let state = StickyState::new("normal", "a-very-long-model-name-that-exceeds-25-chars");
assert_eq!(state.model.len(), 25);
}
#[test]
fn render_top_pads_to_width() {
let state = StickyState::new("normal", "m");
let top = render_top(&state, 100);
assert_eq!(top.len(), 100, "should pad to exact width");
}
#[test]
fn render_bottom_pads_to_width() {
let state = StickyState::new("normal", "m");
let bottom = render_bottom(&state, 100);
assert_eq!(bottom.len(), 100, "should pad to exact width");
}
#[test]
fn is_active_default_false() {
let _ = is_active();
}
}