diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-20 12:43:07 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-20 12:43:07 +0300 |
| commit | 4af6a4f625b1965b783c9efb857f8ac665adb8eb (patch) | |
| tree | dd2dac9627735fd02b3372a4f569125a702ba57a | |
| parent | 943fc940d9c387916708cb3fdc912323c92ef6dc (diff) | |
rebuild the status model as herdr's state machine, event-driven and tested
the seen logic was inverted: first observations were marked done and real
completions idle, so the menu showed teal for agents that did nothing and
green for agents that finished, and attending changed nothing visible.
- watcher::advance is herdr's apply_pane_state_change + mark_active_tab_seen
as a pure function, unit-tested against herdr's own cases; attendance is
window-level like herdr's active tab
- hook reports are events applied in order, so a turn shorter than a poll is
still a completion
- event-driven: hook reports, dingger focus, and tmux focus hooks wake the
watcher; notifications are delivered off-thread (the macos call blocked
ticks for 2+ seconds) after the pane options are written
- all per-pane state persists in tmux pane options, so restarts and deploys
lose nothing, including the authoritative hook state
- every click goes through dingger focus: the menu bar shells out to it and
the status-line binding runs it; it picks the right client, skips the
window already shown, and activates the terminal app hosting the client
- the ready queue is ordered like the menu (blocked first, then oldest done)
- menu bar and tmux segment render herdr's symbols in herdr's palette
- tests/e2e.sh: 60 assertions against an isolated tmux server with a real
client, a 30s poll interval, and a slow fake notifier
| -rw-r--r-- | README.md | 149 | ||||
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 144 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 144 | ||||
| -rw-r--r-- | crates/dinggerd/src/notify.rs | 31 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 137 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 634 | ||||
| -rw-r--r-- | menubar/main.swift | 440 | ||||
| -rw-r--r-- | packaging/launchd/dev.dingger.dingger.plist | 2 | ||||
| -rw-r--r-- | packaging/launchd/dev.dingger.menubar.plist | 28 | ||||
| -rw-r--r-- | packaging/tmux/dingger.tmux | 14 | ||||
| -rwxr-xr-x | tests/e2e.sh | 244 |
11 files changed, 1261 insertions, 706 deletions
@@ -1,74 +1,107 @@ # dingger -macOS agent watcher for tmux panes. dingger watches every pane in your tmux -server, detects which AI coding agent is running in each one (claude, codex, -pi, gemini, and 19 others), tracks its state (idle / working / blocked), and -sends a macOS notification — with sound — when an agent needs attention or -finishes. - -dingger owns no terminals. It is a client of your tmux server: `capture-pane` -for screen text, `#{pane_title}` for OSC titles, and `#{pane_pid}` plus a `ps` -tree walk for agent identity. The detection engine and rule manifests are -extracted from [herdr](https://github.com/herdrdev/herdr) (Apache-2.0), so -detection quality matches herdr's screen-matching engine. herdr's manifests -use the same terminal-screen regions (`capture-pane` output maps 1:1); OSC -progress sequences are not re-exposed by tmux (a small confidence loss on a -few rules). Agents launched as bare `node <entry.js>` scripts (rather than via -an install shim) are not identified yet. +macOS agent watcher for tmux panes: herdr's agent panel, without herdr. +dingger watches every pane in your tmux server, detects which AI coding agent +runs in each (claude, codex, pi, gemini, and 19 others), tracks herdr's five +statuses, and tells you which agents are waiting for you: in the menu bar, in +the tmux status line, and with a notification and a sound. + +dingger owns no terminals. It is a client of your tmux server. The detection +engine and rule manifests are extracted from +[herdr](https://github.com/herdrdev/herdr) (Apache-2.0). + +## Statuses (herdr's, verbatim) + +| status | meaning | symbol | color | priority | +|---------|--------------------------------------------------|--------|--------|----------| +| blocked | needs your input | `×` | red | 4 | +| done | finished a turn while you were looking elsewhere | `✓` | teal | 3 | +| working | turn in flight | `◐` | yellow | 2 | +| idle | at its prompt, nothing new since you looked | `○` | green | 1 | + +The state machine is herdr's `apply_pane_state_change` and +`mark_active_tab_seen`, implemented as one pure function (`watcher::advance`) +and unit-tested against herdr's own test cases: + +- a turn that finishes while you look at another window becomes **done**; +- a turn that finishes in the window you are looking at stays **idle**; +- looking at a window (a tmux window is herdr's tab) turns its done agents + **idle**, as does jumping to one from the menu bar or status line; +- an agent seen for the first time is **idle**, not done. + +dingger watches tmux focus through tmux hooks, so attendance updates the +moment you switch windows; agent reports apply in order, so a turn shorter +than a poll is still a completion. + +## Surfaces + +- **Menu bar** (`menubar/main.swift`): `×1 ✓3 ◐2` in herdr's colors; the + menu lists every agent in priority order, oldest waiting first. Clicking a + row jumps to that pane. +- **tmux status line** (`packaging/tmux/dingger.tmux`): the same summary after + the clock. Clicking it jumps to the top waiting agent (the menu's top row), + skipping the window you already look at. +- **Notifications**: "needs attention" (blocked) and "finished" (done), with + herdr's sounds; silent for completions you are already watching. + +## Where state comes from + +1. **Agent hooks** (authoritative): `dingger install pi` installs a pi + extension that reports working/blocked/idle over + `~/.local/state/dingger/agent.sock`. Restart running pi agents to load it. +2. **Screen matching** (fallback): `capture-pane` text through herdr's + manifests, for agents without hooks. + +All per-pane state lives in tmux pane options (`@agent_state`, +`@dingger_since`, `@dingger_hook`), so restarting dingger loses nothing. ## Usage ```bash -dingger watch # run the watcher; notify on transitions -dingger status # table of panes, agents, states -dingger manifests update # refresh manifests from herdr's published catalog +dingger watch # the daemon (run by launchd, see below) +dingger status # agents in priority order +dingger focus [--pane ID] # jump to an agent (default: the top waiter) +dingger install pi # install the pi state-reporting extension +dingger manifests update # refresh manifests from herdr's catalog ``` -`dingger status` reads state written back into tmux as the per-pane user -option `@agent_state`, so it works without any daemon IPC. Render it directly -in your status line with `#{:@agent_state}` in `status-format` / -`pane-border-status`. `dingger manifests update` stores refreshed manifests -from herdr's published catalog; restart `dingger watch` to pick them up. +## Install -Environment: - -- `DINGGER_TMUX_SOCKET` — target a named tmux server socket (`tmux -L <name>`) -- `DINGGER_CONFIG_DIR` / `DINGGER_STATE_DIR` — override/remote manifest locations - (default `~/.config/dingger` / `~/.local/state/dingger`) - -## Notification policy - -herdr's transition policy: notify on any transition to `blocked` ("needs -attention", `request.mp3`) and on `working`/`blocked` → `idle` ("finished", -`done.mp3`), with no notification for `unknown` panes. One deliberate -difference from herdr: a pane on the active window of an attached client is -suppressed entirely (herdr still plays the blocker sound there). Viewer -screens such as Claude's transcript never change state. - -## Layout - -- `crates/detect-core` — the detection engine (manifest compiler, region - extractors, priority arbitration) and 22 bundled manifests, extracted from - herdr's `src/detect/`. Pure text-in → state-out. -- `crates/dinggerd` — the `dingger` binary: tmux watcher, notification - policy, sounds, CLI. -- `sounds/` — herdr's bundled notification sounds. - -## Status +```bash +cargo build --release +cp target/release/dingger ~/.local/bin/dingger # do not strip: invalidates +codesign --force --sign - ~/.local/bin/dingger # the signature, macOS kills it +swiftc -O -o target/dingger-menu menubar/main.swift # menu bar, bundled as + # ~/Applications/dingger.app +``` -Early scaffold. Roadmap: menu bar UI (Swift shell over the daemon), configurable -notification policy. +- launchd agents (start at login, restart on exit): + `packaging/launchd/dev.dingger.{dingger,menubar}.plist`; adjust the user + path, copy to `~/Library/LaunchAgents/`, `launchctl bootstrap gui/$(id -u) <plist>`. +- tmux: `source-file /path/to/dingger/packaging/tmux/dingger.tmux` (needs + `set -g mouse on` for the click). -### Running as a login agent (launchd) +dingger does not need tmux running first; it attaches when a server appears. -`packaging/launchd/dev.dingger.dingger.plist` runs `dingger watch` at login -with auto-restart; adjust the user path, then: +## Testing ```bash -launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.dingger.dingger.plist +cargo test # engine tests + herdr state-machine tests +tests/e2e.sh # end to end against an isolated tmux server ``` -dingger does not require tmux to be running first — it waits for a server and -attaches when one appears. All tmux invocations use `-u` (forced UTF-8): under -launchd's C locale, tmux mangles tab-separated `-F` output into underscores, -which would silently hide every pane. +`tests/e2e.sh` starts its own tmux server, its own daemon (polling only every +30 s, so everything asserted must arrive through events), fake agents driven +over the hook protocol, and a real tmux client whose focus it controls. It +asserts every status transition, attendance by navigation, both click paths +(including the real menu bar binary), the status-line segment, latency with a +deliberately slow notifier, and state surviving a daemon restart. It never +touches your tmux server. + +## Layout + +- `crates/detect-core`: the detection engine and 22 manifests, from herdr. +- `crates/dinggerd`: the `dingger` binary (watcher, hooks, focus, CLI). +- `menubar/`: the menu bar app. +- `packaging/`: launchd agents and the tmux snippet. +- `tests/e2e.sh`: the end-to-end suite. diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs index da1121e..40f0f20 100644 --- a/crates/dinggerd/src/hooks.rs +++ b/crates/dinggerd/src/hooks.rs @@ -6,23 +6,27 @@ //! {"method":"pane.report_agent","params":{"pane_id":"%3","agent":"pi","state":"working"}} //! ``` //! -//! Reports are authoritative for their pane until the next report or until -//! the pane stops running the agent; screen detection is the fallback for -//! panes that never report. +//! Reports are state-change *events*: the watcher applies every one in +//! order, so a turn that starts and finishes between two polls is still a +//! completion, and each report wakes the watcher immediately. +//! +//! `{"method":"poke"}` only wakes the watcher; `dingger focus` and tmux's +//! focus-change hooks send it so attendance updates without waiting for the +//! next poll. The last reported state stays authoritative for its pane +//! until the next report or until the pane stops running the agent; screen +//! detection is the fallback for panes that never report. use std::collections::HashMap; use std::io::{BufRead, BufReader}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; -use std::sync::{Mutex, OnceLock}; -use std::time::Instant; +use std::sync::{Condvar, Mutex, MutexGuard, OnceLock}; +use std::time::Duration; use detect_core::AgentState; use serde::Deserialize; use tracing::{debug, info, warn}; -pub const SOCKET_FILENAME: &str = "agent.sock"; - pub fn socket_path() -> PathBuf { if let Some(path) = std::env::var_os("DINGGER_AGENT_SOCKET") { return PathBuf::from(path); @@ -30,54 +34,88 @@ pub fn socket_path() -> PathBuf { let state = std::env::var_os("DINGGER_STATE_DIR") .map(PathBuf::from) .or_else(|| { - std::env::var_os("HOME").map(|home| { - PathBuf::from(home).join(".local").join("state").join("dingger") - }) + std::env::var_os("HOME") + .map(|home| PathBuf::from(home).join(".local").join("state").join("dingger")) }) .unwrap_or_else(|| PathBuf::from(".dingger")); - state.join(SOCKET_FILENAME) + state.join("agent.sock") +} + +#[derive(Debug, Default)] +struct PaneReports { + /// Last reported state: authoritative until the next report. + last: Option<AgentState>, + /// Reports not yet applied by the watcher, oldest first. + pending: Vec<AgentState>, +} + +fn reports() -> MutexGuard<'static, HashMap<String, PaneReports>> { + static REPORTS: OnceLock<Mutex<HashMap<String, PaneReports>>> = OnceLock::new(); + REPORTS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn wake_signal() -> &'static (Mutex<bool>, Condvar) { + static WAKE: OnceLock<(Mutex<bool>, Condvar)> = OnceLock::new(); + WAKE.get_or_init(|| (Mutex::new(false), Condvar::new())) +} + +fn wake() { + let (flag, condvar) = wake_signal(); + *flag.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = true; + condvar.notify_all(); } -#[derive(Debug, Clone)] -struct HookReport { - state: AgentState, - #[allow(dead_code)] // kept for future hook diagnostics - agent: Option<String>, - #[allow(dead_code)] // kept for future staleness policy - seen: Instant, +/// Sleep until woken by a report or poke, or until `timeout` passes. +pub fn wait_for_wake(timeout: Duration) { + let (flag, condvar) = wake_signal(); + let guard = flag.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let (mut guard, _) = condvar + .wait_timeout_while(guard, timeout, |woken| !*woken) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *guard = false; } -fn reports() -> &'static Mutex<HashMap<String, HookReport>> { - static REPORTS: OnceLock<Mutex<HashMap<String, HookReport>>> = OnceLock::new(); - REPORTS.get_or_init(|| Mutex::new(HashMap::new())) +/// Wake a running watcher (used by `dingger focus` and tmux focus hooks). +pub fn poke() -> Result<(), String> { + use std::io::Write; + let mut stream = UnixStream::connect(socket_path()) + .map_err(|err| format!("dingger watch is not running: {err}"))?; + stream + .write_all(b"{\"method\":\"poke\"}\n") + .map_err(|err| format!("poke failed: {err}")) } #[derive(Deserialize)] struct HookMessage { method: String, - params: HookParams, + #[serde(default)] + params: Option<HookParams>, } #[derive(Deserialize)] struct HookParams { pane_id: String, state: String, - #[serde(default)] - agent: Option<String>, } fn handle_stream(stream: UnixStream) { - let reader = BufReader::new(stream); - for line in reader.lines() { + for line in BufReader::new(stream).lines() { let Ok(line) = line else { return }; let Ok(message) = serde_json::from_str::<HookMessage>(&line) else { warn!(%line, "invalid hook message"); continue; }; - if message.method != "pane.report_agent" { + if message.method == "poke" { + wake(); continue; } - let state = match message.params.state.as_str() { + let (true, Some(params)) = (message.method == "pane.report_agent", message.params) else { + continue; + }; + let state = match params.state.as_str() { "working" => AgentState::Working, "blocked" => AgentState::Blocked, "idle" => AgentState::Idle, @@ -86,17 +124,14 @@ fn handle_stream(stream: UnixStream) { continue; } }; - let pane_id = message.params.pane_id; - debug!(pane = %pane_id, ?state, "hook report"); - let mut reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - reports.insert( - pane_id, - HookReport { - state, - agent: message.params.agent, - seen: Instant::now(), - }, - ); + debug!(pane = %params.pane_id, ?state, "hook report"); + { + let mut reports = reports(); + let pane = reports.entry(params.pane_id).or_default(); + pane.last = Some(state); + pane.pending.push(state); + } + wake(); } } @@ -123,20 +158,29 @@ pub fn spawn_listener() -> Result<(), String> { Ok(()) } -/// The authoritative state for a pane, if an extension reported one. -pub fn hook_state(pane_id: &str) -> Option<AgentState> { - let reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - reports.get(pane_id).map(|report| report.state) +/// The pane's hook observations for this tick: every report since the last +/// tick, in order; otherwise the last reported state; `None` if the pane +/// never reported to this process. +pub fn take_observations(pane_id: &str) -> Option<Vec<AgentState>> { + let mut reports = reports(); + let pane = reports.get_mut(pane_id)?; + if pane.pending.is_empty() { + return pane.last.map(|state| vec![state]); + } + Some(std::mem::take(&mut pane.pending)) +} + +/// The last state the pane's extension reported, if any. +pub fn last_state(pane_id: &str) -> Option<AgentState> { + reports().get(pane_id).and_then(|pane| pane.last) } -/// Drop a single pane's report (the agent exited). -pub fn drop_report(pane_id: &str) { - let mut reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - reports.remove(pane_id); +/// Drop a pane's reports (the agent exited). +pub fn drop_reports(pane_id: &str) { + reports().remove(pane_id); } -/// Drop reports for panes that no longer exist or no longer run the agent. +/// Drop reports for panes that no longer exist. pub fn forget(pane_ids_to_keep: &[String]) { - let mut reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - reports.retain(|pane_id, _| pane_ids_to_keep.contains(pane_id)); + reports().retain(|pane_id, _| pane_ids_to_keep.contains(pane_id)); } diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index 7ef601f..f458e18 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -27,6 +27,9 @@ enum Command { /// Disable notification sounds. #[arg(long)] no_sounds: bool, + /// Disable macOS notifications (sounds still play unless --no-sounds). + #[arg(long)] + no_notifications: bool, }, /// Print the current agent state of every tmux pane. Status, @@ -35,12 +38,19 @@ enum Command { #[command(subcommand)] command: ManifestCommand, }, - /// Switch to the agent that has been waiting the longest (FIFO). Bound to - /// a mouse click on the tmux status line by the packaged config. + /// Show an agent pane and mark it attended. Without --pane, jumps to the + /// agent that has waited longest (FIFO), skipping the window the client + /// already shows. Used by the tmux status-line click and the menu bar. Focus { - /// Name of the tmux client that clicked, passed as #{client_name}. + /// tmux client to switch (default: the most recently active one). + #[arg(long)] client: Option<String>, + /// Pane to show (default: the head of the ready queue). + #[arg(long)] + pane: Option<String>, }, + /// Wake the watcher so focus changes show up immediately (tmux hooks). + Poke, /// Install agent integrations (hook extensions that report state). Install { /// Agent to install the integration for. @@ -65,12 +75,13 @@ fn main() { Command::Watch { interval_secs, no_sounds, + no_notifications, } => { if let Err(err) = hooks::spawn_listener() { eprintln!("dingger: {err}"); std::process::exit(1); } - let mut watcher = watcher::Watcher::new(interval_secs, !no_sounds); + let mut watcher = watcher::Watcher::new(interval_secs, !no_sounds, !no_notifications); if let Err(err) = watcher.run() { eprintln!("dingger: {err}"); std::process::exit(1); @@ -90,12 +101,16 @@ fn main() { } } }, - Command::Focus { client } => { - if let Err(err) = focus(client) { + Command::Focus { client, pane } => { + if let Err(err) = focus(client, pane) { eprintln!("dingger: {err}"); std::process::exit(1); } } + Command::Poke => { + // Best effort: a stopped watcher just means nothing to wake. + let _ = hooks::poke(); + } Command::Install { agent } => { if let Err(err) = integrations::install(&agent) { eprintln!("dingger: {err}"); @@ -105,55 +120,92 @@ fn main() { } } -/// Jump to the head of the ready queue (oldest waiting agent) and ack it, so -/// the next focus moves on to the following one. -fn focus(client: Option<String>) -> Result<(), String> { - let queue = tmux::show_global("@dingger_queue") - .unwrap_or_default() - .split(',') - .filter(|entry| !entry.is_empty()) - .map(|entry| entry.split(':').next().unwrap_or("").to_string()) - .collect::<Vec<_>>(); - let Some(pane_id) = queue.first() else { - return Ok(()); // nothing waiting +/// Show a pane in a client and mark it attended (`@dingger_ack`, consumed +/// by the watcher on its next tick). This is the single code path behind +/// every click: the tmux status line and the menu bar rows. +fn focus(client: Option<String>, pane: Option<String>) -> Result<(), String> { + let clients = tmux::clients(); + // The named client if it is attached; otherwise the most recently active + // one (a click on the status line makes the clicking client the most + // recent, so this is right even if the name did not expand). + let chosen = client + .and_then(|name| clients.iter().find(|(n, _)| *n == name).cloned()) + .or_else(|| clients.first().cloned()); + let Some((client, client_pid)) = chosen else { + return Err("no tmux client attached".to_string()); }; - let target = tmux::show_pane_format(pane_id, "#{session_name}:#{window_index}.#{pane_index}") - .unwrap_or_default(); - if target.is_empty() { - return Err("ready pane no longer exists".to_string()); - } - tmux::ack(pane_id)?; - let mut args = vec!["switch-client".to_string()]; - if let Some(client) = client { - args.push("-c".to_string()); - args.push(client); + + let pane_id = match pane { + Some(pane) => pane, + None => { + let current_window = tmux::client_format(&client, "#{window_id}"); + let queue = tmux::show_global("@dingger_queue").unwrap_or_default(); + let next = queue.split(',').filter(|id| !id.is_empty()).find(|id| { + tmux::pane_format(id, "#{window_id}").as_deref() != current_window.as_deref() + }); + match next { + Some(id) => id.to_string(), + None => return Ok(()), // nothing waiting elsewhere + } + } + }; + + tmux::set_pane_option(&pane_id, "@dingger_ack", "1")?; + tmux::show_pane(&client, &pane_id)?; + let _ = hooks::poke(); + activate_terminal_app(client_pid); + Ok(()) +} + +/// Bring the terminal app hosting a tmux client to the front: walk the +/// client's process ancestry up to the first `*.app` bundle (Ghostty, +/// Alacritty, ...) and `open -a` it. +fn activate_terminal_app(client_pid: u32) { + let mut pid = client_pid; + for _ in 0..16 { + let Ok(out) = std::process::Command::new("ps") + .args(["-o", "ppid=,comm=", "-p", &pid.to_string()]) + .output() + else { + return; + }; + let line = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let Some((ppid, comm)) = line.split_once(char::is_whitespace) else { + return; + }; + if let Some(end) = comm.find(".app/") { + let bundle = &comm[..end + 4]; + let _ = std::process::Command::new("open").args(["-a", bundle]).status(); + return; + } + match ppid.trim().parse::<u32>() { + Ok(parent) if parent > 1 => pid = parent, + _ => return, + } } - args.push("-t".to_string()); - args.push(target); - tmux::run_tmux_client( - &args.iter().map(String::as_str).collect::<Vec<_>>(), - ) } +/// Every agent pane in herdr's priority order (blocked, done, working, +/// idle), oldest-waiting first within a status. fn status() -> Result<(), String> { - let panes = tmux::list_panes()?; + let mut panes: Vec<_> = tmux::list_panes()? + .into_iter() + .filter(|pane| pane.agent.is_some()) + .collect(); if panes.is_empty() { - println!("no tmux panes"); + println!("no agent panes"); return Ok(()); } - println!("{:<22} {:<10} {:<12} COMMAND", "TARGET", "AGENT", "STATE"); + panes.sort_by_key(|pane| { + let status = watcher::Status::from_label(&pane.written_state); + let since = pane.written_since.parse::<u64>().unwrap_or(u64::MAX); + (std::cmp::Reverse(status.priority()), since, pane.display_target.clone()) + }); + println!("{:<8} {:<10} TARGET", "STATUS", "AGENT"); for pane in panes { - println!( - "{:<22} {:<10} {:<12} {}", - pane.display_target, - pane.agent.map(detect_core::agent_label).unwrap_or("-"), - if pane.written_state.is_empty() { - "-" - } else { - &pane.written_state - }, - pane.current_command, - ); + let state = if pane.written_state.is_empty() { "-" } else { &pane.written_state }; + let agent = pane.agent.map(detect_core::agent_label).unwrap_or("-"); + println!("{state:<8} {agent:<10} {}", pane.display_target); } Ok(()) } diff --git a/crates/dinggerd/src/notify.rs b/crates/dinggerd/src/notify.rs index 9fcfcde..e619d6d 100644 --- a/crates/dinggerd/src/notify.rs +++ b/crates/dinggerd/src/notify.rs @@ -3,7 +3,7 @@ use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; -use tracing::{debug, warn}; +use tracing::{info, warn}; pub enum NotificationKind { NeedsAttention, @@ -63,14 +63,27 @@ pub fn play_sound(kind: &NotificationKind) { } } +/// Deliver a macOS notification without blocking the caller: the delivery +/// call waits on Notification Center for seconds, and the watcher must keep +/// updating the UI meanwhile. pub fn send_notification(agent_label: &str, kind: &NotificationKind, context: &str) { - debug!(agent = agent_label, kind = kind.title(), context, "notifying"); + info!(agent = agent_label, kind = kind.title(), context, "notifying"); let title = format!("{agent_label} {}", kind.title()); - let notification = mac_notification_sys::Notification::new() - .title(title.as_str()) - .message(context) - .send(); - if let Err(err) = notification { - warn!(%err, "failed to deliver macOS notification"); - } + let context = context.to_string(); + std::thread::spawn(move || { + // Test seam: deliver through a command instead (tests/e2e.sh). + if let Ok(command) = std::env::var("DINGGER_NOTIFIER") { + let _ = std::process::Command::new("sh") + .args(["-c", &command, &title, &context]) + .status(); + return; + } + let notification = mac_notification_sys::Notification::new() + .title(title.as_str()) + .message(context.as_str()) + .send(); + if let Err(err) = notification { + warn!(%err, "failed to deliver macOS notification"); + } + }); } diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index f6c2f17..326eda9 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -2,7 +2,7 @@ use std::process::Command; -use detect_core::{identify_agent, Agent, AgentState}; +use detect_core::{identify_agent, Agent}; #[derive(Debug, Clone)] pub struct Pane { @@ -10,38 +10,27 @@ pub struct Pane { pub pane_id: 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}`. pub pane_title: String, pub dead: bool, - /// The pane's window is the active window of its session. + /// The pane's window is the current window of its session. pub window_active: bool, /// The pane's session has at least one attached client. pub session_attached: bool, pub agent: Option<Agent>, - /// The `@agent_state` user option previously written for this pane, - /// read straight from the pane format. + /// Pane options written by dingger (see the watcher module docs). pub written_state: String, - /// The `@agent` user option (agent label) previously written, if any. pub written_agent: String, - /// 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, + pub written_since: String, + pub written_hook: String, } impl Pane { - /// 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. + /// The user is looking at this pane's window. herdr marks every pane of + /// the active tab seen (`mark_active_tab_seen`); a tmux window is a tab. pub fn attended(&self) -> bool { - self.pane_active && self.suppresses_notifications() + self.window_active && self.session_attached } } @@ -80,42 +69,42 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { "#{@agent_state}\t", "#{@agent}\t", "#{@dingger_ack}\t", - "#{pane_active}\t", + "#{@dingger_since}\t", + "#{@dingger_hook}\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(); + let mut process_tree = None; for line in out.lines() { let fields: Vec<&str> = line.split('\t').collect(); - if fields.len() < 15 { + if fields.len() < 16 { continue; } - let current_command = fields[1].to_string(); - let start_command = fields[6].to_string(); + let current_command = fields[1]; + let start_command = fields[6]; 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 = - identify_agent(¤t_command).or_else(|| identify_agent(&start_command)); + identify_agent(current_command).or_else(|| identify_agent(start_command)); // Some agents rename their process (claude sets its title to the // version string), so `pane_current_command` alone is not enough. - let mut process_tree = None; if agent.is_none() { 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) { + let mut display_target = fields[13].to_string(); + if fields[14].parse::<u32>().map(|n| n > 1).unwrap_or(false) { display_target.push('.'); - display_target.push_str(fields[14]); + display_target.push_str(fields[15]); } panes.push(Pane { pane_id: fields[0].to_string(), display_target, - current_command, pane_title: fields[2].to_string(), dead: fields[3] == "1", window_active: fields[4] == "1", @@ -124,7 +113,8 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { written_state: fields[8].to_string(), written_agent: fields[9].to_string(), written_ack: fields[10].to_string(), - pane_active: fields[11] == "1", + written_since: fields[11].to_string(), + written_hook: fields[12].to_string(), }); } Ok(panes) @@ -187,35 +177,18 @@ fn fetch_process_tree() -> ProcessTree { (children, comm) } -/// Run a tmux command whose effect is an action (e.g. switch-client), not -/// output to parse. -pub fn run_tmux_client(args: &[&str]) -> Result<(), String> { - run_tmux(args).map(|_| ()) -} - -/// Read a format expansion for one pane. -pub fn show_pane_format(pane_id: &str, format: &str) -> Option<String> { - run_tmux(&["display-message", "-p", "-t", pane_id, format]) - .ok() - .map(|value| value.trim().to_string()) -} - -/// Capture the pane's visible screen as plain text — the exact input shape +/// Capture the pane's visible screen as plain text: the exact input shape /// the detection manifests expect. pub fn capture_pane(pane_id: &str) -> Result<String, String> { run_tmux(&["capture-pane", "-p", "-q", "-t", pane_id]) } -/// 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(|_| ()) +pub fn set_pane_option(pane_id: &str, name: &str, value: &str) -> Result<(), String> { + run_tmux(&["set-option", "-p", "-t", pane_id, name, value]).map(|_| ()) } -/// Label the pane with the agent running in it, for tmux status-line and -/// menu-bar rendering; clears the label when the pane stops running one. -pub fn set_agent(pane_id: &str, agent: Option<Agent>) -> Result<(), String> { - let label = agent.map(detect_core::agent_label).unwrap_or(""); - run_tmux(&["set-option", "-p", "-t", pane_id, "@agent", label]).map(|_| ()) +pub fn unset_pane_option(pane_id: &str, name: &str) -> Result<(), String> { + run_tmux(&["set-option", "-p", "-u", "-t", pane_id, name]).map(|_| ()) } /// Write a server-global option (readable from any client and from formats). @@ -230,12 +203,60 @@ pub fn show_global(name: &str) -> Option<String> { .filter(|value| !value.is_empty()) } -/// Mark a pane as seen by the user after `dingger focus`: taken out of the -/// ready queue until its next state transition. -pub fn ack(pane_id: &str) -> Result<(), String> { - run_tmux(&["set-option", "-p", "-t", pane_id, "@dingger_ack", "1"]).map(|_| ()) +/// Expand a format in the context of a pane. +pub fn pane_format(pane_id: &str, format: &str) -> Option<String> { + run_tmux(&["display-message", "-p", "-t", pane_id, format]) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) } -pub fn clear_ack(pane_id: &str) -> Result<(), String> { - run_tmux(&["set-option", "-p", "-t", pane_id, "-u", "@dingger_ack"]).map(|_| ()) +/// Expand a format in the context of a client (its current pane/window). +pub fn client_format(client: &str, format: &str) -> Option<String> { + run_tmux(&["display-message", "-p", "-c", client, format]) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +/// Attached clients as (name, pid), most recently active first. +pub fn clients() -> Vec<(String, u32)> { + let Ok(out) = run_tmux(&[ + "list-clients", + "-F", + "#{client_activity}\t#{client_name}\t#{client_pid}", + ]) else { + return Vec::new(); + }; + let mut clients: Vec<(u64, String, u32)> = out + .lines() + .filter_map(|line| { + let mut fields = line.split('\t'); + let activity = fields.next()?.parse().ok()?; + let name = fields.next()?.to_string(); + let pid = fields.next()?.parse().ok()?; + Some((activity, name, pid)) + }) + .collect(); + clients.sort_by_key(|client| std::cmp::Reverse(client.0)); + clients.into_iter().map(|(_, name, pid)| (name, pid)).collect() +} + +/// Show a pane in a client: switch the client to the pane's window and make +/// the pane the active one. +pub fn show_pane(client: &str, pane_id: &str) -> Result<(), String> { + run_tmux(&["switch-client", "-c", client, "-t", pane_id])?; + run_tmux(&["select-pane", "-t", pane_id]).map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ps_tree() { + let (children, comm) = fetch_process_tree(); + assert!(comm.contains_key(&1u32), "launchd should be present"); + assert!(!children.is_empty()); + } } diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index f1cb90e..2455407 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -1,21 +1,28 @@ //! Poll-and-detect loop: tmux pane capture → detection engine → herdr's //! status model → notifications and pane-option write-back. //! -//! Status model (herdr's, verbatim): five display statuses over two inputs — +//! 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): +//! `seen` flag: //! //! | raw | seen | display | priority | //! |----------|--------|----------|----------| -//! | working | — | working | 2 | -//! | blocked | — | blocked | 4 | +//! | working | any | working | 2 | +//! | blocked | any | blocked | 4 | //! | idle | false | done | 3 | //! | idle | true | idle | 1 | -//! | none | — | unknown | 0 | +//! | none | any | 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. +//! `seen` follows herdr's `apply_pane_state_change` and +//! `mark_active_tab_seen` (src/app/actions.rs); see [`advance`]. +//! +//! All per-pane state lives in tmux pane options, so a daemon restart picks +//! up exactly where it left off: +//! +//! - `@agent_state` display status (what every surface renders) +//! - `@dingger_since` unix ms the pane started needing the user (FIFO) +//! - `@dingger_hook` last state an agent extension reported +//! - `@dingger_ack` one-shot "the user jumped here" event from `dingger focus` use std::collections::HashMap; @@ -26,8 +33,8 @@ use crate::hooks; use crate::notify::{play_sound, send_notification, NotificationKind}; use crate::tmux; -/// herdr's display statuses, in priority order (highest first). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +/// herdr's display statuses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Status { Unknown, Idle, @@ -37,8 +44,7 @@ pub enum Status { } impl Status { - #[allow(dead_code)] // herdr parity: the full vocabulary is part of the model - pub fn label(&self) -> &'static str { + pub fn label(self) -> &'static str { match self { Status::Unknown => "unknown", Status::Idle => "idle", @@ -48,21 +54,18 @@ impl Status { } } - #[allow(dead_code)] // herdr parity - pub fn parse(label: &str) -> Option<Status> { + pub fn from_label(label: &str) -> Status { match label { - "working" => Some(Status::Working), - "blocked" => Some(Status::Blocked), - "done" => Some(Status::Done), - "idle" => Some(Status::Idle), - "unknown" => Some(Status::Unknown), - _ => None, + "idle" => Status::Idle, + "working" => Status::Working, + "done" => Status::Done, + "blocked" => Status::Blocked, + _ => Status::Unknown, } } /// 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 { + pub fn priority(self) -> u8 { match self { Status::Blocked => 4, Status::Done => 3, @@ -84,49 +87,126 @@ pub fn notification_kind_for_transition( } match new_state { AgentState::Blocked => Some(NotificationKind::NeedsAttention), - AgentState::Idle - if matches!(prev_state, AgentState::Working | AgentState::Blocked) => - { + AgentState::Idle if matches!(prev_state, AgentState::Working | AgentState::Blocked) => { Some(NotificationKind::Finished) } _ => None, } } +/// herdr's per-pane attendance record. #[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 PaneTrack { + pub state: AgentState, + pub seen: bool, + /// Unix millisecond the pane started needing the user (done or blocked); + /// orders the FIFO queue within a status. + pub since: Option<u64>, +} + +impl PaneTrack { + /// A pane not observed yet. herdr: `PaneState::new` starts `seen = true`. + pub const NEW: PaneTrack = PaneTrack { + state: AgentState::Unknown, + seen: true, + since: None, + }; + + pub fn status(&self) -> Status { + match (self.state, self.seen) { + (AgentState::Working, _) => Status::Working, + (AgentState::Blocked, _) => Status::Blocked, + (AgentState::Idle, false) => Status::Done, + (AgentState::Idle, true) => Status::Idle, + (AgentState::Unknown, _) => Status::Unknown, + } + } + + /// Rebuild a track from the pane options a previous daemon wrote. + fn restore(written_state: &str, written_since: &str) -> PaneTrack { + let since = written_since.trim().parse::<u64>().ok(); + let (state, seen) = match written_state { + "done" => (AgentState::Idle, false), + "idle" => (AgentState::Idle, true), + "working" => (AgentState::Working, true), + "blocked" => (AgentState::Blocked, true), + _ => return PaneTrack::NEW, + }; + let track = PaneTrack { state, seen, since }; + let needs_user = matches!(track.status(), Status::Done | Status::Blocked); + PaneTrack { + since: if needs_user { since } else { None }, + ..track + } + } +} + +/// One observation step for a pane: herdr's `apply_pane_state_change` +/// followed by `mark_active_tab_seen`, as a pure function. +/// +/// - Transition to any non-idle state: seen. +/// - Completion (working/blocked to idle): seen only if the user is looking +/// at the pane's window; otherwise the pane is done. +/// - Any other transition to idle (first observation): seen unchanged. +/// - Looking at the pane's window, or jumping to it (`acked`): seen. +pub fn advance( + prev: PaneTrack, + observed: AgentState, + attended: bool, + acked: bool, + now: u64, +) -> PaneTrack { + let mut next = prev; + if observed != prev.state { + next.state = observed; + if observed != AgentState::Idle { + next.seen = true; + } else if matches!(prev.state, AgentState::Working | AgentState::Blocked) { + next.seen = attended; + } + next.since = None; // a new episode + } + if attended || acked { + next.seen = true; + } + let needs_user = matches!(next.status(), Status::Done | Status::Blocked); + next.since = match (needs_user, next.since) { + (true, Some(since)) => Some(since), + (true, None) => Some(now), + (false, _) => None, + }; + next } pub struct Watcher { tracks: HashMap<String, PaneTrack>, - /// Last published (queue, counts); avoids rewriting options every tick. - last_published: Option<(String, (String, String, String, String))>, + /// Last published (queue, blocked, done, working); avoids rewriting the + /// global options every tick. + last_published: Option<(String, usize, usize, usize)>, interval_secs: u64, sounds: bool, + notifications: bool, } impl Watcher { - pub fn new(interval_secs: u64, sounds: bool) -> Self { + pub fn new(interval_secs: u64, sounds: bool, notifications: bool) -> Self { Self { tracks: HashMap::new(), last_published: None, interval_secs: interval_secs.max(1), sounds, + notifications, } } pub fn run(&mut self) -> Result<(), String> { info!(interval = self.interval_secs, "watching tmux panes"); - // dingger is a client of the tmux server, which may not exist yet — + // dingger is a client of the tmux server, which may not exist yet: // it starts when the first terminal opens one. Wait for it, then // keep watching across server restarts. let mut had_server = false; loop { + let started = std::time::Instant::now(); match self.tick() { Ok(()) => { if !had_server { @@ -141,145 +221,64 @@ impl Watcher { had_server = false; } } - std::thread::sleep(std::time::Duration::from_secs(self.interval_secs)); + debug!(ms = started.elapsed().as_millis() as u64, "tick"); + hooks::wait_for_wake(std::time::Duration::from_secs(self.interval_secs)); } } fn tick(&mut self) -> Result<(), String> { let panes = tmux::list_panes()?; + let now = unix_now(); // Forget panes that went away. self.tracks .retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id)); - hooks::forget( - &panes - .iter() - .map(|pane| pane.pane_id.clone()) - .collect::<Vec<_>>(), - ); + hooks::forget(&panes.iter().map(|pane| pane.pane_id.clone()).collect::<Vec<_>>()); for pane in &panes { - 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)); - - // 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 raw = if pane.dead { - Some(AgentState::Idle) - } else if let Some(agent) = pane.agent { - if let Some(state) = hooks::hook_state(&pane.pane_id) { - Some(state) - } else { - let screen = match tmux::capture_pane(&pane.pane_id) { - Ok(screen) => screen, - Err(err) => { - warn!(target = %pane.display_target, %err, "capture failed"); - continue; - } - }; - // tmux pane_title carries the last OSC title the application - // set; tmux does not re-expose OSC progress sequences. - let detection = - detect_agent_with_osc(Some(agent), &screen, &pane.pane_title, ""); - (!detection.skip_state_update).then_some(detection.state) - } - } else { - // A pane that stops running an agent (agent quit into a - // shell) is no longer an observation; a stale hook report - // from the dead agent is not authoritative. - Some(AgentState::Unknown) - }; - - let Some(new_state) = raw else { + let Some(observations) = observe(pane) 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 _ = tmux::clear_ack(&pane.pane_id); - } else if seen != prev_seen { - let _ = tmux::ack(&pane.pane_id); - } - let display = display_status( - self.tracks - .get(&pane.pane_id) - .unwrap_or(&PaneTrack { state: new_state, seen, ready_since: None }), - ); - // @agent_state mirrors the display status on both transitions and - // attendance changes (done → idle when the user focuses the pane). - if pane.written_state != display.label() { - if let Err(err) = tmux::set_agent_state(&pane.pane_id, display.label()) { - warn!(target = %pane.display_target, %err, "failed to write @agent_state"); - } - } - if new_state == AgentState::Unknown { - // The agent is gone; its last hook report must not outlive it. - hooks::drop_report(&pane.pane_id); - } + // First sight of a pane in this process: resume from its options. + let restored = !self.tracks.contains_key(&pane.pane_id); + let mut track = self + .tracks + .get(&pane.pane_id) + .copied() + .unwrap_or_else(|| PaneTrack::restore(&pane.written_state, &pane.written_since)); + let acked = pane.written_ack == "1"; - // Keep the pane's agent label current even when the state is - // unchanged (e.g. an agent swap between two idle panes). - let agent_label = pane - .agent - .map(detect_core::agent_label) - .unwrap_or("") - .to_string(); - if pane.written_agent != agent_label { - if let Err(err) = tmux::set_agent(&pane.pane_id, pane.agent) { - warn!(target = %pane.display_target, %err, "failed to write @agent"); + // Apply every observation in order: a turn that started and + // finished between two ticks is still a completion. + let mut alerts = Vec::new(); + for observed in observations { + let next = advance(track, observed, pane.attended(), acked, now); + if next.state != track.state { + info!( + target = %pane.display_target, + prev = track.status().label(), + next = next.status().label(), + "state change" + ); + // Notify on transitions observed by this process, not + // when resuming from options written before a restart. + if !restored { + alerts.extend(notification_kind_for_transition(track.state, next.state)); + } } + track = next; } + self.tracks.insert(pane.pane_id.clone(), track); - 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); - } - } + // Update what the user sees first, then alert. + if acked { + let _ = tmux::unset_pane_option(&pane.pane_id, "@dingger_ack"); + } + self.write_pane_options(pane, track); + for kind in alerts { + self.notify(pane, kind); } } @@ -287,134 +286,279 @@ impl Watcher { Ok(()) } - /// 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 write_pane_options(&self, pane: &tmux::Pane, track: PaneTrack) { + let label = track.status().label(); + if pane.written_state != label { + if let Err(err) = tmux::set_pane_option(&pane.pane_id, "@agent_state", label) { + warn!(target = %pane.display_target, %err, "failed to write @agent_state"); + } + } + let since = track.since.map(|s| s.to_string()).unwrap_or_default(); + if pane.written_since != since { + let _ = if since.is_empty() { + tmux::unset_pane_option(&pane.pane_id, "@dingger_since") + } else { + tmux::set_pane_option(&pane.pane_id, "@dingger_since", &since) + }; + } + let agent = pane.agent.map(detect_core::agent_label).unwrap_or(""); + if pane.written_agent != agent { + let _ = tmux::set_pane_option(&pane.pane_id, "@agent", agent); + } + // Persist the authoritative hook state so a restart keeps it. + let hook = hooks::last_state(&pane.pane_id) + .map(state_label) + .unwrap_or_default(); + if pane.agent.is_some() && !hook.is_empty() && pane.written_hook != hook { + let _ = tmux::set_pane_option(&pane.pane_id, "@dingger_hook", hook); + } + if pane.agent.is_none() && !pane.written_hook.is_empty() { + let _ = tmux::unset_pane_option(&pane.pane_id, "@dingger_hook"); + } + } + + fn notify(&self, pane: &tmux::Pane, kind: NotificationKind) { + // herdr: a blocked pane plays the alert even when the user is looking + // at it; a finished pane stays silent when they already are. + let watching = pane.attended(); + if matches!(kind, NotificationKind::Finished) && watching { + return; + } + let agent = pane.agent.map(detect_core::agent_label).unwrap_or("agent"); + if self.notifications && !watching { + send_notification(agent, &kind, &format!("{} ({agent})", pane.display_target)); + } + if self.sounds { + play_sound(&kind); + } + } + + /// Publish the ready queue and counts as server-global options for the + /// status line, menu bar, and focus. The queue is ordered like the menu: + /// herdr's priority (blocked before done), oldest first within it. 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; + let mut queue: Vec<(std::cmp::Reverse<u8>, u64, &str)> = Vec::new(); + let (mut blocked, mut done, mut working) = (0, 0, 0); for pane in panes { let Some(track) = self.tracks.get(&pane.pane_id) else { continue; }; - let status = display_status(track); - match status { + match track.status() { + Status::Blocked => blocked += 1, + Status::Done => done += 1, 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 { - ready.push((pane.pane_id.as_str(), pane.display_target.clone(), since)); - } - } - } Status::Idle | Status::Unknown => {} } + if let Some(since) = track.since { + queue.push((std::cmp::Reverse(track.status().priority()), since, pane.pane_id.as_str())); + } } - ready.sort_by_key(|(_, _, since)| *since); - - let queue = ready + queue.sort(); + let queue = queue .iter() - .map(|(pane_id, _, since)| format!("{pane_id}:{since}")) + .map(|(_, _, pane_id)| *pane_id) .collect::<Vec<_>>() .join(","); - let counts = ( - 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) { - 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()), - ("@dingger_done", counts.3.as_str()), - ] { - if let Err(err) = tmux::set_global(name, value) { - warn!(%err, "failed to write {name}"); - } + + let next = (queue, blocked, done, working); + if self.last_published.as_ref() == Some(&next) { + return; + } + for (name, value) in [ + ("@dingger_queue", next.0.clone()), + ("@dingger_blocked", next.1.to_string()), + ("@dingger_done", next.2.to_string()), + ("@dingger_working", next.3.to_string()), + ] { + if let Err(err) = tmux::set_global(name, &value) { + warn!(%err, "failed to write {name}"); } } + self.last_published = Some(next); + } +} + +/// The pane's raw agent states this tick, in order, or `None` when the +/// screen is a viewer (transcript, picker) that says nothing about state. +fn observe(pane: &tmux::Pane) -> Option<Vec<AgentState>> { + if pane.dead { + return Some(vec![AgentState::Idle]); + } + let Some(agent) = pane.agent else { + // The agent is gone; its last hook report must not outlive it. + hooks::drop_reports(&pane.pane_id); + return Some(vec![AgentState::Unknown]); + }; + // Agent-reported state is authoritative; it survives restarts through + // the `@dingger_hook` option until the extension reports again. + if let Some(states) = hooks::take_observations(&pane.pane_id) { + return Some(states); + } + if let Some(state) = parse_state(&pane.written_hook) { + return Some(vec![state]); } + let screen = match tmux::capture_pane(&pane.pane_id) { + Ok(screen) => screen, + Err(err) => { + warn!(target = %pane.display_target, %err, "capture failed"); + return None; + } + }; + // tmux pane_title carries the last OSC title the application set; tmux + // does not re-expose OSC progress sequences. + let detection = detect_agent_with_osc(Some(agent), &screen, &pane.pane_title, ""); + (!detection.skip_state_update).then(|| vec![detection.state]) } -/// 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 { +fn state_label(state: AgentState) -> &'static str { match state { - AgentState::Blocked => true, - AgentState::Idle => !seen, - _ => false, + AgentState::Idle => "idle", + AgentState::Working => "working", + AgentState::Blocked => "blocked", + AgentState::Unknown => "unknown", } } -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 parse_state(label: &str) -> Option<AgentState> { + match label { + "idle" => Some(AgentState::Idle), + "working" => Some(AgentState::Working), + "blocked" => Some(AgentState::Blocked), + _ => None, } } fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) + .map(|d| d.as_millis() as u64) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; + use AgentState::{Blocked, Idle, Unknown, Working}; + + fn step(prev: PaneTrack, observed: AgentState, attended: bool) -> PaneTrack { + advance(prev, observed, attended, false, 100) + } + + #[test] + fn notification_policy_matches_herdr() { + assert!(matches!( + notification_kind_for_transition(Working, Blocked), + Some(NotificationKind::NeedsAttention) + )); + assert!(matches!( + notification_kind_for_transition(Unknown, Blocked), + Some(NotificationKind::NeedsAttention) + )); + assert!(matches!( + notification_kind_for_transition(Working, Idle), + Some(NotificationKind::Finished) + )); + assert!(matches!( + notification_kind_for_transition(Blocked, Idle), + Some(NotificationKind::Finished) + )); + assert!(notification_kind_for_transition(Blocked, Blocked).is_none()); + assert!(notification_kind_for_transition(Unknown, Idle).is_none()); + assert!(notification_kind_for_transition(Idle, Working).is_none()); + assert!(notification_kind_for_transition(Idle, Unknown).is_none()); + } + + // herdr: initial_idle_in_background_stays_seen + #[test] + fn first_observation_of_idle_is_idle() { + let track = step(PaneTrack::NEW, Idle, false); + assert_eq!(track.status(), Status::Idle); + assert_eq!(track.since, None); + } + + // herdr: state_changed_idle_in_background_marks_unseen + #[test] + fn completion_away_from_the_window_is_done() { + let working = step(step(PaneTrack::NEW, Idle, false), Working, false); + assert_eq!(working.status(), Status::Working); + let done = step(working, Idle, false); + assert_eq!(done.status(), Status::Done); + assert_eq!(done.since, Some(100)); + } + + // herdr: active_tab_completion_marks_pane_seen + #[test] + fn completion_while_looking_is_idle() { + let working = step(PaneTrack::NEW, Working, true); + let idle = step(working, Idle, true); + assert_eq!(idle.status(), Status::Idle); + assert_eq!(idle.since, None); + } + + // herdr: switch_workspace_marks_panes_seen (mark_active_tab_seen) + #[test] + fn looking_at_the_window_turns_done_into_idle() { + let done = step(step(PaneTrack::NEW, Working, false), Idle, false); + assert_eq!(done.status(), Status::Done); + let still_done = step(done, Idle, false); + assert_eq!(still_done.status(), Status::Done); + let seen = step(still_done, Idle, true); + assert_eq!(seen.status(), Status::Idle); + assert_eq!(seen.since, None); + } + + // herdr: agent_focus_marks_already_focused_done_agent_seen + #[test] + fn jumping_to_a_pane_turns_done_into_idle() { + let done = step(step(PaneTrack::NEW, Working, false), Idle, false); + let seen = advance(done, Idle, false, true, 200); + assert_eq!(seen.status(), Status::Idle); + } + + #[test] + fn working_again_clears_done_and_the_next_completion_is_done_again() { + let done = step(step(PaneTrack::NEW, Working, false), Idle, false); + let working = step(done, Working, false); + assert_eq!(working.status(), Status::Working); + assert_eq!(working.since, None); + let done_again = advance(working, Idle, false, false, 300); + assert_eq!(done_again.status(), Status::Done); + assert_eq!(done_again.since, Some(300)); + } + + #[test] + fn blocked_needs_the_user_even_while_looking() { + let blocked = step(step(PaneTrack::NEW, Working, false), Blocked, true); + assert_eq!(blocked.status(), Status::Blocked); + assert_eq!(blocked.since, Some(100)); + // answering the prompt: back to work, then a completion away → done + let done = step(step(blocked, Working, false), Idle, false); + assert_eq!(done.status(), Status::Done); + } + + #[test] + fn fifo_stamp_survives_unchanged_ticks() { + let done = advance(step(PaneTrack::NEW, Working, false), Idle, false, false, 50); + let later = advance(done, Idle, false, false, 999); + assert_eq!(later.since, Some(50)); + } #[test] - fn policy_matches_herdr() { - assert!(notification_kind_for_transition(AgentState::Working, AgentState::Blocked) - .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention))); - assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Blocked).is_none()); - assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Blocked) - .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention))); - assert!(notification_kind_for_transition(AgentState::Working, AgentState::Idle) - .is_some_and(|kind| matches!(kind, NotificationKind::Finished))); - assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Idle) - .is_some_and(|kind| matches!(kind, NotificationKind::Finished))); - // Unknown → Idle is a first observation, not a completion. - assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Idle).is_none()); - assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Working).is_none()); - assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Unknown).is_none()); + fn agent_exit_is_unknown() { + let done = step(step(PaneTrack::NEW, Working, false), Idle, false); + assert_eq!(step(done, Unknown, false).status(), Status::Unknown); } #[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 - ); + fn restore_resumes_where_the_previous_daemon_left_off() { + let done = PaneTrack::restore("done", "42"); + assert_eq!(done.status(), Status::Done); + assert_eq!(done.since, Some(42)); + // the same observation after a restart keeps it done + assert_eq!(step(done, Idle, false).status(), Status::Done); + assert_eq!(PaneTrack::restore("idle", "").status(), Status::Idle); + assert_eq!(PaneTrack::restore("", "").status(), Status::Unknown); + assert_eq!(PaneTrack::restore("idle", "42").since, None); } #[test] diff --git a/menubar/main.swift b/menubar/main.swift index d0a3b30..602b5cc 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -1,286 +1,272 @@ -// dingger menu bar: live agent states from tmux pane user options -// (@agent / @agent_state), written by the dingger daemon. +// dingger menu bar: herdr's agent panel as a macOS status item. // -// 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). +// Reads the pane options the dingger daemon writes (@agent, @agent_state, +// @dingger_since) and renders herdr's Symbols style with herdr's palette: +// +// blocked × red done ✓ teal working ◐ yellow idle ○ green +// +// in herdr's priority order (blocked > done > working > idle), oldest +// waiting first. Clicking a row runs `dingger focus --pane`, the same tested +// code path as the tmux status-line click. +// +// Debug flags (used by tests/e2e.sh): +// --print print the title and rows the menu would show, then exit +// --click <pane> perform a row click for <pane>, then exit import AppKit -struct PaneRow { - let paneId: String - let target: String - let agent: String - let state: String -} - -let red = NSColor.systemRed -let yellow = NSColor.systemYellow -let teal = NSColor.systemTeal -let green = NSColor.systemGreen -let gray = NSColor.systemGray +// MARK: herdr's palette (catppuccin mocha on dark, latte on light) -/// Tint a symbol image (non-template, so it keeps its color in menus). -func tinted(_ image: NSImage?, _ color: NSColor) -> NSImage? { - guard let image = image else { return nil } - let img = NSImage(size: image.size, flipped: false) { rect in - image.draw(in: rect) - color.set() - rect.fill(using: .sourceAtop) - return true +func herdrColor(dark: (CGFloat, CGFloat, CGFloat), light: (CGFloat, CGFloat, CGFloat)) -> NSColor { + NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + let (r, g, b) = isDark ? dark : light + return NSColor(srgbRed: r / 255, green: g / 255, blue: b / 255, alpha: 1) } - img.isTemplate = false - return img } -func symbol(_ name: String, _ color: NSColor? = nil) -> NSImage? { - let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)? - .withSymbolConfiguration(.init(pointSize: 13, weight: .regular)) - return color.map { tinted(image, $0) } ?? image -} +let herdrRed = herdrColor(dark: (243, 139, 168), light: (210, 15, 57)) +let herdrTeal = herdrColor(dark: (148, 226, 213), light: (23, 146, 153)) +let herdrYellow = herdrColor(dark: (249, 226, 175), light: (223, 142, 29)) +let herdrGreen = herdrColor(dark: (166, 227, 161), light: (64, 160, 43)) +let herdrGray = herdrColor(dark: (108, 112, 134), light: (156, 160, 176)) -/// 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) - } -} +// MARK: herdr's status vocabulary -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 +enum Status: String { + case blocked, done, working, idle, unknown + + var symbol: String { + switch self { + case .blocked: return "×" + case .done: return "✓" + case .working: return "◐" + case .idle: return "○" + case .unknown: return "·" + } } -} -final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { - private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - private let menu = NSMenu() - private var panes: [PaneRow] = [] - private var queue: [String] = [] // pane ids, oldest waiting first + var color: NSColor { + switch self { + case .blocked: return herdrRed + case .done: return herdrTeal + case .working: return herdrYellow + case .idle: return herdrGreen + case .unknown: return herdrGray + } + } - func applicationDidFinishLaunching(_: Notification) { - NSApp.setActivationPolicy(.accessory) // no Dock icon - item.button?.image = symbol("bell", gray) - item.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular) - item.menu = menu - menu.delegate = self - let timer = Timer(timeInterval: 2.0, repeats: true) { [weak self] _ in - self?.refreshTitle() + var priority: Int { + switch self { + case .blocked: return 4 + case .done: return 3 + case .working: return 2 + case .idle: return 1 + case .unknown: return 0 } - RunLoop.main.add(timer, forMode: .common) - refreshTitle() } +} - // MARK: data +struct Row { + let paneId: String + let agent: String + let status: Status + let target: String + let since: Int +} - private func runTmux(_ args: [String]) -> String? { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/tmux") - process.arguments = args - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice - do { - try process.run() - } catch { - return nil - } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus == 0 else { return nil } - return String(data: data, encoding: .utf8) +// MARK: tmux and dingger access + +let environment = ProcessInfo.processInfo.environment + +func run(_ path: String, _ args: [String]) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = args + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + return String(data: data, encoding: .utf8) +} - private func fetchPanes() -> [PaneRow] { - 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 >= 7 else { return nil } - let agent = String(fields[1]) - let state = fields[2].isEmpty ? "unknown" : String(fields[2]) - guard !agent.isEmpty, ["working", "blocked", "done", "idle"].contains(state) else { - return nil - } - // 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) - } +func tmux(_ args: [String]) -> String? { + var all = ["-u"] + if let socket = environment["DINGGER_TMUX_SOCKET"] { + all += ["-L", socket] } + return run(environment["DINGGER_TMUX"] ?? "/opt/homebrew/bin/tmux", all + args) +} - private func fetchQueue() -> [String] { - guard let out = runTmux(["-u", "show-option", "-gv", "@dingger_queue"]) else { return [] } - return out.trimmingCharacters(in: .whitespacesAndNewlines) - .split(separator: ",").map { $0.split(separator: ":").first.map(String.init) ?? "" } +let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/bin/dingger") + +/// Every agent pane, in herdr's priority order, oldest waiting first. +func fetchRows() -> [Row] { + let format = [ + "#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", + "#{session_name}:#{window_name}", "#{window_panes}", "#{pane_index}", + ].joined(separator: "\t") + guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] } + let rows: [Row] = out.split(separator: "\n").compactMap { line in + let f = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) + guard f.count >= 7, !f[1].isEmpty else { return nil } + let status = Status(rawValue: f[2]) ?? .unknown + guard status != .unknown else { return nil } + let target = (Int(f[5]) ?? 1) > 1 ? "\(f[4]).\(f[6])" : f[4] + return Row(paneId: f[0], agent: f[1], status: status, target: target, + since: Int(f[3]) ?? Int.max) + } + return rows.sorted { + if $0.status.priority != $1.status.priority { return $0.status.priority > $1.status.priority } + if $0.since != $1.since { return $0.since < $1.since } + return $0.target < $1.target } +} + +/// Show the pane and mark it attended; the same code path as the tmux +/// status-line click. +func focus(_ paneId: String) { + _ = run(dinggerBinary, ["focus", "--pane", paneId]) +} - 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 +// MARK: rendering + +/// herdr-style summary: `×1 ✓3 ◐2` in priority order; `○` when every agent +/// is idle; `·` when there are none. +func title(for rows: [Row]) -> NSAttributedString { + let font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium) + let text = NSMutableAttributedString() + for status in [Status.blocked, .done, .working] { + let count = rows.filter { $0.status == status }.count + guard count > 0 else { continue } + if text.length > 0 { + text.append(NSAttributedString(string: " ", attributes: [.font: font])) } - return rows + text.append(NSAttributedString( + string: "\(status.symbol)\(count)", + attributes: [.font: font, .foregroundColor: status.color])) } + if text.length == 0 { + let status: Status = rows.isEmpty ? .unknown : .idle + text.append(NSAttributedString( + string: status.symbol, attributes: [.font: font, .foregroundColor: status.color])) + } + return text +} - // MARK: title (timer-driven; never touches the open menu) +func plainTitle(for rows: [Row]) -> String { + title(for: rows).string +} - private func refreshTitle() { - panes = fetchPanes() - queue = fetchQueue() - FileHandle.standardError.write(Data( - "\(Date()) menubar: panes=\(panes.count) queue=\(queue.count)\n".utf8)) - redrawTitle() - } +func rowTitle(_ row: Row, tabStop: CGFloat) -> NSAttributedString { + let font = NSFont.menuFont(ofSize: 0) + let mono = NSFont.monospacedSystemFont(ofSize: font.pointSize - 1, weight: .regular) + let paragraph = NSMutableParagraphStyle() + paragraph.tabStops = [NSTextTab(textAlignment: .right, location: tabStop)] + let text = NSMutableAttributedString() + text.append(NSAttributedString( + string: "\(row.status.symbol) ", + attributes: [.font: mono, .foregroundColor: row.status.color, .paragraphStyle: paragraph])) + text.append(NSAttributedString( + string: row.agent, attributes: [.font: font, .paragraphStyle: paragraph])) + text.append(NSAttributedString( + string: "\t\(row.target)", + attributes: [.font: mono, .foregroundColor: NSColor.secondaryLabelColor, + .paragraphStyle: paragraph])) + return text +} - 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 +// MARK: app - let (image, count): (NSImage?, String) - if blocked > 0 { - (image, count) = (symbol("bell.fill", red), String(blocked)) - } else if done > 0 { - (image, count) = (symbol("bell.fill", teal), String(done)) - } else if working > 0 { - (image, count) = (symbol("bell", yellow), "") - } else { - (image, count) = (symbol("bell", gray), "") +final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { + private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + private let menu = NSMenu() + + func applicationDidFinishLaunching(_: Notification) { + menu.delegate = self + item.menu = menu + refreshTitle() + let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in + self?.refreshTitle() } - item.button?.image = image - item.button?.title = count + RunLoop.main.add(timer, forMode: .common) + } + private func refreshTitle() { + let rows = fetchRows() + item.button?.attributedTitle = title(for: rows) var tip: [String] = [] - if blocked > 0 { tip.append("\(blocked) blocked") } - if done > 0 { tip.append("\(done) done") } - if working > 0 { tip.append("\(working) working") } + for status in [Status.blocked, .done, .working, .idle] { + let count = rows.filter { $0.status == status }.count + if count > 0 { tip.append("\(count) \(status.rawValue)") } + } let tipText = tip.isEmpty ? "No agents" : tip.joined(separator: " · ") item.button?.toolTip = tipText item.button?.setAccessibilityLabel("dingger, " + tipText) } - // MARK: menu (rebuilt when opened, so rows don't reshuffle under the cursor) - + // Rebuilt from fresh data every time the menu opens. func menuNeedsUpdate(_ menu: NSMenu) { - rebuildMenu() - } - - private func rebuildMenu() { menu.removeAllItems() - - let rows = orderedPanes() + let rows = fetchRows() if rows.isEmpty { let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "") none.isEnabled = false menu.addItem(none) } - - var previousPriority: Int? - for pane in rows { - let priority = statusPriority(pane.state) - if let previous = previousPriority, previous != priority { - menu.addItem(.separator()) - } - previousPriority = priority - - let row = NSMenuItem(title: "\(pane.agent)\t\(pane.target)", - action: #selector(paneClicked(_:)), keyEquivalent: "") - row.target = self - row.image = statusSymbol(pane.state) - row.attributedTitle = attributedRow(agent: pane.agent, target: pane.target) - row.toolTip = "\(pane.state) — \(pane.target)" - row.representedObject = pane.paneId - menu.addItem(row) + let font = NSFont.menuFont(ofSize: 0) + let mono = NSFont.monospacedSystemFont(ofSize: font.pointSize - 1, weight: .regular) + let widest = rows.map { row -> CGFloat in + ("\(row.status.symbol) " as NSString).size(withAttributes: [.font: mono]).width + + (row.agent as NSString).size(withAttributes: [.font: font]).width + + (row.target as NSString).size(withAttributes: [.font: mono]).width + }.max() ?? 0 + let tabStop = ceil(widest + 40) + for row in rows { + let entry = NSMenuItem(title: row.agent, action: #selector(rowClicked(_:)), keyEquivalent: "") + entry.target = self + entry.attributedTitle = rowTitle(row, tabStop: tabStop) + entry.toolTip = "\(row.status.rawValue) · \(row.target)" + entry.representedObject = row.paneId + menu.addItem(entry) } - menu.addItem(.separator()) - let quit = NSMenuItem(title: "Quit dingger", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") - menu.addItem(quit) + menu.addItem(NSMenuItem(title: "Quit dingger", action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q")) } - /// Agent left, tmux target right-aligned at a computed tab stop — like a - /// native menu's key-equivalent column. - private func attributedRow(agent: String, target: String) -> NSAttributedString { - let menuFont = NSFont.menuFont(ofSize: 0) - let monoFont = NSFont.monospacedSystemFont( - ofSize: menuFont.pointSize - 1, weight: .regular) - let agentWidth = (agent as NSString).size(withAttributes: [.font: menuFont]).width - let targetWidth = (target as NSString).size(withAttributes: [.font: monoFont]).width - let tabStop = ceil(agentWidth + 40 + targetWidth) - - let paragraph = NSMutableParagraphStyle() - paragraph.tabStops = [NSTextTab(textAlignment: .right, location: tabStop)] - - let text = NSMutableAttributedString() - text.append(NSAttributedString( - string: agent, - attributes: [.font: menuFont, .paragraphStyle: paragraph])) - text.append(NSAttributedString(string: "\t")) - text.append(NSAttributedString( - string: target, - attributes: [.font: monoFont, .paragraphStyle: paragraph])) - return text - } - - // MARK: actions - - func printPanesForDebug() { - panes = fetchPanes() - queue = fetchQueue() - let rows = orderedPanes() - if rows.isEmpty { print("(no agents)") } - for pane in rows { - print("\(pane.state) \(pane.agent) \(pane.target)") + @objc private func rowClicked(_ sender: NSMenuItem) { + guard let paneId = sender.representedObject as? String else { return } + DispatchQueue.global().async { + focus(paneId) + DispatchQueue.main.async { self.refreshTitle() } } } +} - @objc private func paneClicked(_ sender: NSMenuItem) { - guard let paneId = sender.representedObject as? String else { return } - // Ack like `dingger focus` does, so both counts drop together. - _ = runTmux(["-u", "set-option", "-p", "-t", paneId, "@dingger_ack", "1"]) - let target = panes.first { $0.paneId == paneId }?.target ?? "" - guard !target.isEmpty else { return } - _ = runTmux(["-u", "switch-client", "-t", target]) - for bundle in ["com.mitchellh.ghostty", "org.alacritty", "com.apple.Terminal"] { - if let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundle).first { - app.activate() - break - } - } +let arguments = CommandLine.arguments +if arguments.contains("--print") { + let rows = fetchRows() + print("title: \(plainTitle(for: rows))") + for row in rows { + print("\(row.status.symbol) \(row.status.rawValue) \(row.agent) \(row.target)") } + exit(0) +} +if let index = arguments.firstIndex(of: "--click"), index + 1 < arguments.count { + focus(arguments[index + 1]) + exit(0) } let app = NSApplication.shared let delegate = Menubar() app.delegate = delegate app.setActivationPolicy(.accessory) - -// Debug: print the rows the menu would show and exit. -if CommandLine.arguments.contains("--print") { - delegate.printPanesForDebug() - exit(0) -} - app.run() diff --git a/packaging/launchd/dev.dingger.dingger.plist b/packaging/launchd/dev.dingger.dingger.plist index 212b903..2bb434a 100644 --- a/packaging/launchd/dev.dingger.dingger.plist +++ b/packaging/launchd/dev.dingger.dingger.plist @@ -7,7 +7,7 @@ <key>PATH</key> <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string> <key>RUST_LOG</key> - <string>debug</string> + <string>info</string> </dict> <key>KeepAlive</key> <true/> diff --git a/packaging/launchd/dev.dingger.menubar.plist b/packaging/launchd/dev.dingger.menubar.plist index c0b5b50..5ff8f0a 100644 --- a/packaging/launchd/dev.dingger.menubar.plist +++ b/packaging/launchd/dev.dingger.menubar.plist @@ -2,17 +2,21 @@ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> - <key>Label</key> - <string>dev.dingger.menubar</string> - <key>ProgramArguments</key> - <array> - <string>/Users/sz/Applications/dingger.app/Contents/MacOS/dingger-menu</string> - </array> - <key>RunAtLoad</key> - <true/> - <key>KeepAlive</key> - <true/> - <key>ThrottleInterval</key> - <integer>10</integer> + <key>KeepAlive</key> + <true/> + <key>Label</key> + <string>dev.dingger.menubar</string> + <key>ProgramArguments</key> + <array> + <string>/Users/YOURUSER/Applications/dingger.app/Contents/MacOS/dingger-menu</string> + </array> + <key>RunAtLoad</key> + <true/> + <key>StandardErrorPath</key> + <string>/Users/YOURUSER/Library/Logs/dingger-menubar.log</string> + <key>StandardOutPath</key> + <string>/Users/YOURUSER/Library/Logs/dingger-menubar.log</string> + <key>ThrottleInterval</key> + <integer>10</integer> </dict> </plist> diff --git a/packaging/tmux/dingger.tmux b/packaging/tmux/dingger.tmux new file mode 100644 index 0000000..64c5bff --- /dev/null +++ b/packaging/tmux/dingger.tmux @@ -0,0 +1,14 @@ +# dingger: herdr-style agent summary after the clock, in herdr's colors: +# × blocked (red, bold) ✓ done (teal) ◐ working (yellow) +# Clicking the status line jumps to the top waiting agent, like the top row +# of the menu bar: blocked first, then the longest-done (FIFO), skipping the +# window you are already looking at. +# +# Source this from your tmux config: source-file /path/to/dingger.tmux +set -g status-right '%d-%b %H:%M#{?#{e|>:#{@dingger_blocked},0}, #[fg=#f38ba8#,bold]×#{@dingger_blocked}#[default],}#{?#{e|>:#{@dingger_done},0}, #[fg=#94e2d5]✓#{@dingger_done}#[default],}#{?#{e|>:#{@dingger_working},0}, #[fg=#f9e2af]◐#{@dingger_working}#[default],}' +bind -n MouseDown1StatusRight run-shell "$HOME/.local/bin/dingger focus --client '#{client_name}'" +# Tell dingger the moment you look somewhere else (it marks what you see as +# attended). Index 77 keeps your own hooks and makes reloading idempotent. +set-hook -g 'client-session-changed[77]' 'run-shell -b "$HOME/.local/bin/dingger poke"' +set-hook -g 'session-window-changed[77]' 'run-shell -b "$HOME/.local/bin/dingger poke"' +set-hook -g 'window-pane-changed[77]' 'run-shell -b "$HOME/.local/bin/dingger poke"' diff --git a/tests/e2e.sh b/tests/e2e.sh new file mode 100755 index 0000000..d74d73b --- /dev/null +++ b/tests/e2e.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# End-to-end test of dingger's status model against a real, isolated tmux +# server. Nothing here touches the user's tmux server or hook socket. +# +# tests/e2e.sh [path/to/dingger] +# +# The test daemon polls only every 30s, so every update asserted here must +# arrive through events (hook reports, focus pokes, tmux focus hooks), the +# same paths that make the real UI react immediately. +# +# Layout: +# session a: window wa1 (pane A1, fake pi), window wa2 (pane A2, fake pi) +# session b: window wb1 (pane B1, fake pi) +# session viewer: a pane running a real tmux client attached to session b, +# so "what the user is looking at" is real tmux state. +set -u + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +DINGGER="${1:-$ROOT/target/release/dingger}" +MENUBAR="${2:-$ROOT/target/dingger-menu}" +SOCK=dingger-e2e +DIR=$(mktemp -d /tmp/dingger-e2e.XXXXXX) +HOOK="$DIR/agent.sock" +FAILS=0 +PASSES=0 + +# Every dingger invocation below (daemon, focus, poke, menu bar) targets the +# isolated server and the test daemon's hook socket. +export DINGGER_TMUX_SOCKET="$SOCK" DINGGER_AGENT_SOCKET="$HOOK" + +t() { tmux -u -L "$SOCK" "$@"; } + +cleanup() { + [ -n "${DAEMON_PID:-}" ] && kill "$DAEMON_PID" 2>/dev/null + t kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup EXIT + +# Notifications are on, delivered by a fake notifier that takes 3s like the +# real one can; the UI must not wait for it. +NOTES="$DIR/notifications" +start_daemon() { + DINGGER_NOTIFIER="sleep 3; echo \"\$0|\$1\" >> $NOTES" RUST_LOG=debug \ + "$DINGGER" watch --interval-secs 30 --no-sounds >>"$DIR/daemon.log" 2>&1 & + DAEMON_PID=$! + for _ in $(seq 50); do [ -S "$HOOK" ] && return; sleep 0.1; done + echo "daemon did not start"; cat "$DIR/daemon.log"; exit 1 +} + +hook() { # pane state + python3 - "$HOOK" "$1" "$2" <<'PY' +import json, socket, sys +s = socket.socket(socket.AF_UNIX) +s.connect(sys.argv[1]) +s.sendall((json.dumps({"method": "pane.report_agent", + "params": {"pane_id": sys.argv[2], "agent": "pi", "state": sys.argv[3]}}) + "\n").encode()) +s.close() +PY +} + +opt() { t show -pv -t "$1" "$2" 2>/dev/null; } # pane option +gopt() { t show -gv "$1" 2>/dev/null; } # global option +client_window() { t display -p -c "$CLIENT" '#{window_name}'; } + +pass() { PASSES=$((PASSES + 1)); echo " ok $1"; } +fail() { FAILS=$((FAILS + 1)); echo " FAIL $1"; } + +expect() { # description command expected [tries] (polls every 0.1s, default 4s) + local desc=$1 cmd=$2 want=$3 tries=${4:-40} got + for _ in $(seq "$tries"); do + got=$(eval "$cmd") + [ "$got" = "$want" ] && { pass "$desc"; return; } + sleep 0.1 + done + fail "$desc: want [$want] got [$got]" +} + +# --- setup ------------------------------------------------------------------- +mkdir -p "$DIR/bin" +printf '#!/bin/sh\nexec sleep 100000\n' >"$DIR/bin/pi" +chmod +x "$DIR/bin/pi" + +t -f /dev/null new-session -d -s a -n wa1 -x 120 -y 40 "$DIR/bin/pi" +t new-window -d -t a: -n wa2 "$DIR/bin/pi" +t new-session -d -s b -n wb1 -x 120 -y 40 "$DIR/bin/pi" +t new-session -d -s c -n wc1 -x 120 -y 40 "$DIR/bin/pi" +t set -g mouse on +A1=$(t display -p -t a:wa1 '#{pane_id}') +A2=$(t display -p -t a:wa2 '#{pane_id}') +B1=$(t display -p -t b:wb1 '#{pane_id}') +C1=$(t display -p -t c:wc1 '#{pane_id}') +t new-session -d -s viewer -x 120 -y 40 "env -u TMUX tmux -u -L $SOCK attach -t b" +for _ in $(seq 50); do CLIENT=$(t list-clients -F '#{client_name}' | head -1); [ -n "$CLIENT" ] && break; sleep 0.1; done +[ -z "$CLIENT" ] && { echo "no client attached"; exit 1; } +echo "panes A1=$A1 A2=$A2 B1=$B1, client $CLIENT on $(client_window)" + +# tmux focus hooks, as in packaging/tmux/dingger.tmux, pointed at the test daemon +POKE="run-shell -b \"DINGGER_AGENT_SOCKET=$HOOK $DINGGER poke\"" +[ -z "${E2E_NO_FOCUS_HOOKS:-}" ] && for h in client-session-changed session-window-changed window-pane-changed; do t set-hook -g "$h[77]" "$POKE"; done + +start_daemon + +# --- 1. first observation is idle, not done (herdr: initial idle stays seen) -- +echo "1. first observation" +for p in "$A1" "$A2" "$B1" "$C1"; do hook "$p" idle; done +expect "A1 idle on first sight" "opt $A1 @agent_state" idle +expect "A2 idle on first sight" "opt $A2 @agent_state" idle +expect "B1 idle on first sight" "opt $B1 @agent_state" idle +expect "queue empty" "gopt @dingger_queue" "" + +# --- 2. working, then completion away from the window -> done ---------------- +echo "2. completion while away" +hook "$A1" working +expect "A1 working within 0.5s (event-driven)" "opt $A1 @agent_state" working 5 +expect "working count 1" "gopt @dingger_working" 1 +hook "$A1" idle +expect "A1 done within 0.5s despite a 3s notifier" "opt $A1 @agent_state" done 5 +expect "done count 1" "gopt @dingger_done" 1 +expect "queue = A1" "gopt @dingger_queue" "$A1" +expect "'pi finished' notification for a:wa1" "grep -c 'pi finished|a:wa1 (pi)' $NOTES 2>/dev/null" 1 60 + +# --- 3. completion in the window the user is looking at -> idle -------------- +echo "3. completion while looking" +hook "$B1" working +expect "B1 working" "opt $B1 @agent_state" working +hook "$B1" idle +expect "B1 idle (user is looking at wb1)" "opt $B1 @agent_state" idle +expect "queue still = A1" "gopt @dingger_queue" "$A1" +sleep 3.5 +expect "no notification for a completion the user watched" "grep -c 'b:wb1' $NOTES" 0 + +# --- 4. FIFO and blocked ------------------------------------------------------- +echo "4. fifo and blocked" +hook "$A2" working +hook "$A2" blocked +expect "A2 blocked within 0.5s" "opt $A2 @agent_state" blocked 5 +expect "blocked count 1" "gopt @dingger_blocked" 1 +expect "'pi needs attention' notification for a:wa2" "grep -c 'pi needs attention|a:wa2 (pi)' $NOTES 2>/dev/null" 1 60 +expect "queue = A2,A1 (blocked before done)" "gopt @dingger_queue" "$A2,$A1" +hook "$C1" working; hook "$C1" idle +expect "C1 done" "opt $C1 @agent_state" done +expect "queue = A2,A1,C1 (oldest done first)" "gopt @dingger_queue" "$A2,$A1,$C1" +DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$C1" +expect "client shows wc1" "client_window" wc1 +expect "C1 idle after the jump" "opt $C1 @agent_state" idle + +# --- 5. attending by navigating --------------------------------------------- +echo "5. navigate to a done agent" +t switch-client -c "$CLIENT" -t "$A1" +expect "client shows wa1" "client_window" wa1 +expect "A1 idle within 1s of looking (tmux focus hook)" "opt $A1 @agent_state" idle 10 +expect "queue = A2" "gopt @dingger_queue" "$A2" +expect "done count 0" "gopt @dingger_done" 0 + +# --- 6. status-line click: focus with no args jumps to the queue head -------- +echo "6. dingger focus (status-line click)" +DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus +expect "client jumped to wa2" "client_window" wa2 +expect "A2 still blocked (needs input even when seen)" "opt $A2 @agent_state" blocked +expect "ack consumed" "opt $A2 @dingger_ack" "" +hook "$A2" working +hook "$A2" idle +expect "A2 idle (completed while looked at)" "opt $A2 @agent_state" idle +expect "queue empty" "gopt @dingger_queue" "" + +# --- 7. menu row click: focus --pane ----------------------------------------- +echo "7. dingger focus --pane (menu row click)" +t switch-client -c "$CLIENT" -t "$B1" +expect "client back on wb1" "client_window" wb1 +hook "$A1" working +hook "$A1" idle +expect "A1 done again" "opt $A1 @agent_state" done +DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A1" +expect "client shows wa1" "client_window" wa1 +expect "A1 idle after the jump" "opt $A1 @agent_state" idle + +# --- 8. a whole turn between two polls is still a completion ---------------- +echo "8. sub-second turn" +t switch-client -c "$CLIENT" -t "$B1" +expect "client back on wb1" "client_window" wb1 +hook "$A1" working; hook "$A1" idle # both within one poll interval +expect "A1 done after a sub-second turn" "opt $A1 @agent_state" done +t switch-client -c "$CLIENT" -t "$A1" +expect "A1 idle once looked at" "opt $A1 @agent_state" idle + +# --- 9. restart keeps state ----------------------------------------------------- +echo "9. daemon restart" +t switch-client -c "$CLIENT" -t "$B1" +expect "client back on wb1" "client_window" wb1 +hook "$A2" working +hook "$A2" idle +expect "A2 done before restart" "opt $A2 @agent_state" done +kill "$DAEMON_PID"; wait "$DAEMON_PID" 2>/dev/null +start_daemon +sleep 1.5 +expect "A2 still done after restart" "opt $A2 @agent_state" done +expect "A1 still idle after restart" "opt $A1 @agent_state" idle +expect "B1 still idle after restart" "opt $B1 @agent_state" idle +expect "queue = A2 after restart" "gopt @dingger_queue" "$A2" +hook "$A2" working +expect "hook state still authoritative after restart" "opt $A2 @agent_state" working + +# --- 10. the tmux status-line segment and its click binding ----------------- +echo "10. status line" +t switch-client -c "$CLIENT" -t "$B1" +expect "client back on wb1" "client_window" wb1 +hook "$A2" blocked +hook "$A1" working; hook "$A1" idle +expect "A2 blocked" "opt $A2 @agent_state" blocked +expect "A1 done" "opt $A1 @agent_state" done +t source-file "$ROOT/packaging/tmux/dingger.tmux" +segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g') +case "$segment" in *"×1"*"✓1"*) pass "segment shows ×1 ✓1 ($segment)";; *) fail "segment: [$segment]";; esac +expect "mouse binding installed" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'dingger focus'" 1 +# same run-shell + #{client_name} expansion as the mouse binding, via a key +t bind -n F12 run-shell "DINGGER_TMUX_SOCKET=$SOCK $DINGGER focus --client '#{client_name}'" +t send-keys -t viewer: F12 +expect "key binding jumped to the top waiter (A2, blocked)" "client_window" wa2 +t send-keys -t viewer: F12 +expect "next press jumps to the next waiter (A1)" "client_window" wa1 +expect "A1 idle once jumped to" "opt $A1 @agent_state" idle + +# --- 11. the menu bar binary: rendering and row click ------------------------ +echo "11. menu bar" +if [ -x "$MENUBAR" ]; then + mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; } + t switch-client -c "$CLIENT" -t "$B1" + expect "client back on wb1" "client_window" wb1 + hook "$A1" working; hook "$A1" idle + expect "A1 done" "opt $A1 @agent_state" done + expect "menu title" "mb --print | head -1" "title: ×1 ✓1" + expect "menu rows in herdr priority order" "mb --print | tail -n +2 | cut -d' ' -f2,4 | tr '\n' ' '" "blocked a:wa2 done a:wa1 idle b:wb1 idle c:wc1 " + mb --click "$A1" + expect "row click jumped to wa1" "client_window" wa1 + expect "row click marked A1 idle" "opt $A1 @agent_state" idle + expect "menu title after the click" "mb --print | head -1" "title: ×1" +else + fail "menu bar binary not found at $MENUBAR" +fi + +echo +echo "$PASSES passed, $FAILS failed" +[ "$FAILS" -eq 0 ] || { echo "--- daemon log tail"; tail -30 "$DIR/daemon.log"; exit 1; } |
