diff options
Diffstat (limited to 'crates/dinggerd/src')
| -rw-r--r-- | crates/dinggerd/src/main.rs | 42 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 46 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 107 |
3 files changed, 183 insertions, 12 deletions
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index f046732..4c8521c 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -33,6 +33,12 @@ 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. + Focus { + /// Name of the tmux client that clicked, passed as #{client_name}. + client: Option<String>, + }, } #[derive(Subcommand)] @@ -73,7 +79,43 @@ fn main() { } } }, + Command::Focus { client } => { + if let Err(err) = focus(client) { + eprintln!("dingger: {err}"); + std::process::exit(1); + } + } + } +} + +/// 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 + }; + 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); } + args.push("-t".to_string()); + args.push(target); + tmux::run_tmux_client( + &args.iter().map(String::as_str).collect::<Vec<_>>(), + ) } fn status() -> Result<(), String> { diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index edd44aa..cba1ca6 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -26,6 +26,9 @@ pub struct Pane { 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, } impl Pane { @@ -70,7 +73,8 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { "#{pane_start_command}\t", "#{pane_pid}\t", "#{@agent_state}\t", - "#{@agent}" + "#{@agent}\t", + "#{@dingger_ack}" ); let out = run_tmux(&["list-panes", "-a", "-F", format])?; let mut panes = Vec::new(); @@ -104,6 +108,7 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { agent, written_state: fields[9].to_string(), written_agent: fields[10].to_string(), + written_ack: fields.get(11).copied().unwrap_or("").to_string(), }); } Ok(panes) @@ -166,6 +171,19 @@ 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 /// the detection manifests expect. pub fn capture_pane(pane_id: &str) -> Result<String, String> { @@ -187,6 +205,28 @@ pub fn set_agent_state(pane_id: &str, state: AgentState) -> Result<(), String> { /// 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(|_| ()) + let label = agent.map(detect_core::agent_label).unwrap_or(""); + run_tmux(&["set-option", "-p", "-t", pane_id, "@agent", label]).map(|_| ()) +} + +/// Write a server-global option (readable from any client and from formats). +pub fn set_global(name: &str, value: &str) -> Result<(), String> { + run_tmux(&["set-option", "-g", name, value]).map(|_| ()) +} + +pub fn show_global(name: &str) -> Option<String> { + run_tmux(&["show-option", "-gv", name]) + .ok() + .map(|value| value.trim().to_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(|_| ()) +} + +pub fn clear_ack(pane_id: &str) -> Result<(), String> { + run_tmux(&["set-option", "-p", "-t", pane_id, "-u", "@dingger_ack"]).map(|_| ()) } diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index f039456..9fd999d 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -9,14 +9,30 @@ use tracing::{debug, info, warn}; use crate::notify::{play_sound, send_notification, NotificationKind}; use crate::tmux; +/// When a pane became ready for the user, as unix seconds. Ready means the +/// agent is waiting on the user: idle at its prompt or blocked on input. +fn is_ready(state: AgentState) -> bool { + matches!(state, AgentState::Idle | AgentState::Blocked) +} + pub struct Watcher { - /// Last published state per pane id. `status` reads the same values back - /// from tmux's `@agent_state`, so no other state is needed. - states: HashMap<String, AgentState>, + /// Published state plus the unix second it became ready, per pane id. + /// Published state plus the unix second it became ready, per pane id. + /// `status` reads the same values back from tmux's `@agent_state`, so no + /// other durable state is needed. + panes: HashMap<String, PaneTrack>, + /// Last (queue, counts) written to the global options; avoids rewriting + /// them every tick. + last_published: Option<(String, (String, String, String))>, interval_secs: u64, sounds: bool, } +struct PaneTrack { + state: AgentState, + ready_since: Option<u64>, +} + /// herdr's notification policy (`notification_toast_for_state_change`). pub fn notification_kind_for_transition( prev_state: AgentState, @@ -39,7 +55,8 @@ pub fn notification_kind_for_transition( impl Watcher { pub fn new(interval_secs: u64, sounds: bool) -> Self { Self { - states: HashMap::new(), + panes: HashMap::new(), + last_published: None, interval_secs: interval_secs.max(1), sounds, } @@ -74,14 +91,14 @@ impl Watcher { let panes = tmux::list_panes()?; // Forget panes that went away. - self.states + self.panes .retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id)); - for pane in panes { + for pane in &panes { let prev_state = self - .states + .panes .get(&pane.pane_id) - .copied() + .map(|track| track.state) .unwrap_or(AgentState::Unknown); // The detection result is an observation of live agent state. @@ -130,12 +147,22 @@ impl Watcher { if new_state == prev_state { continue; } - self.states.insert(pane.pane_id.clone(), new_state); + let ready_since = is_ready(new_state).then(unix_now); + self.panes.insert( + pane.pane_id.clone(), + PaneTrack { + state: new_state, + ready_since, + }, + ); debug!(target = %pane.target, ?prev_state, ?new_state, "state change"); if let Err(err) = tmux::set_agent_state(&pane.pane_id, new_state) { warn!(target = %pane.target, %err, "failed to write @agent_state"); } + // A fresh transition re-enters the ready queue even if the user + // already jumped to this pane in a previous ready episode. + let _ = tmux::clear_ack(&pane.pane_id); if let Some(kind) = notification_kind_for_transition(prev_state, new_state) { @@ -151,8 +178,70 @@ impl Watcher { } } + self.publish_ready_queue(&panes); Ok(()) } + + /// Publish the FIFO ready queue (oldest first) as a server-global option; + /// `dingger focus` pops its head, and the tmux status line renders counts. + fn publish_ready_queue(&mut self, panes: &[tmux::Pane]) { + let mut ready: Vec<(&str, String, u64)> = Vec::new(); // (pane_id, target, ready_since) + let mut blocked = 0usize; + let mut working = 0usize; + for pane in panes { + let Some(track) = self.panes.get(&pane.pane_id) else { + continue; + }; + match track.state { + AgentState::Working => working += 1, + AgentState::Idle | AgentState::Blocked => { + let acked = pane.written_ack == "1"; + if !acked { + if let Some(since) = track.ready_since { + if track.state == AgentState::Blocked { + blocked += 1; + } + ready.push((pane.pane_id.as_str(), pane.target.clone(), since)); + } + } + } + AgentState::Unknown => {} + } + } + ready.sort_by_key(|(_, _, since)| *since); + + let queue = ready + .iter() + .map(|(pane_id, _, since)| format!("{pane_id}:{since}")) + .collect::<Vec<_>>() + .join(","); + let counts = ( + ready.len().to_string(), + blocked.to_string(), + working.to_string(), + ); + let next = (queue.clone(), counts.clone()); + if self.last_published.as_ref() != Some(&next) { + self.last_published = Some(next); + for (name, value) in [ + ("@dingger_queue", queue.as_str()), + ("@dingger_ready", counts.0.as_str()), + ("@dingger_blocked", counts.1.as_str()), + ("@dingger_working", counts.2.as_str()), + ] { + if let Err(err) = tmux::set_global(name, value) { + warn!(%err, "failed to write {name}"); + } + } + } + } +} + +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) } #[cfg(test)] |
