cctop 0.28.0

An htop-like terminal monitor for AI coding agent sessions on Linux (Claude Code, Codex, Cursor, Devin, Gemini CLI, OpenCode, Pi, Windsurf)
//! Which finished turns you have not looked at yet.
//!
//! A session whose turn is over is amber in the table and green in the tab
//! bar, and it stays that way for as long as the agent sits at its prompt —
//! which is most of an agent's life. So the colour says "your move" about the
//! turn that ended a minute ago and the one that ended yesterday alike, and a
//! glance at a dozen tabs cannot tell you which of them has news. This is the
//! difference: a session that was *seen working* and then stopped, while you
//! were not looking at it, is "done, unseen" until you do look.
//!
//! # What counts as looking
//!
//! Two things, both of which put the agent's own words in front of you:
//!
//! - its pane is the focused pane of the tab on screen, or
//! - the dashboard is on screen and its row is the selected one — the bottom
//!   panels are about the selected row, so that is where its last reply is.
//!
//! A split's *other* pane is on screen too, but it is not counted: the tab bar
//! already treats the focused pane as the only one you are looking straight at
//! (see [`Tab::attention`](super::tabs::Tab::attention)), and a mark that
//! cleared because a pane was in the corner of your eye would clear on every
//! turn of a split you were never reading.
//!
//! # Local to this process
//!
//! Tab names and colours are shared across every cctop on the machine, written
//! onto the rmux sessions. Seen-ness is not. It is a fact about a *person
//! looking at a screen*, and two cctops are usually two screens — a laptop and
//! a phone over ssh — where having read a reply on one says nothing about the
//! other. The shared-state mechanism that exists (rmux session options) would
//! also make every focus change a write to the daemon, for a flag that is only
//! ever wanted by the screen that set it.
//!
//! ponytail: a cctop started after a turn ended has never seen that session
//! work, so it does not mark it. "Done" means a transition this process
//! watched happen, not a guess about what you have read elsewhere.

use crate::session::Session;
use std::collections::HashSet;

/// What a session is doing, as far as the unseen mark cares.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
    /// Mid-turn.
    Working,
    /// The turn is over and the prompt is yours.
    Stopped,
    /// Anything else — blocked on a question, sitting in an API error. Neither
    /// arms nor fires the mark: a question is already the loudest thing on the
    /// screen, and the turn it interrupted is not over.
    Other,
}

impl Phase {
    /// The phase of a row, read the way the status dot reads it.
    pub fn of(state: crate::session::ActivityState) -> Phase {
        use crate::session::ActivityState;
        match state {
            ActivityState::Working => Phase::Working,
            ActivityState::WaitingForInput => Phase::Stopped,
            ActivityState::Asking | ActivityState::ApiError => Phase::Other,
        }
    }
}

/// The sessions seen working since last looked at, and the ones that have
/// since stopped.
///
/// Keyed by [`Session::key`](crate::session::Session::key): the table's
/// identity for a row, which a tab reaches through its pane's pid.
#[derive(Debug, Default)]
pub struct Seen {
    /// Seen mid-turn, so a stop is a transition worth marking.
    working: HashSet<String>,
    /// Stopped while nobody was looking.
    done: HashSet<String>,
    /// This pass's keys, kept so the buffers are reused rather than formatted
    /// afresh. See [`Seen::observe`].
    live: Vec<String>,
}

impl Seen {
    /// Fold in where every live session is now, and which one is being looked
    /// at. Returns whether the set of unseen sessions changed, which is a frame
    /// owed.
    ///
    /// `live` is every session still running. One that is missing has ended
    /// or was never live, and is forgotten: its dot is hollow grey whatever
    /// this says, and a pid reused by a later agent must not inherit it.
    pub fn observe<'a>(
        &mut self,
        live: impl IntoIterator<Item = (&'a Session, Phase)>,
        viewed: Option<&str>,
    ) -> bool {
        let before = self.done.len();
        let mut changed = false;
        // The keys written first, into the buffer this struct keeps and reuses:
        // the caller used to hand over a `Vec` holding a fresh `String` per live
        // session, on a path that runs every pass of the loop. The `present` set
        // below borrows them, which is why they are all written before it is
        // built rather than as they are walked.
        let live: Vec<(&Session, Phase)> = live.into_iter().collect();
        self.live.clear();
        self.live.reserve(live.len());
        for (session, _) in &live {
            let next = self.live.len();
            self.live.push(String::new());
            session.key_into(&mut self.live[next]);
        }
        let present: HashSet<&str> = self.live.iter().map(String::as_str).collect();
        for ((_, phase), key) in live.iter().zip(&self.live) {
            let key = key.as_str();
            let looking = viewed == Some(key);
            match *phase {
                // Working again is a turn in progress, not one waiting to be
                // read: the mark goes, and comes back when this one ends.
                Phase::Working => {
                    // Asked first, because `insert` would format the key either
                    // way: a session already known to be working — nearly all of
                    // them, nearly always — would pay a `String` per pass to be
                    // told what it already knew.
                    if !self.working.contains(key) {
                        self.working.insert(key.to_string());
                    }
                    changed |= self.done.remove(key);
                }
                // Only a stop this process watched happen. Watched while you
                // were looking at it, it is a turn you saw end — nothing to
                // tell you.
                Phase::Stopped => {
                    if self.working.remove(key) && !looking {
                        changed |= self.done.insert(key.to_string());
                    }
                }
                Phase::Other => {}
            }
            if looking {
                changed |= self.done.remove(key);
            }
        }
        self.working.retain(|key| present.contains(key.as_str()));
        self.done.retain(|key| present.contains(key.as_str()));
        changed || self.done.len() != before
    }

