aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/tmux.rs
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-20 12:43:07 +0300
committerIgor Soarez <igor@soarez.org>2026-10-20 12:43:07 +0300
commit4af6a4f625b1965b783c9efb857f8ac665adb8eb (patch)
treedd2dac9627735fd02b3372a4f569125a702ba57a /crates/dinggerd/src/tmux.rs
parent943fc940d9c387916708cb3fdc912323c92ef6dc (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
Diffstat (limited to 'crates/dinggerd/src/tmux.rs')
-rw-r--r--crates/dinggerd/src/tmux.rs137
1 files changed, 79 insertions, 58 deletions
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(&current_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());
+ }
}