diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-20 13:59:29 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-20 13:59:29 +0300 |
| commit | 6bccc5f786c479a335c55ad83487e077195cff44 (patch) | |
| tree | 7777eb50a5e264719a92bd34c653aba9edd133c5 /crates | |
| parent | 3731a80384f5e2b7d3343eb3e4999f626bbc7f91 (diff) | |
menu rows lead with the agent's session name; harness and place move below
herdr's sidebar shows the session name in place of the harness label (via
the user's herdr-session-name extension). dingger reads it from the title
pi already sets ("π - <name> - <dir>"); other agents use herdr's
terminal_title_stripped rule, and claude's default "Claude Code" title or
tmux's host-name title count as no name. the daemon stores it as
@agent_name for every surface.
menu rows: colored status symbol + name as the title (the harness, dimmed,
only when unnamed), and a native subtitle "session · window · harness",
plus how long done/blocked agents have been waiting; one separator between
agents waiting for you and the rest. dingger status shows the same.
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/dinggerd/src/main.rs | 9 | ||||
| -rw-r--r-- | crates/dinggerd/src/names.rs | 117 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 11 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 20 |
4 files changed, 152 insertions, 5 deletions
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index f458e18..285f83a 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -3,6 +3,7 @@ mod hooks; mod integrations; mod manifest_update; +mod names; mod notify; mod tmux; mod watcher; @@ -201,11 +202,13 @@ fn status() -> Result<(), String> { 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 { + let place = |pane: &tmux::Pane| pane.display_target.replacen(':', " · ", 1); + let width = panes.iter().map(|pane| place(pane).chars().count()).max().unwrap_or(5).max(5); + println!("{:<8} {:<width$} {:<7} NAME", "STATUS", "WHERE", "AGENT"); + for pane in &panes { 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); + println!("{state:<8} {:<width$} {agent:<7} {}", place(pane), pane.written_name); } Ok(()) } diff --git a/crates/dinggerd/src/names.rs b/crates/dinggerd/src/names.rs new file mode 100644 index 0000000..ef09988 --- /dev/null +++ b/crates/dinggerd/src/names.rs @@ -0,0 +1,117 @@ +//! Agent display names: what the agent is working on, taken from the +//! terminal title the agent sets (herdr's `terminal_title_stripped`, plus +//! pi's title convention). + +use detect_core::Agent; + +/// herdr's activity glyphs (src/terminal/title.rs): claude prefixes its title +/// with one of these, or a braille spinner frame. +const ACTIVITY_GLYPHS: &str = "·✢✳✶✻✽◐◓◑◒"; + +/// herdr's `stripped_terminal_title`: drop one recognized leading activity +/// glyph and the whitespace after it. +fn stripped_title(title: &str) -> &str { + let title = title.trim(); + let mut chars = title.chars(); + let Some(first) = chars.next() else { + return title; + }; + let rest = chars.as_str(); + let recognized = matches!(first, '\u{2800}'..='\u{28ff}') || ACTIVITY_GLYPHS.contains(first); + if recognized && (rest.is_empty() || rest.starts_with(char::is_whitespace)) { + rest.trim() + } else { + title + } +} + +/// The agent's session/task name, if its title carries one. +/// +/// - pi titles itself `π - <session name> - <cwd basename>`, or +/// `π - <cwd basename>` when the session has no name. +/// - other agents (claude) use the title as the task, behind an activity +/// glyph. +/// - tmux's default title is the host name: no name. +pub fn display_name(agent: Option<Agent>, title: &str, cwd: &str, host: &str) -> Option<String> { + let title = stripped_title(title); + if title.is_empty() || title == host || host.split('.').next() == Some(title) { + return None; + } + let name = match agent { + Some(Agent::Pi) => { + let rest = title.strip_prefix("π - ")?; + let dir = cwd.trim_end_matches('/').rsplit('/').next().unwrap_or(""); + if !dir.is_empty() && rest == dir { + return None; + } + rest.strip_suffix(&format!(" - {dir}")).filter(|_| !dir.is_empty()).unwrap_or(rest) + } + // claude's title before it names a task is just the product name. + Some(Agent::Claude) if title == "Claude Code" => return None, + _ => title, + }; + let name = name.trim(); + (!name.is_empty()).then(|| name.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const HOST: &str = "Igor-Soarez-H3H1W71DJ0"; + + #[test] + fn pi_session_name_between_prefix_and_directory() { + assert_eq!( + display_name(Some(Agent::Pi), "π - Ship langgraph 1.2.13 release - x", "/Users/sz/x", HOST), + Some("Ship langgraph 1.2.13 release".into()) + ); + assert_eq!(display_name(Some(Agent::Pi), "π - 9103 - x", "/Users/sz/x", HOST), Some("9103".into())); + } + + #[test] + fn pi_without_a_session_name_has_no_name() { + assert_eq!(display_name(Some(Agent::Pi), "π - x", "/Users/sz/x", HOST), None); + } + + #[test] + fn pi_name_may_contain_the_separator() { + assert_eq!( + display_name(Some(Agent::Pi), "π - Fix a - b split - dot", "/Users/sz/x/dot", HOST), + Some("Fix a - b split".into()) + ); + } + + #[test] + fn pi_title_after_cd_keeps_the_rest() { + assert_eq!( + display_name(Some(Agent::Pi), "π - Task - olddir", "/Users/sz/newdir", HOST), + Some("Task - olddir".into()) + ); + } + + #[test] + fn claude_title_loses_its_activity_glyph() { + assert_eq!( + display_name(Some(Agent::Claude), "✳ Investigate flaky test", "/tmp", HOST), + Some("Investigate flaky test".into()) + ); + assert_eq!( + display_name(Some(Agent::Claude), "⠋ Investigate flaky test", "/tmp", HOST), + Some("Investigate flaky test".into()) + ); + } + + #[test] + fn claude_default_title_is_not_a_name() { + assert_eq!(display_name(Some(Agent::Claude), "✳ Claude Code", "/tmp", HOST), None); + assert_eq!(display_name(Some(Agent::Claude), "Claude Code", "/tmp", HOST), None); + } + + #[test] + fn default_tmux_title_is_not_a_name() { + assert_eq!(display_name(Some(Agent::Claude), HOST, "/tmp", HOST), None); + assert_eq!(display_name(Some(Agent::Claude), "Igor-Soarez-H3H1W71DJ0", "/tmp", "Igor-Soarez-H3H1W71DJ0.local"), None); + assert_eq!(display_name(Some(Agent::Claude), "", "/tmp", HOST), None); + } +} diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index 326eda9..6189c4b 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -12,6 +12,8 @@ pub struct Pane { pub display_target: String, /// Last OSC title the pane's application set, from `#{pane_title}`. pub pane_title: String, + /// Working directory of the pane's foreground process. + pub cwd: String, pub dead: bool, /// The pane's window is the current window of its session. pub window_active: bool, @@ -24,6 +26,7 @@ pub struct Pane { pub written_ack: String, pub written_since: String, pub written_hook: String, + pub written_name: String, } impl Pane { @@ -73,14 +76,16 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { "#{@dingger_hook}\t", "#{session_name}:#{window_name}\t", "#{window_panes}\t", - "#{pane_index}", + "#{pane_index}\t", + "#{pane_current_path}\t", + "#{@agent_name}", ); 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() < 16 { + if fields.len() < 18 { continue; } let current_command = fields[1]; @@ -106,6 +111,7 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { pane_id: fields[0].to_string(), display_target, pane_title: fields[2].to_string(), + cwd: fields[16].to_string(), dead: fields[3] == "1", window_active: fields[4] == "1", session_attached: fields[5] != "0", @@ -115,6 +121,7 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { written_ack: fields[10].to_string(), written_since: fields[11].to_string(), written_hook: fields[12].to_string(), + written_name: fields[17].to_string(), }); } Ok(panes) diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index 2455407..e87a7bf 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -305,6 +305,15 @@ impl Watcher { if pane.written_agent != agent { let _ = tmux::set_pane_option(&pane.pane_id, "@agent", agent); } + let name = crate::names::display_name(pane.agent, &pane.pane_title, &pane.cwd, host_name()) + .unwrap_or_default(); + if pane.written_name != name { + let _ = if name.is_empty() { + tmux::unset_pane_option(&pane.pane_id, "@agent_name") + } else { + tmux::set_pane_option(&pane.pane_id, "@agent_name", &name) + }; + } // Persist the authoritative hook state so a restart keeps it. let hook = hooks::last_state(&pane.pane_id) .map(state_label) @@ -410,6 +419,17 @@ fn observe(pane: &tmux::Pane) -> Option<Vec<AgentState>> { (!detection.skip_state_update).then(|| vec![detection.state]) } +/// The host name tmux uses as a pane's default title. +fn host_name() -> &'static str { + static HOST: std::sync::OnceLock<String> = std::sync::OnceLock::new(); + HOST.get_or_init(|| { + std::process::Command::new("hostname") + .output() + .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string()) + .unwrap_or_default() + }) +} + fn state_label(state: AgentState) -> &'static str { match state { AgentState::Idle => "idle", |
