aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/dinggerd/src/main.rs9
-rw-r--r--crates/dinggerd/src/names.rs117
-rw-r--r--crates/dinggerd/src/tmux.rs11
-rw-r--r--crates/dinggerd/src/watcher.rs20
-rw-r--r--menubar/main.swift90
-rwxr-xr-xtests/e2e.sh21
6 files changed, 226 insertions, 42 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",
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: "<symbol> <status>\t<title>\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