    /// Whether this session finished a turn you have not looked at.
    pub fn is_done(&self, key: &str) -> bool {
        self.done.contains(key)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A row named `id`, which is what `observe` now takes rather than a key:
    /// the key is written once per pass, into a buffer, where before it was a
    /// `String` per live session handed over by the caller.
    fn row(id: &str) -> Session {
        Session::new(crate::pricing::Provider::Claude, id.into())
    }

    /// The whole life of the mark: armed by working, fired by stopping
    /// unwatched, cleared by looking — and not re-fired by the same stop.
    #[test]
    fn a_turn_that_ends_unwatched_is_marked_until_looked_at() {
        let mut seen = Seen::default();
        let a = row("a");
        let key = a.key();
        assert!(!seen.observe([(&a, Phase::Working)], None));
        assert!(!seen.is_done(&key), "working is not done");

        assert!(seen.observe([(&a, Phase::Stopped)], None), "no frame owed");
        assert!(seen.is_done(&key), "an unwatched stop was not marked");

        // Still stopped, still unwatched: nothing new.
        assert!(!seen.observe([(&a, Phase::Stopped)], None));
        assert!(seen.is_done(&key));

        assert!(seen.observe([(&a, Phase::Stopped)], Some(key.as_str())));
        assert!(!seen.is_done(&key), "looking did not clear it");

        // Looked away again: the turn was read, and stays read.
        seen.observe([(&a, Phase::Stopped)], None);
        assert!(!seen.is_done(&key), "the same stop was marked twice");
    }

    /// A session already sitting at its prompt when cctop starts has no turn
    /// this process watched end, so nothing is claimed about it.
    #[test]
    fn a_stop_nobody_saw_begin_is_not_marked() {
        let mut seen = Seen::default();
        let a = row("a");
        seen.observe([(&a, Phase::Stopped)], None);
        assert!(!seen.is_done(&a.key()));
    }

    /// Watching a turn end is having seen it.
    #[test]
    fn a_turn_that_ends_while_watched_is_not_marked() {
        let mut seen = Seen::default();
        let a = row("a");
        let key = a.key();
        seen.observe([(&a, Phase::Working)], Some(key.as_str()));
        seen.observe([(&a, Phase::Stopped)], Some(key.as_str()));
        seen.observe([(&a, Phase::Stopped)], None);
        assert!(!seen.is_done(&a.key()));
    }

    /// Working again takes the mark down; the next stop puts it back.
    #[test]
    fn a_new_turn_clears_the_mark_until_it_ends() {
        let mut seen = Seen::default();
        let a = row("a");
        seen.observe([(&a, Phase::Working)], None);
        seen.observe([(&a, Phase::Stopped)], None);
        assert!(seen.observe([(&a, Phase::Working)], None));
        assert!(!seen.is_done(&a.key()));
        seen.observe([(&a, Phase::Stopped)], None);
        assert!(seen.is_done(&a.key()));
    }

    /// A question mid-turn neither ends the turn nor forgets that it began:
    /// answered, the agent works on and the stop after it is still news.
    #[test]
    fn a_question_mid_turn_leaves_the_turn_armed() {
        let mut seen = Seen::default();
        let a = row("a");
        seen.observe([(&a, Phase::Working)], None);
        seen.observe([(&a, Phase::Other)], None);
        assert!(!seen.is_done(&a.key()), "a question is not a finished turn");
        seen.observe([(&a, Phase::Stopped)], None);
        assert!(seen.is_done(&a.key()));
    }

    /// A session that ends is forgotten, mark and all.
    #[test]
    fn an_ended_session_is_forgotten() {
        let mut seen = Seen::default();
        let a = row("a");
        let b = row("b");
        seen.observe([(&a, Phase::Working), (&b, Phase::Working)], None);
        seen.observe([(&a, Phase::Stopped), (&b, Phase::Working)], None);
        assert!(seen.is_done(&a.key()));
        assert!(seen.observe([(&b, Phase::Working)], None), "no frame owed");
        assert!(!seen.is_done(&a.key()));
        // And coming back under the same key starts from nothing.
        seen.observe([(&a, Phase::Stopped)], None);
        assert!(!seen.is_done(&a.key()));
    }

    #[test]
    fn a_row_reads_its_phase_off_its_dot() {
        use crate::session::ActivityState;
        assert_eq!(Phase::of(ActivityState::Working), Phase::Working);
        assert_eq!(Phase::of(ActivityState::WaitingForInput), Phase::Stopped);
        assert_eq!(Phase::of(ActivityState::Asking), Phase::Other);
        assert_eq!(Phase::of(ActivityState::ApiError), Phase::Other);
    }
}