use crate::session::{ActivityState, Session};
use std::collections::HashMap;
use std::time::{Duration, Instant};
pub const MARK_FOR: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Busy,
Waiting,
Stopped,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
NeedsInput,
Stopped,
}
#[derive(Debug, Clone)]
pub struct Rang {
pub key: String,
pub label: String,
pub reason: Reason,
pub at: Instant,
}
#[derive(Default)]
pub struct Notifier {
pub enabled: bool,
watched: HashMap<String, (State, String)>,
pub last: Option<Rang>,
}
impl Notifier {
pub fn new(enabled: bool) -> Self {
Notifier {
enabled,
..Default::default()
}
}
pub fn observe(&mut self, sessions: &[Session]) {
let mut crossed: Vec<Rang> = Vec::new();
let mut next = HashMap::with_capacity(self.watched.len());
for session in sessions.iter().filter(|s| s.is_running()) {
let key = session.key();
let state = state_of(session);
let label = session.display_label().to_string();
if let Some((State::Busy, _)) = self.watched.remove(&key)
&& state == State::Waiting
{
crossed.push(Rang {
key: key.clone(),
label: label.clone(),
reason: Reason::NeedsInput,
at: Instant::now(),
});
}
if state == State::Busy && self.last.as_ref().is_some_and(|r| r.key == key) {
self.last = None;
}
next.insert(key, (state, label));
}
for (key, (state, label)) in self.watched.drain() {
if state == State::Busy {
crossed.push(Rang {
key,
label,
reason: Reason::Stopped,
at: Instant::now(),
});
}
}
self.watched = next;
if !self.enabled || crossed.is_empty() {
return;
}
let extra = crossed.len() - 1;
let rang = crossed.swap_remove(0);
ring(&desktop_text(&rang, extra));
self.last = Some(rang);
}
pub fn rang_recently(&self, key: &str) -> bool {
self.last
.as_ref()
.is_some_and(|r| r.key == key && r.at.elapsed() < MARK_FOR)
}
pub fn footer(&self, selected: Option<&str>) -> Option<String> {
let rang = self.last.as_ref()?;
if selected == Some(rang.key.as_str()) {
return None;
}
let secs = rang.at.elapsed().as_secs();
let ago = if secs < 60 {
format!("{secs}s")
} else {
format!("{}m", secs / 60)
};
Some(format!(
"Bell: ◉ {} · {} · {ago} ago · b jumps to it",
rang.label,
match rang.reason {
Reason::NeedsInput => "waiting for input",
Reason::Stopped => "stopped",
}
))
}
}
fn state_of(session: &Session) -> State {
if !session.is_running() {
return State::Stopped;
}
match session.activity_state {
ActivityState::WaitingForInput => State::Waiting,
ActivityState::Working | ActivityState::ApiError => State::Busy,
}
}
fn desktop_text(rang: &Rang, extra: usize) -> String {
let what = match rang.reason {
Reason::NeedsInput => "is waiting for input",
Reason::Stopped => "stopped",
};
let more = if extra > 0 {
format!(" (+{extra} more)")
} else {
String::new()
};
format!("cctop: {} {what}{more}", rang.label)
}
fn ring(text: &str) {
use std::io::Write;
if cfg!(test) {
return;
}
let mut out = std::io::stdout();
let _ = write!(out, "\x07\x1b]9;{}\x07", sanitize(text));
let _ = out.flush();
}
fn sanitize(text: &str) -> String {
text.chars().filter(|c| !c.is_control()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pricing::Provider;
fn session(id: &str, running: bool, state: ActivityState) -> Session {
let mut s = Session::new(Provider::Claude, id.into());
s.label_source = format!("/home/x/{id}");
s.abbrev_label = id.into();
s.activity_state = state;
if running {
s.process = Some(crate::proc::ProcInfo::default());
}
s
}
#[test]
fn a_session_that_stops_working_rings_once_and_not_again() {
let mut n = Notifier {
enabled: true,
..Default::default()
};
let busy = vec![session("a", true, ActivityState::Working)];
n.observe(&busy);
assert!(n.last.is_none(), "still working, nothing to say");
let waiting = vec![session("a", true, ActivityState::WaitingForInput)];
n.observe(&waiting);
let first = n.last.clone().expect("the crossing rings");
assert_eq!(first.reason, Reason::NeedsInput);
n.observe(&waiting);
assert_eq!(
n.last.as_ref().map(|r| r.at),
Some(first.at),
"a session that is still waiting must not ring again"
);
}
#[test]
fn a_busy_session_that_disappears_rings_as_stopped() {
let mut n = Notifier {
enabled: true,
..Default::default()
};
n.observe(&[session("a", true, ActivityState::Working)]);
n.observe(&[session("a", false, ActivityState::Working)]);
let rang = n.last.as_ref().expect("an agent that exits is news");
assert_eq!(rang.reason, Reason::Stopped);
assert_eq!(rang.label, "a");
n.last = None;
n.observe(&[]);
assert!(n.last.is_none());
}
#[test]
fn a_session_first_seen_idle_never_rings_for_being_idle() {
let mut n = Notifier {
enabled: true,
..Default::default()
};
let waiting = vec![session("a", true, ActivityState::WaitingForInput)];
n.observe(&waiting);
n.observe(&waiting);
assert!(n.last.is_none());
}
#[test]
fn the_state_machine_stays_warm_while_notifications_are_off() {
let mut n = Notifier::default();
n.observe(&[session("a", true, ActivityState::Working)]);
n.enabled = true;
n.observe(&[session("a", true, ActivityState::WaitingForInput)]);
assert!(
n.last.is_some(),
"the busy state seen before the toggle still counts"
);
}
#[test]
fn going_back_to_work_answers_the_bell() {
let mut n = Notifier {
enabled: true,
..Default::default()
};
n.observe(&[session("a", true, ActivityState::Working)]);
n.observe(&[session("a", true, ActivityState::WaitingForInput)]);
assert!(n.last.is_some());
n.observe(&[session("a", true, ActivityState::Working)]);
assert!(n.last.is_none(), "answered, so stop naming it");
}
#[test]
fn the_footer_names_the_session_until_it_is_selected() {
let mut n = Notifier {
enabled: true,
..Default::default()
};
n.observe(&[session("a", true, ActivityState::Working)]);
n.observe(&[session("a", true, ActivityState::WaitingForInput)]);
let key = n.last.as_ref().unwrap().key.clone();
assert!(n.footer(None).is_some_and(|t| t.contains('a')));
assert!(n.footer(Some("claude:other")).is_some());
assert!(n.footer(Some(&key)).is_none());
assert!(n.rang_recently(&key));
assert!(!n.rang_recently("claude:other"));
}
#[test]
fn a_label_cannot_break_out_of_the_notification() {
let text = sanitize("proj\x07\x1b]0;pwned\x07");
assert!(!text.contains('\x07'));
assert!(!text.contains('\x1b'));
assert_eq!(text, "proj]0;pwned");
}
#[test]
fn one_bell_for_a_refresh_that_finishes_several_sessions() {
let rang = Rang {
key: "claude:a".into(),
label: "alpha".into(),
reason: Reason::NeedsInput,
at: Instant::now(),
};
assert_eq!(desktop_text(&rang, 0), "cctop: alpha is waiting for input");
assert_eq!(
desktop_text(&rang, 2),
"cctop: alpha is waiting for input (+2 more)"
);
}
}