aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/watcher.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src/watcher.rs')
-rw-r--r--crates/dinggerd/src/watcher.rs107
1 files changed, 98 insertions, 9 deletions
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
index f039456..9fd999d 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -9,14 +9,30 @@ use tracing::{debug, info, warn};
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)
+}
+
pub struct Watcher {
- /// Last published state per pane id. `status` reads the same values back
- /// from tmux's `@agent_state`, so no other state is needed.
- states: HashMap<String, AgentState>,
+ /// 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,
}
+struct PaneTrack {
+ state: AgentState,
+ ready_since: Option<u64>,
+}
+
/// herdr's notification policy (`notification_toast_for_state_change`).
pub fn notification_kind_for_transition(
prev_state: AgentState,
@@ -39,7 +55,8 @@ pub fn notification_kind_for_transition(
impl Watcher {
pub fn new(interval_secs: u64, sounds: bool) -> Self {
Self {
- states: HashMap::new(),
+ panes: HashMap::new(),
+ last_published: None,
interval_secs: interval_secs.max(1),
sounds,
}
@@ -74,14 +91,14 @@ impl Watcher {
let panes = tmux::list_panes()?;
// Forget panes that went away.
- self.states
+ self.panes
.retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id));
- for pane in panes {
+ for pane in &panes {
let prev_state = self
- .states
+ .panes
.get(&pane.pane_id)
- .copied()
+ .map(|track| track.state)
.unwrap_or(AgentState::Unknown);
// The detection result is an observation of live agent state.
@@ -130,12 +147,22 @@ impl Watcher {
if new_state == prev_state {
continue;
}
- self.states.insert(pane.pane_id.clone(), new_state);
+ 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) {
@@ -151,8 +178,70 @@ impl Watcher {
}
}
+ self.publish_ready_queue(&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]) {
+ let mut ready: Vec<(&str, String, u64)> = Vec::new(); // (pane_id, target, ready_since)
+ let mut blocked = 0usize;
+ let mut working = 0usize;
+ for pane in panes {
+ let Some(track) = self.panes.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 {
+ 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));
+ }
+ }
+ }
+ AgentState::Unknown => {}
+ }
+ }
+ ready.sort_by_key(|(_, _, since)| *since);
+
+ let queue = ready
+ .iter()
+ .map(|(pane_id, _, since)| format!("{pane_id}:{since}"))
+ .collect::<Vec<_>>()
+ .join(",");
+ let counts = (
+ ready.len().to_string(),
+ blocked.to_string(),
+ working.to_string(),
+ );
+ let next = (queue.clone(), counts.clone());
+ if self.last_published.as_ref() != Some(&next) {
+ self.last_published = Some(next);
+ for (name, value) in [
+ ("@dingger_queue", queue.as_str()),
+ ("@dingger_ready", counts.0.as_str()),
+ ("@dingger_blocked", counts.1.as_str()),
+ ("@dingger_working", counts.2.as_str()),
+ ] {
+ if let Err(err) = tmux::set_global(name, value) {
+ warn!(%err, "failed to write {name}");
+ }
+ }
+ }
+ }
+}
+
+fn unix_now() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
}
#[cfg(test)]