diff options
Diffstat (limited to 'crates/dinggerd/src/watcher.rs')
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 298 |
1 files changed, 216 insertions, 82 deletions
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index d352ab2..045fb64 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -1,5 +1,21 @@ //! Poll-and-detect loop: tmux pane capture → detection engine → herdr's -//! transition policy → notifications and `@agent_state` write-back. +//! status model → notifications and pane-option write-back. +//! +//! Status model (herdr's, verbatim): five display statuses over two inputs — +//! the raw agent state (working/blocked/idle, from hooks or scraping) and a +//! `seen` flag (attended since the last state change): +//! +//! | raw | seen | display | priority | +//! |----------|--------|----------|----------| +//! | working | — | working | 2 | +//! | blocked | — | blocked | 4 | +//! | idle | false | done | 3 | +//! | idle | true | idle | 1 | +//! | none | — | unknown | 0 | +//! +//! "Seen" is maintained three ways: the pane is fully focused in an attached +//! client (the daemon watches tmux focus), the user jumped to it via +//! `dingger focus` / menu click, or its next transition. use std::collections::HashMap; @@ -10,31 +26,55 @@ use crate::hooks; use crate::notify::{play_sound, send_notification, NotificationKind}; use crate::tmux; -/// When a pane became ready for the user, as unix seconds. Ready means the -/// agent is waiting on the user: idle at its prompt or blocked on input. -fn is_ready(state: AgentState) -> bool { - matches!(state, AgentState::Idle | AgentState::Blocked) +/// herdr's display statuses, in priority order (highest first). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Status { + Unknown, + Idle, + Working, + Done, + Blocked, } -pub struct Watcher { - /// Published state plus the unix second it became ready, per pane id. - /// Published state plus the unix second it became ready, per pane id. - /// `status` reads the same values back from tmux's `@agent_state`, so no - /// other durable state is needed. - panes: HashMap<String, PaneTrack>, - /// Last (queue, counts) written to the global options; avoids rewriting - /// them every tick. - last_published: Option<(String, (String, String, String))>, - interval_secs: u64, - sounds: bool, -} +impl Status { + #[allow(dead_code)] // herdr parity: the full vocabulary is part of the model + pub fn label(&self) -> &'static str { + match self { + Status::Unknown => "unknown", + Status::Idle => "idle", + Status::Working => "working", + Status::Done => "done", + Status::Blocked => "blocked", + } + } -struct PaneTrack { - state: AgentState, - ready_since: Option<u64>, + #[allow(dead_code)] // herdr parity + pub fn parse(label: &str) -> Option<Status> { + match label { + "working" => Some(Status::Working), + "blocked" => Some(Status::Blocked), + "done" => Some(Status::Done), + "idle" => Some(Status::Idle), + "unknown" => Some(Status::Unknown), + _ => None, + } + } + + /// herdr's priority: blocked > done > working > idle > unknown. + #[allow(dead_code)] // herdr parity; used once the menu bar consumes statuses from the daemon + pub fn priority(&self) -> u8 { + match self { + Status::Blocked => 4, + Status::Done => 3, + Status::Working => 2, + Status::Idle => 1, + Status::Unknown => 0, + } + } } -/// herdr's notification policy (`notification_toast_for_state_change`). +/// herdr's notification policy (`notification_toast_for_state_change`), +/// driven by raw state transitions. pub fn notification_kind_for_transition( prev_state: AgentState, new_state: AgentState, @@ -53,10 +93,27 @@ pub fn notification_kind_for_transition( } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PaneTrack { + state: AgentState, + seen: bool, + /// Unix second the pane entered its current done/blocked episode; drives + /// the FIFO ready queue. + ready_since: Option<u64>, +} + +pub struct Watcher { + tracks: HashMap<String, PaneTrack>, + /// Last published (queue, counts); avoids rewriting options every tick. + last_published: Option<(String, (String, String, String, String))>, + interval_secs: u64, + sounds: bool, +} + impl Watcher { pub fn new(interval_secs: u64, sounds: bool) -> Self { Self { - panes: HashMap::new(), + tracks: HashMap::new(), last_published: None, interval_secs: interval_secs.max(1), sounds, @@ -92,7 +149,7 @@ impl Watcher { let panes = tmux::list_panes()?; // Forget panes that went away. - self.panes + self.tracks .retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id)); hooks::forget( &panes @@ -102,19 +159,17 @@ impl Watcher { ); for pane in &panes { - let prev_state = self - .panes - .get(&pane.pane_id) - .map(|track| track.state) - .unwrap_or(AgentState::Unknown); + let prev_track = self.tracks.get(&pane.pane_id).copied(); + let (prev_state, prev_seen) = prev_track + .map(|track| (track.state, track.seen)) + .unwrap_or((AgentState::Unknown, true)); - // The detection result is an observation of live agent state. - // Agent-reported state (hook receiver) is authoritative while the - // pane runs the agent; screen detection is the fallback for panes - // that never report. Viewer screens (transcripts, pickers) return + // Raw state: hook reports are authoritative while the pane runs + // the agent; screen detection is the fallback for panes that + // never report. Viewer screens (transcripts, pickers) return // `skip_state_update: true` — they say nothing about state and // leave the previous one standing (herdr's publishing contract). - let observed = if pane.dead { + let raw = if pane.dead { Some(AgentState::Idle) } else if let Some(agent) = pane.agent { if let Some(state) = hooks::hook_state(&pane.pane_id) { @@ -123,7 +178,7 @@ impl Watcher { let screen = match tmux::capture_pane(&pane.pane_id) { Ok(screen) => screen, Err(err) => { - warn!(target = %pane.target, %err, "capture failed"); + warn!(target = %pane.display_target, %err, "capture failed"); continue; } }; @@ -140,10 +195,51 @@ impl Watcher { Some(AgentState::Unknown) }; - let Some(new_state) = observed else { - debug!(target = %pane.target, "observation skipped (viewer screen)"); + let Some(new_state) = raw else { + debug!(target = %pane.display_target, "observation skipped (viewer screen)"); continue; }; + + // Seen: attended since the last state change. Attended means the + // pane is fully focused in an attached client, the user jumped to + // it (`@dingger_ack`), or it was just first observed. A first + // observation of idle is seen (nothing has happened yet); a + // working/blocked → idle transition is an unseen completion. + let transition = new_state != prev_state; + // Attended also via `dingger focus` / menu click (@dingger_ack). + let mut seen = if transition { + !(new_state == AgentState::Idle && prev_state == AgentState::Unknown) + } else { + prev_seen || pane.written_ack == "1" + }; + if !seen && pane.attended() { + seen = true; + } + if transition || seen != prev_seen { + let ready_since = if is_ready_to_attend(new_state, seen) { + Some(unix_now()) + } else { + None + }; + self.tracks.insert( + pane.pane_id.clone(), + PaneTrack { + state: new_state, + seen, + ready_since, + }, + ); + } + if transition { + debug!(target = %pane.display_target, ?prev_state, ?new_state, seen, "state change"); + let display = display_status(&self.tracks[&pane.pane_id]); + if let Err(err) = tmux::set_agent_state(&pane.pane_id, display.label()) { + warn!(target = %pane.display_target, %err, "failed to write @agent_state"); + } + let _ = tmux::clear_ack(&pane.pane_id); + } else if seen != prev_seen { + let _ = tmux::ack(&pane.pane_id); + } if new_state == AgentState::Unknown { // The agent is gone; its last hook report must not outlive it. hooks::drop_report(&pane.pane_id); @@ -158,72 +254,59 @@ impl Watcher { .to_string(); if pane.written_agent != agent_label { if let Err(err) = tmux::set_agent(&pane.pane_id, pane.agent) { - warn!(target = %pane.target, %err, "failed to write @agent"); + warn!(target = %pane.display_target, %err, "failed to write @agent"); } } - if new_state == prev_state { - continue; - } - let ready_since = is_ready(new_state).then(unix_now); - self.panes.insert( - pane.pane_id.clone(), - PaneTrack { - state: new_state, - ready_since, - }, - ); - debug!(target = %pane.target, ?prev_state, ?new_state, "state change"); - - if let Err(err) = tmux::set_agent_state(&pane.pane_id, new_state) { - warn!(target = %pane.target, %err, "failed to write @agent_state"); - } - // A fresh transition re-enters the ready queue even if the user - // already jumped to this pane in a previous ready episode. - let _ = tmux::clear_ack(&pane.pane_id); - - - if let Some(kind) = notification_kind_for_transition(prev_state, new_state) { - if pane.suppresses_notifications() { - debug!(target = %pane.target, "suppressed (active pane)"); - continue; - } - let context = format!("{} ({})", pane.target, agent_label); - send_notification(&agent_label, &kind, &context); - if self.sounds { - play_sound(&kind); + if transition { + if let Some(kind) = notification_kind_for_transition(prev_state, new_state) { + // herdr: a blocked pane plays the alert sound even when + // the user is watching; a finished pane stays silent when + // they are already looking at it. + let suppressed = pane.suppresses_notifications(); + if !suppressed { + let context = format!("{} ({})", pane.display_target, agent_label); + send_notification(&agent_label, &kind, &context); + } + if self.sounds { + play_sound(&kind); + } } } } - self.publish_ready_queue(&panes); + self.publish(&panes); Ok(()) } - /// Publish the FIFO ready queue (oldest first) as a server-global option; - /// `dingger focus` pops its head, and the tmux status line renders counts. - fn publish_ready_queue(&mut self, panes: &[tmux::Pane]) { + /// Publish the FIFO queue (done + blocked panes, oldest transition first) + /// and counts as server-global options for the tmux status line and + /// `dingger focus`. + fn publish(&mut self, panes: &[tmux::Pane]) { let mut ready: Vec<(&str, String, u64)> = Vec::new(); // (pane_id, target, ready_since) let mut blocked = 0usize; + let mut done = 0usize; let mut working = 0usize; for pane in panes { - let Some(track) = self.panes.get(&pane.pane_id) else { + let Some(track) = self.tracks.get(&pane.pane_id) else { continue; }; - match track.state { - AgentState::Working => working += 1, - AgentState::Idle | AgentState::Blocked => { - let acked = pane.written_ack == "1"; - if !acked { + let status = display_status(track); + match status { + Status::Working => working += 1, + Status::Done | Status::Blocked => { + if track.ready_since.is_some() { + if status == Status::Blocked { + blocked += 1; + } else { + done += 1; + } if let Some(since) = track.ready_since { - if track.state == AgentState::Blocked { - blocked += 1; - } - ready.push((pane.pane_id.as_str(), pane.target.clone(), since)); + ready.push((pane.pane_id.as_str(), pane.display_target.clone(), since)); } } } - AgentState::Unknown => {} + Status::Idle | Status::Unknown => {} } } ready.sort_by_key(|(_, _, since)| *since); @@ -237,6 +320,7 @@ impl Watcher { ready.len().to_string(), blocked.to_string(), working.to_string(), + done.to_string(), ); let next = (queue.clone(), counts.clone()); if self.last_published.as_ref() != Some(&next) { @@ -246,6 +330,7 @@ impl Watcher { ("@dingger_ready", counts.0.as_str()), ("@dingger_blocked", counts.1.as_str()), ("@dingger_working", counts.2.as_str()), + ("@dingger_done", counts.3.as_str()), ] { if let Err(err) = tmux::set_global(name, value) { warn!(%err, "failed to write {name}"); @@ -255,6 +340,31 @@ impl Watcher { } } +/// When a pane became ready for the user, as unix seconds. Ready means the +/// agent is waiting on the user: done (finished, unseen) or blocked. +fn is_ready_to_attend(state: AgentState, seen: bool) -> bool { + match state { + AgentState::Blocked => true, + AgentState::Idle => !seen, + _ => false, + } +} + +fn display_status(track: &PaneTrack) -> Status { + match track.state { + AgentState::Working => Status::Working, + AgentState::Blocked => Status::Blocked, + AgentState::Idle => { + if track.seen { + Status::Idle + } else { + Status::Done + } + } + AgentState::Unknown => Status::Unknown, + } +} + fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -282,4 +392,28 @@ mod tests { assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Working).is_none()); assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Unknown).is_none()); } + + #[test] + fn display_status_maps_herdr() { + let unseen = PaneTrack { state: AgentState::Idle, seen: false, ready_since: Some(1) }; + let seen = PaneTrack { state: AgentState::Idle, seen: true, ready_since: None }; + assert_eq!(display_status(&unseen), Status::Done); + assert_eq!(display_status(&seen), Status::Idle); + assert_eq!( + display_status(&PaneTrack { state: AgentState::Working, seen: false, ready_since: None }), + Status::Working + ); + assert_eq!( + display_status(&PaneTrack { state: AgentState::Blocked, seen: false, ready_since: Some(1) }), + Status::Blocked + ); + } + + #[test] + fn priority_matches_herdr() { + assert!(Status::Blocked.priority() > Status::Done.priority()); + assert!(Status::Done.priority() > Status::Working.priority()); + assert!(Status::Working.priority() > Status::Idle.priority()); + assert!(Status::Idle.priority() > Status::Unknown.priority()); + } } |
