diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-19 16:55:30 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-19 16:55:30 +0300 |
| commit | ea1dda2f056fdfd56bffe8b978c0b0786b82aa7d (patch) | |
| tree | 2a6451f2a956cdd2345d6a77a2db00e0fff1d376 | |
| parent | 9c6b10426bd897afe6cf6f0c022f45310c5ab3d3 (diff) | |
ready-queue fifo with click-to-focus; redesign presentation per design panel
daemon tracks when each agent became ready (idle/blocked, unacked) and
publishes fifo queue + numeric counts as server-global options; unknown
to idle transitions stay out of the queue (restart spam). new dingger
focus subcommand jumps to the longest-waiting pane and acks it, bound to
status-right clicks in tmux. menu bar redesigned per two-model design
panel: monochrome sf-symbol bell + queue depth, queue-ordered rows with
state glyphs and right-aligned targets, row clicks ack like focus does.
tmux segment renders glyph+count with a single red accent for blocked;
fixes unescaped comma in the conditional format that truncated the
segment entirely.
| -rw-r--r-- | crates/dinggerd/src/main.rs | 42 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 46 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 107 | ||||
| -rw-r--r-- | menubar/main.swift | 198 |
4 files changed, 333 insertions, 60 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)] diff --git a/menubar/main.swift b/menubar/main.swift index e186f93..71bba46 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -1,57 +1,55 @@ // dingger menu bar: live agent states from tmux pane user options // (@agent / @agent_state), written by the dingger daemon. +// +// Design (per design panel): one question answered โ "how many agents are +// waiting on me?" The status item is a bell plus the ready-queue depth; +// shape carries state (filled = needs you, outline = seen/calm, dotted = +// working); color is reserved for the tmux segment's blocked accent. import AppKit struct PaneRow { + let paneId: String let target: String let agent: String let state: String } -func emoji(for state: String) -> String { - switch state { - case "blocked": return "๐ด" - case "working": return "๐ก" - case "idle": return "๐ข" - default: return "โช๏ธ" +func symbol(_ name: String) -> NSImage? { + let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) + return image?.withSymbolConfiguration(.init(pointSize: 13, weight: .regular)) +} + +func stateSymbol(_ state: String, queued: Bool) -> NSImage? { + switch (state, queued) { + case ("blocked", true): return symbol("exclamationmark.circle.fill") + case ("idle", true): return symbol("circle.fill") + case ("working", _): return symbol("circle.dotted") ?? symbol("hourglass") + case ("blocked", _): return symbol("exclamationmark.circle") + default: return symbol("circle") } } -final class Menubar: NSObject, NSApplicationDelegate { +final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let menu = NSMenu() private var panes: [PaneRow] = [] + private var queue: [String] = [] // pane ids, oldest waiting first func applicationDidFinishLaunching(_: Notification) { NSApp.setActivationPolicy(.accessory) // no Dock icon - item.button?.title = "โช๏ธ" + item.button?.image = symbol("bell") + item.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular) item.menu = menu + menu.delegate = self let timer = Timer(timeInterval: 2.0, repeats: true) { [weak self] _ in - self?.refresh() + self?.refreshTitle() } RunLoop.main.add(timer, forMode: .common) - refresh() + refreshTitle() } - private func refresh() { - panes = fetchPanes() - redrawTitle() - rebuildMenu() - } - - private func fetchPanes() -> [PaneRow] { - let fmt = "#{pane_id}\t#{session_name}:#{window_index}.#{pane_index}\t#{@agent}\t#{@agent_state}" - guard let out = runTmux(["-u", "list-panes", "-a", "-F", fmt]) else { return [] } - return out.split(separator: "\n").compactMap { line in - let fields = line.split(separator: "\t", omittingEmptySubsequences: false) - guard fields.count >= 4 else { return nil } - let agent = String(fields[2]) - let state = fields[3].isEmpty ? "unknown" : String(fields[3]) - guard !agent.isEmpty || !fields[3].isEmpty else { return nil } // untracked shell - return PaneRow(target: String(fields[1]), agent: agent, state: state) - } - } + // MARK: data private func runTmux(_ args: [String]) -> String? { let process = Process() @@ -71,45 +69,149 @@ final class Menubar: NSObject, NSApplicationDelegate { return String(data: data, encoding: .utf8) } - private func redrawTitle() { - let blocked = panes.filter { $0.state == "blocked" }.count + private func fetchPanes() -> [PaneRow] { + let fmt = "#{pane_id}\t#{session_name}:#{window_index}.#{pane_index}\t#{@agent}\t#{@agent_state}\t#{@dingger_ack}\t#{window_panes}" + guard let out = runTmux(["-u", "list-panes", "-a", "-F", fmt]) else { return [] } + return out.split(separator: "\n").compactMap { line in + let fields = line.split(separator: "\t", omittingEmptySubsequences: false) + guard fields.count >= 6 else { return nil } + let agent = String(fields[2]) + let state = fields[3].isEmpty ? "unknown" : String(fields[3]) + // Only tracked panes with a live state belong in the menu. + guard !agent.isEmpty, ["idle", "working", "blocked"].contains(state) else { + return nil + } + var target = "\(fields[1].split(separator: ".", maxSplits: 1).first ?? fields[1])" + if Int(fields[5]) ?? 1 > 1 { + target = String(fields[1]) + } + return PaneRow(paneId: String(fields[0]), target: target, agent: agent, state: state) + } + } + + private func fetchQueue() -> [String] { + guard let out = runTmux(["-u", "show-option", "-gv", "@dingger_queue"]) else { return [] } + return out.trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: ",").map { $0.split(separator: ":").first.map(String.init) ?? "" } + } + + // MARK: title (timer-driven; never touches the open menu) + + private func refreshTitle() { + panes = fetchPanes() + queue = fetchQueue() + let queued = panes.filter { row in queue.contains(row.paneId) } + let blocked = queued.filter { $0.state == "blocked" }.count let working = panes.filter { $0.state == "working" }.count - let idle = panes.filter { $0.state == "idle" }.count - var parts: [String] = [] - if blocked > 0 { parts.append("๐ด\(blocked)") } - if working > 0 { parts.append("๐ก\(working)") } - if parts.isEmpty { - item.button?.title = idle > 0 ? "๐ข\(idle)" : "โช๏ธ" + + let (name, count): (String, String) + if blocked > 0 { + (name, count) = ("bell.fill", String(queued.count)) + } else if !queued.isEmpty { + (name, count) = ("bell", String(queued.count)) + } else if working > 0 { + (name, count) = ("bell", "") } else { - item.button?.title = parts.joined(separator: " ") + (name, count) = ("bell", "") } + item.button?.image = symbol(name) + item.button?.title = count + + var tip: [String] = [] + if blocked > 0 { tip.append("\(blocked) blocked") } + if queued.count - blocked > 0 { tip.append("\(queued.count - blocked) waiting") } + if working > 0 { tip.append("\(working) working") } + let tipText = tip.isEmpty ? "Nothing waiting" : tip.joined(separator: " ยท ") + item.button?.toolTip = tipText + item.button?.setAccessibilityLabel("dingger, " + tipText) + } + + // MARK: menu (rebuilt when opened, so rows don't reshuffle under the cursor) + + func menuNeedsUpdate(_ menu: NSMenu) { + rebuildMenu() } private func rebuildMenu() { menu.removeAllItems() - menu.addItem(NSMenuItem(title: "dingger", action: nil, keyEquivalent: "")) + + // Queue order (oldest waiting first), then the rest by target. + var ordered: [PaneRow] = [] + for paneId in queue { + if let row = panes.first(where: { $0.paneId == paneId && $0.state != "working" }) { + ordered.append(row) + } + } + let orderedIds = ordered.map { $0.paneId } + let rest = panes + .filter { !orderedIds.contains($0.paneId) } + .sorted { $0.target < $1.target } + ordered.append(contentsOf: rest) if panes.isEmpty { - let none = NSMenuItem(title: "no agents running", action: nil, keyEquivalent: "") + let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "") none.isEnabled = false menu.addItem(none) } - for pane in panes.sorted(by: { $0.target < $1.target }) { - let label = "\(emoji(for: pane.state)) \(pane.agent) \(pane.target) \(pane.state)" - let item = NSMenuItem(title: label, action: #selector(paneClicked(_:)), keyEquivalent: "") - item.target = self - item.representedObject = pane.target - menu.addItem(item) + var previousWaiting: Bool? + for pane in ordered { + let queued = queue.contains(pane.paneId) + let waiting = pane.state == "idle" || pane.state == "blocked" + // One separator between waiting agents and busy ones. + if let previous = previousWaiting, previous && !waiting { + menu.addItem(.separator()) + } + previousWaiting = waiting + + let row = NSMenuItem(title: "\(pane.agent)\t\(pane.target)", + action: #selector(paneClicked(_:)), keyEquivalent: "") + row.target = self + row.image = stateSymbol(pane.state, queued: queued) + row.attributedTitle = attributedRow(agent: pane.agent, target: pane.target) + row.toolTip = "\(pane.state) โ \(pane.target)" + row.representedObject = pane.paneId + menu.addItem(row) } menu.addItem(.separator()) - menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + let quit = NSMenuItem(title: "Quit dingger", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + menu.addItem(quit) } + /// Agent left, tmux target right-aligned at a computed tab stop โ like a + /// native menu's key-equivalent column. + private func attributedRow(agent: String, target: String) -> NSAttributedString { + let menuFont = NSFont.menuFont(ofSize: 0) + let monoFont = NSFont.monospacedSystemFont( + ofSize: menuFont.pointSize - 1, weight: .regular) + let agentWidth = (agent as NSString).size(withAttributes: [.font: menuFont]).width + let targetWidth = (target as NSString).size(withAttributes: [.font: monoFont]).width + let tabStop = ceil(agentWidth + 40 + targetWidth) + + let paragraph = NSMutableParagraphStyle() + paragraph.tabStops = [NSTextTab(textAlignment: .right, location: tabStop)] + + let text = NSMutableAttributedString() + text.append(NSAttributedString( + string: agent, + attributes: [.font: menuFont, .paragraphStyle: paragraph])) + text.append(NSAttributedString(string: "\t")) + text.append(NSAttributedString( + string: target, + attributes: [.font: monoFont, .paragraphStyle: paragraph])) + return text + } + + // MARK: actions + @objc private func paneClicked(_ sender: NSMenuItem) { - guard let target = sender.representedObject as? String else { return } - _ = runTmux(["switch-client", "-t", target]) + guard let paneId = sender.representedObject as? String else { return } + // Ack like `dingger focus` does, so both counts drop together. + _ = runTmux(["-u", "set-option", "-p", "-t", paneId, "@dingger_ack", "1"]) + let target = panes.first { $0.paneId == paneId }?.target ?? "" + guard !target.isEmpty else { return } + _ = runTmux(["-u", "switch-client", "-t", target]) for bundle in ["com.mitchellh.ghostty", "org.alacritty", "com.apple.Terminal"] { if let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundle).first { app.activate() |
