diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-20 11:53:23 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-20 11:53:23 +0300 |
| commit | 3509e48c5776c4153d8f90aff83a796c6d383208 (patch) | |
| tree | b19a75889a12f7c8ae828f61b8d70130e6ebe968 | |
| parent | fed066bc3908d18ede5490130975203a56c48fc2 (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.
| -rw-r--r-- | crates/dinggerd/src/main.rs | 2 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 67 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 298 | ||||
| -rw-r--r-- | menubar/main.swift | 145 |
4 files changed, 332 insertions, 180 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()); + } } diff --git a/menubar/main.swift b/menubar/main.swift index 6c6c9a3..d0a3b30 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -1,9 +1,10 @@ // dingger menu bar: live agent states from tmux pane user options // (@agent / @agent_state), written by the dingger daemon. // -// Visual language is herdr's agent panel: green = ready (finished, waiting -// for you), yellow = working, red = blocked; filled glyph = in the ready -// queue (unseen), outline = already seen. Priority order = queue order. +// Visual language is herdr's agent panel, verbatim: five statuses +// (working ◐ yellow, blocked × red, done ✓ teal, idle ○ green, +// unknown · gray), priority order blocked > done > working > idle, +// and the number in the title is herdr's actionable set (done + blocked). import AppKit @@ -16,8 +17,9 @@ struct PaneRow { let red = NSColor.systemRed let yellow = NSColor.systemYellow +let teal = NSColor.systemTeal let green = NSColor.systemGreen -let dim = NSColor.systemGray +let gray = NSColor.systemGray /// Tint a symbol image (non-template, so it keeps its color in menus). func tinted(_ image: NSImage?, _ color: NSColor) -> NSImage? { @@ -38,13 +40,24 @@ func symbol(_ name: String, _ color: NSColor? = nil) -> NSImage? { return color.map { tinted(image, $0) } ?? image } -func rowSymbol(_ state: String, queued: Bool) -> NSImage? { - switch (state, queued) { - case ("blocked", true): return symbol("exclamationmark.circle.fill", red) - case ("idle", true): return symbol("checkmark.circle.fill", green) - case ("working", _): return symbol("circle.dotted", yellow) - case ("blocked", _): return symbol("exclamationmark.circle", dim) - default: return symbol("checkmark.circle", dim) +/// Herdr's status_color + Symbols style, as SF Symbols. +func statusSymbol(_ status: String) -> NSImage? { + switch status { + case "working": return symbol("circle.dotted", yellow) + case "blocked": return symbol("exclamationmark.circle.fill", red) + case "done": return symbol("checkmark.circle.fill", teal) + case "idle": return symbol("checkmark.circle", green) + default: return symbol("questionmark.circle", gray) + } +} + +func statusPriority(_ status: String) -> Int { + switch status { + case "blocked": return 4 + case "done": return 3 + case "working": return 2 + case "idle": return 1 + default: return 0 } } @@ -56,7 +69,7 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { func applicationDidFinishLaunching(_: Notification) { NSApp.setActivationPolicy(.accessory) // no Dock icon - item.button?.image = symbol("bell", dim) + item.button?.image = symbol("bell", gray) item.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular) item.menu = menu menu.delegate = self @@ -88,22 +101,21 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { } private func fetchPanes() -> [PaneRow] { - let fmt = "#{pane_id}\t#{@agent}\t#{@agent_state}\t#{@dingger_ack}\t#{session_name}\t#{window_name}\t#{window_panes}\t#{pane_index}" + let fmt = "#{pane_id}\t#{@agent}\t#{@agent_state}\t#{@dingger_ack}\t#{session_name}:#{window_name}\t#{window_panes}\t#{pane_index}" guard let out = runTmux(["-u", "list-panes", "-a", "-F", fmt]) else { return [] } return out.split(separator: "\n").compactMap { line in let fields = line.split(separator: "\t", omittingEmptySubsequences: false) - guard fields.count >= 8 else { return nil } + guard fields.count >= 7 else { return nil } let agent = String(fields[1]) let state = fields[2].isEmpty ? "unknown" : String(fields[2]) - // Only tracked panes with a live state belong in the menu. - guard !agent.isEmpty, ["idle", "working", "blocked"].contains(state) else { + guard !agent.isEmpty, ["working", "blocked", "done", "idle"].contains(state) else { return nil } - // Display target: session:window-name, with the pane index only - // when the window is split. (Switching uses the pane id.) - var target = "\(fields[4]):\(fields[5])" - if Int(fields[6]) ?? 1 > 1 { - target += ".\(fields[7])" + // fields[4] is already `session:window-name`; add the pane index + // only when the window is split. + var target = String(fields[4]) + if Int(fields[5]) ?? 1 > 1 { + target += ".\(fields[6])" } return PaneRow(paneId: String(fields[0]), target: target, agent: agent, state: state) } @@ -115,6 +127,23 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { .split(separator: ",").map { $0.split(separator: ":").first.map(String.init) ?? "" } } + private func orderedPanes() -> [PaneRow] { + // herdr's priority: blocked > done > working > idle; FIFO within the + // queue (oldest waiting first), target order for the rest. + let priority = { (row: PaneRow) -> Int in statusPriority(row.state) } + var rows = panes + rows.sort { + let (pa, pb) = (priority($0), priority($1)) + if pa != pb { return pa > pb } + let (qa, qb) = (queue.firstIndex(of: $0.paneId), queue.firstIndex(of: $1.paneId)) + if let qa, let qb { return qa < qb } + if qa != nil { return true } + if qb != nil { return false } + return $0.target < $1.target + } + return rows + } + // MARK: title (timer-driven; never touches the open menu) private func refreshTitle() { @@ -122,28 +151,32 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { queue = fetchQueue() FileHandle.standardError.write(Data( "\(Date()) menubar: panes=\(panes.count) queue=\(queue.count)\n".utf8)) - let queued = panes.filter { row in queue.contains(row.paneId) } - let blocked = queued.filter { $0.state == "blocked" }.count + redrawTitle() + } + + private func redrawTitle() { + let blocked = panes.filter { $0.state == "blocked" }.count + let done = panes.filter { $0.state == "done" }.count let working = panes.filter { $0.state == "working" }.count - let (name, color, count): (String, NSColor, String) + let (image, count): (NSImage?, String) if blocked > 0 { - (name, color, count) = ("bell.fill", red, String(queued.count)) - } else if !queued.isEmpty { - (name, color, count) = ("bell.fill", green, String(queued.count)) + (image, count) = (symbol("bell.fill", red), String(blocked)) + } else if done > 0 { + (image, count) = (symbol("bell.fill", teal), String(done)) } else if working > 0 { - (name, color, count) = ("bell", yellow, "") + (image, count) = (symbol("bell", yellow), "") } else { - (name, color, count) = ("bell", dim, "") + (image, count) = (symbol("bell", gray), "") } - item.button?.image = symbol(name, color) + item.button?.image = image item.button?.title = count var tip: [String] = [] if blocked > 0 { tip.append("\(blocked) blocked") } - if queued.count - blocked > 0 { tip.append("\(queued.count - blocked) waiting") } + if done > 0 { tip.append("\(done) done") } if working > 0 { tip.append("\(working) working") } - let tipText = tip.isEmpty ? "Nothing waiting" : tip.joined(separator: " · ") + let tipText = tip.isEmpty ? "No agents" : tip.joined(separator: " · ") item.button?.toolTip = tipText item.button?.setAccessibilityLabel("dingger, " + tipText) } @@ -157,39 +190,25 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private func rebuildMenu() { menu.removeAllItems() - // Queue order (oldest waiting first), then the rest by target. - var ordered: [PaneRow] = [] - for paneId in queue { - if let row = panes.first(where: { $0.paneId == paneId && $0.state != "working" }) { - ordered.append(row) - } - } - let orderedIds = ordered.map { $0.paneId } - let rest = panes - .filter { !orderedIds.contains($0.paneId) } - .sorted { $0.target < $1.target } - ordered.append(contentsOf: rest) - - if panes.isEmpty { + let rows = orderedPanes() + if rows.isEmpty { let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "") none.isEnabled = false menu.addItem(none) } - var previousWaiting: Bool? - for pane in ordered { - let queued = queue.contains(pane.paneId) - let waiting = pane.state == "idle" || pane.state == "blocked" - // One separator between waiting agents and busy ones. - if let previous = previousWaiting, previous && !waiting { + var previousPriority: Int? + for pane in rows { + let priority = statusPriority(pane.state) + if let previous = previousPriority, previous != priority { menu.addItem(.separator()) } - previousWaiting = waiting + previousPriority = priority let row = NSMenuItem(title: "\(pane.agent)\t\(pane.target)", action: #selector(paneClicked(_:)), keyEquivalent: "") row.target = self - row.image = rowSymbol(pane.state, queued: queued) + row.image = statusSymbol(pane.state) row.attributedTitle = attributedRow(agent: pane.agent, target: pane.target) row.toolTip = "\(pane.state) — \(pane.target)" row.representedObject = pane.paneId @@ -228,22 +247,12 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { // MARK: actions func printPanesForDebug() { - let rows = fetchPanes() - let queue = fetchQueue() + panes = fetchPanes() + queue = fetchQueue() + let rows = orderedPanes() if rows.isEmpty { print("(no agents)") } for pane in rows { - let queued = queue.contains(pane.paneId) - print("\(emojilessState(pane.state, queued: queued)) \(pane.agent) \(pane.target)") - } - } - - private func emojilessState(_ state: String, queued: Bool) -> String { - switch (state, queued) { - case ("blocked", true): return "[blocked!]" - case ("idle", true): return "[waiting]" - case ("working", _): return "[working]" - case ("blocked", _): return "[blocked]" - default: return "[idle]" + print("\(pane.state) \(pane.agent) \(pane.target)") } } |
