aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-20 11:53:23 +0300
committerIgor Soarez <igor@soarez.org>2026-10-20 11:53:23 +0300
commit3509e48c5776c4153d8f90aff83a796c6d383208 (patch)
treeb19a75889a12f7c8ae828f61b8d70130e6ebe968 /crates
parentfed066bc3908d18ede5490130975203a56c48fc2 (diff)
adopt herdr's five-status model with symbols style and focus detection
statuses are now herdr's verbatim: working (yellow), blocked (red), done (teal, finished and unattended), idle (green, attended since its last transition), unknown (gray) - priority blocked > done > working > idle > unknown. the daemon watches tmux focus (pane_active of attached clients) to mark panes attended, which is what separates done from idle. menu bar renders symbols-style glyphs with herdr's colors in priority order; the tmux segment stays after the clock and renders done/blocked/ working counts from numeric options. deployment note: do not strip the binary - it invalidates the ad-hoc signature and macOS kills it on launch.
Diffstat (limited to 'crates')
-rw-r--r--crates/dinggerd/src/main.rs2
-rw-r--r--crates/dinggerd/src/tmux.rs67
-rw-r--r--crates/dinggerd/src/watcher.rs298
3 files changed, 255 insertions, 112 deletions
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index 61d9c15..7ef601f 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -145,7 +145,7 @@ fn status() -> Result<(), String> {
for pane in panes {
println!(
"{:<22} {:<10} {:<12} {}",
- pane.target,
+ pane.display_target,
pane.agent.map(detect_core::agent_label).unwrap_or("-"),
if pane.written_state.is_empty() {
"-"
diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs
index cba1ca6..f6c2f17 100644
--- a/crates/dinggerd/src/tmux.rs
+++ b/crates/dinggerd/src/tmux.rs
@@ -2,15 +2,14 @@
use std::process::Command;
-use detect_core::manifest::agent_state_label;
use detect_core::{identify_agent, Agent, AgentState};
#[derive(Debug, Clone)]
pub struct Pane {
/// Unique tmux pane id, e.g. `%3`. Stable for the pane's lifetime.
pub pane_id: String,
- /// User-facing target, e.g. `main:1.0`.
- pub target: String,
+ /// Human-readable target, e.g. `main:work` (`.pane` suffix when split).
+ pub display_target: String,
/// Foreground command name in the pane, from `#{pane_current_command}`.
pub current_command: String,
/// Last OSC title the pane's application set, from `#{pane_title}`.
@@ -29,14 +28,21 @@ pub struct Pane {
/// The `@dingger_ack` user option: "1" when the user already jumped to
/// this pane in its current ready episode.
pub written_ack: String,
+ /// The pane is the active pane of the active window.
+ pub pane_active: bool,
}
impl Pane {
- /// Panes the user is actively looking at suppress notifications, mirroring
- /// herdr's active-tab suppression.
+ /// Panes the user is actively looking at suppress finished notifications,
+ /// mirroring herdr's active-tab suppression.
pub fn suppresses_notifications(&self) -> bool {
self.window_active && self.session_attached
}
+
+ /// The pane is fully focused: the user is looking at this exact pane.
+ pub fn attended(&self) -> bool {
+ self.pane_active && self.suppresses_notifications()
+ }
}
fn run_tmux(args: &[&str]) -> Result<String, String> {
@@ -64,7 +70,6 @@ fn run_tmux(args: &[&str]) -> Result<String, String> {
pub fn list_panes() -> Result<Vec<Pane>, String> {
let format = concat!(
"#{pane_id}\t",
- "#{session_name}:#{window_index}.#{pane_index}\t",
"#{pane_current_command}\t",
"#{pane_title}\t",
"#{pane_dead}\t",
@@ -74,18 +79,22 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
"#{pane_pid}\t",
"#{@agent_state}\t",
"#{@agent}\t",
- "#{@dingger_ack}"
+ "#{@dingger_ack}\t",
+ "#{pane_active}\t",
+ "#{session_name}:#{window_name}\t",
+ "#{window_panes}\t",
+ "#{pane_index}",
);
let out = run_tmux(&["list-panes", "-a", "-F", format])?;
let mut panes = Vec::new();
for line in out.lines() {
let fields: Vec<&str> = line.split('\t').collect();
- if fields.len() < 11 {
+ if fields.len() < 15 {
continue;
}
- let current_command = fields[2].to_string();
- let start_command = fields[7].to_string();
- let pane_pid: Option<u32> = fields[8].trim().parse().ok();
+ let current_command = fields[1].to_string();
+ let start_command = fields[6].to_string();
+ let pane_pid: Option<u32> = fields[7].trim().parse().ok();
// An agent may be started via a wrapper or still be initializing
// behind the shell; `pane_start_command` covers launch-command cases.
let mut agent =
@@ -97,18 +106,25 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
let tree = process_tree.get_or_insert_with(fetch_process_tree);
agent = resolve_agent_in_process_tree(pane_pid, tree);
}
+ // Display target: session:window-name, pane index only when split.
+ let mut display_target = fields[12].to_string();
+ if fields[13].parse::<u32>().map(|n| n > 1).unwrap_or(false) {
+ display_target.push('.');
+ display_target.push_str(fields[14]);
+ }
panes.push(Pane {
pane_id: fields[0].to_string(),
- target: fields[1].to_string(),
+ display_target,
current_command,
- pane_title: fields[3].to_string(),
- dead: fields[4] == "1",
- window_active: fields[5] == "1",
- session_attached: fields[6] != "0",
+ pane_title: fields[2].to_string(),
+ dead: fields[3] == "1",
+ window_active: fields[4] == "1",
+ session_attached: fields[5] != "0",
agent,
- written_state: fields[9].to_string(),
- written_agent: fields[10].to_string(),
- written_ack: fields.get(11).copied().unwrap_or("").to_string(),
+ written_state: fields[8].to_string(),
+ written_agent: fields[9].to_string(),
+ written_ack: fields[10].to_string(),
+ pane_active: fields[11] == "1",
});
}
Ok(panes)
@@ -190,16 +206,9 @@ pub fn capture_pane(pane_id: &str) -> Result<String, String> {
run_tmux(&["capture-pane", "-p", "-q", "-t", pane_id])
}
-pub fn set_agent_state(pane_id: &str, state: AgentState) -> Result<(), String> {
- run_tmux(&[
- "set-option",
- "-p",
- "-t",
- pane_id,
- "@agent_state",
- agent_state_label(state),
- ])
- .map(|_| ())
+/// Write the pane's display status (herdr's vocabulary: done/idle/...).
+pub fn set_agent_state(pane_id: &str, label: &str) -> Result<(), String> {
+ run_tmux(&["set-option", "-p", "-t", pane_id, "@agent_state", label]).map(|_| ())
}
/// Label the pane with the agent running in it, for tmux status-line and
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());
+ }
}