aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/dinggerd/src/main.rs42
-rw-r--r--crates/dinggerd/src/tmux.rs46
-rw-r--r--crates/dinggerd/src/watcher.rs107
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)]