From 6bccc5f786c479a335c55ad83487e077195cff44 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Tue, 20 Oct 2026 13:59:29 +0300 Subject: menu rows lead with the agent's session name; harness and place move below MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ("π - - "); 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. --- crates/dinggerd/src/main.rs | 9 ++-- crates/dinggerd/src/names.rs | 117 +++++++++++++++++++++++++++++++++++++++++ crates/dinggerd/src/tmux.rs | 11 +++- crates/dinggerd/src/watcher.rs | 20 +++++++ menubar/main.swift | 90 ++++++++++++++++++++----------- tests/e2e.sh | 21 ++++++-- 6 files changed, 226 insertions(+), 42 deletions(-) create mode 100644 crates/dinggerd/src/names.rs 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::().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} {: &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 `π - - `, or +/// `π - ` 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, title: &str, cwd: &str, host: &str) -> Option { + 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, 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, 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, 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> { (!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 = 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", diff --git a/menubar/main.swift b/menubar/main.swift index 602b5cc..5fb0ab6 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -11,6 +11,7 @@ // // Debug flags (used by tests/e2e.sh): // --print print the title and rows the menu would show, then exit +// (rows: " \t\t<subtitle>") // --click <pane> perform a row click for <pane>, then exit import AppKit @@ -70,9 +71,41 @@ enum Status: String { struct Row { let paneId: String let agent: String + /// What the agent works on (its session/task name), if it has one. + let name: String let status: Status - let target: String + let session: String + let window: String let since: Int + + var location: String { "\(session) · \(window)" } + var waiting: Bool { status == .blocked || status == .done } + + /// The row's title text: the agent's name, or its harness when unnamed. + var label: String { + guard !name.isEmpty else { return agent } + return name.count > 80 ? String(name.prefix(79)) + "…" : name + } + + /// Dim second line: where it runs, which harness, and how long it waits. + var subtitle: String { + var parts = [location, agent] + if waiting, since != Int.max { + parts.append("\(status.rawValue) \(elapsed(sinceMs: since))") + } + return parts.joined(separator: " · ") + } +} + +/// Compact age: "now", "4m", "2h", "3d". +func elapsed(sinceMs: Int) -> String { + let seconds = max(0, Int(Date().timeIntervalSince1970) - sinceMs / 1000) + switch seconds { + case ..<60: return "now" + case ..<3600: return "\(seconds / 60)m" + case ..<86400: return "\(seconds / 3600)h" + default: return "\(seconds / 86400)d" + } } // MARK: tmux and dingger access @@ -110,23 +143,23 @@ let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/ /// 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}", + "#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", "#{@agent_name}", + "#{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 } + guard f.count >= 9, !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) + let window = (Int(f[7]) ?? 1) > 1 ? "\(f[6]).\(f[8])" : f[6] + return Row(paneId: f[0], agent: f[1], name: f[4], status: status, + session: f[5], window: window, 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 + return $0.location < $1.location } } @@ -165,21 +198,17 @@ func plainTitle(for rows: [Row]) -> String { title(for: rows).string } -func rowTitle(_ row: Row, tabStop: CGFloat) -> NSAttributedString { +/// Row title: the colored status symbol, then the agent's name. Unnamed +/// agents show their harness, dimmed, so it never competes with real names. +func rowTitle(_ row: Row) -> 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 mono = NSFont.monospacedSystemFont(ofSize: font.pointSize, weight: .medium) 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])) + string: "\(row.status.symbol) ", attributes: [.font: mono, .foregroundColor: row.status.color])) + let labelColor = row.name.isEmpty ? NSColor.secondaryLabelColor : NSColor.labelColor text.append(NSAttributedString( - string: "\t\(row.target)", - attributes: [.font: mono, .foregroundColor: NSColor.secondaryLabelColor, - .paragraphStyle: paragraph])) + string: row.label, attributes: [.font: font, .foregroundColor: labelColor])) return text } @@ -221,19 +250,16 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { none.isEnabled = false menu.addItem(none) } - 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: "") + for (index, row) in rows.enumerated() { + // One separator between the agents waiting for you and the rest. + if index > 0, rows[index - 1].waiting, !row.waiting { + menu.addItem(.separator()) + } + let entry = NSMenuItem(title: row.label, action: #selector(rowClicked(_:)), keyEquivalent: "") entry.target = self - entry.attributedTitle = rowTitle(row, tabStop: tabStop) - entry.toolTip = "\(row.status.rawValue) · \(row.target)" + entry.attributedTitle = rowTitle(row) + entry.subtitle = row.subtitle + entry.toolTip = row.name.isEmpty ? nil : row.name entry.representedObject = row.paneId menu.addItem(entry) } @@ -256,7 +282,7 @@ 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)") + print("\(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)") } exit(0) } diff --git a/tests/e2e.sh b/tests/e2e.sh index b63260c..015d6aa 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -81,15 +81,20 @@ 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 -f /dev/null new-session -d -s a -n wa1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi" +t new-window -d -t a: -n wa2 -c "$DIR" "$DIR/bin/pi" +t new-session -d -s b -n wb1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi" +t new-session -d -s c -n wc1 -c "$DIR" -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}') +# pi titles its pane "π - <session name> - <cwd basename>", or "π - <dir>" +D=$(basename "$DIR") +t select-pane -t "$A1" -T "π - Deploy the widget - $D" +t select-pane -t "$A2" -T "π - $D" +t select-pane -t "$B1" -T "π - Review PR 42 - $D" 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; } @@ -108,6 +113,9 @@ 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" "" +expect "A1 name from pi's title" "opt $A1 @agent_name" "Deploy the widget" +expect "A2 unnamed pi session has no name" "opt $A2 @agent_name" "" +expect "B1 name from pi's title" "opt $B1 @agent_name" "Review PR 42" # --- 2. working, then completion away from the window -> done ---------------- echo "2. completion while away" @@ -230,7 +238,10 @@ if [ -x "$MENUBAR" ]; then 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 " + expect "menu rows: herdr priority, names as titles, harness when unnamed" \ + "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;" + expect "subtitle: location, harness, waiting time" "mb --print | grep 'Deploy the widget' | cut -f3" "a · wa1 · pi · done now" + expect "idle subtitle has no waiting time" "mb --print | grep 'Review PR 42' | cut -f3" "b · wb1 · pi" mb --click "$A1" expect "row click jumped to wa1" "client_window" wa1 expect "row click marked A1 idle" "opt $A1 @agent_state" idle -- cgit v1.2.3