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)]
#[path = "../../tests/unit/ui/sticky_bar/sticky_bar_test.rs"]
mod tests;