From 9575ead9c637a2dc482c00f417a1d784d46e076a Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Thu, 5 Nov 2026 19:45:08 +0200 Subject: name pi agents by their reported session name, not the terminal title; scroll a panel list taller than the screen --- README.md | 10 ++- crates/dinggerd/assets/pi/pi-agent-state.ts | 13 +++- crates/dinggerd/src/hooks.rs | 30 +++++++++ crates/dinggerd/src/watcher.rs | 7 ++- menubar/panel.swift | 98 ++++++++++++++++++++++++----- tests/pi-live.sh | 15 +++++ 6 files changed, 154 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index cfce910..c3dd806 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,10 @@ than a poll is still a completion. until you turn it back on. While on, "Panel Shows" picks *Only When Agents Are Waiting* (default: it hides itself while nothing waits; turning it on then shows it for 3 seconds so you see it is armed; compact) or *Always* - (with a "Show all / Show less" toggle for the full list). The panel is - always dark, in herdr's palette, so it reads the same over any desktop. + (with a "Show all / Show less" toggle for the full list). It grows down + to the bottom of the screen at most; a longer list scrolls, with the + summary line kept under it. The panel is always dark, in herdr's + palette, so it reads the same over any desktop. - **Jump shortcut**: **⌃⌥⌘U** jumps to the top waiting agent from anywhere, like clicking the tmux status line. - **Agent search**: **⌃⌥⇧⌘U** opens a Spotlight-style search over every @@ -169,6 +171,10 @@ than a poll is still a completion. extension that reports working/blocked/idle over `~/.local/state/dingger/agent.sock`. Running pi agents load it when reloaded (see "Keeping pi agents on the current extension" below). + It also reports the session's name (`/name`, or pi's own), which is + the agent's name in dingger whatever the terminal title says: any + program in the pane can set a title. Agents without it (Claude Code, a + pi on an older extension) are named from their terminal title. Blocked covers a dialog that opens during a run (an extension asking before it goes on), until you answer it; one you open while the agent is idle changes nothing. A run you cancel (Escape) ends idle, not done, diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts index ceac45b..0575ba9 100644 --- a/crates/dinggerd/assets/pi/pi-agent-state.ts +++ b/crates/dinggerd/assets/pi/pi-agent-state.ts @@ -119,6 +119,9 @@ let draft = ""; // How full the context window is, in percent, as pi's footer shows it; // "" when unknown (right after compacting, before the next reply). let context = ""; +// The session's name ("" for none): dingger shows it as the agent's name, +// whatever the terminal title says. +let sessionName = ""; const draftDir = path.join(path.dirname(socketPath), "drafts"); // Enough to find the agent by it; the file keeps all of it. const reportedDraftChars = 500; @@ -212,6 +215,7 @@ async function report(state: AgentState | undefined, message?: string, end: RunE ...(controlListening ? { control: controlPath } : {}), draft: draft.slice(0, reportedDraftChars), context, + name: sessionName, seq: Date.now() * 1000, ...(sessionFile ? { agent_session_path: sessionFile } : {}), }, @@ -563,8 +567,9 @@ export default function (pi) { // Each session has its own: /new starts at normal, /resume brings its own back. importance = savedImportance(ctx); announceImportance(); - // The report below carries it. + // The reports below carry them. checkContext(ctx, true); + sessionName = pi.getSessionName?.() ?? ""; publishState(true); }); @@ -588,6 +593,12 @@ export default function (pi) { }); } + // /name, or a name given by an extension. + pi.on("session_info_changed", (event) => { + sessionName = event?.name ?? ""; + void report(undefined); + }); + pi.on("agent_settled", (event, ctx) => { latestCtx = ctx; if (ctx?.isIdle?.() !== true) { diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs index aeb3285..c9825dd 100644 --- a/crates/dinggerd/src/hooks.rs +++ b/crates/dinggerd/src/hooks.rs @@ -32,6 +32,9 @@ //! Or that it failed, with the `error` as one readable line: the agent is //! done, and the alert says it failed and why. //! +//! A pi report carries the session's `name` ("" for none): the agent's +//! name, whatever its terminal title says. +//! //! It also carries the `draft` in the agent's input box ("" for none), for //! marking a draft you left behind; never logged. And `context`: how full //! the agent's context window is, in percent, as pi's footer shows it ("" @@ -83,6 +86,8 @@ struct PaneReports { draft: Option, /// How full its context window is: whole percent ("" when unknown). context: Option, + /// The agent's session name, as reported ("" for none). + name: Option, /// The error that ended its last failed run, as one line. error: Option, /// When the last report arrived. @@ -189,6 +194,8 @@ struct HookParams { aborted: Option, #[serde(default)] error: Option, + #[serde(default)] + name: Option, } fn handle_stream(stream: UnixStream) { @@ -259,6 +266,7 @@ fn handle_stream(stream: UnixStream) { context: params.context, aborted: params.aborted == Some(true), error: params.error, + name: params.name, }; record(params.pane_id, state, reported); wake(); @@ -278,6 +286,7 @@ struct Reported { aborted: bool, /// The error the run this report ends failed with. error: Option, + name: Option, } /// A reported state, and whether it ends a run the user cancelled. @@ -344,6 +353,9 @@ fn record(pane_id: String, state: Option, reported: Reported) { if let Some(draft) = reported.draft { pane.draft = Some(draft_line(&draft)); } + if let Some(name) = reported.name { + pane.name = Some(draft_line(&name)); + } if let Some(context) = reported.context { pane.context = Some(context_percent(&context)); } @@ -358,6 +370,12 @@ fn context_percent(value: &str) -> String { } } +/// The pane's agent's session name ("" for none), if its extension +/// reports it. +pub fn name(pane_id: &str) -> Option { + reports().get(pane_id).and_then(|pane| pane.name.clone()) +} + /// The error the pane's agent's last failed run ended with. pub fn error(pane_id: &str) -> Option { reports().get(pane_id).and_then(|pane| pane.error.clone()) @@ -527,6 +545,18 @@ mod tests { assert_eq!(take_observations("%t7"), Some(vec![Observation::plain(AgentState::Idle)])); } + #[test] + fn a_reported_name_is_kept_as_one_line_until_reported_again() { + let named = |name: &str| Reported { name: Some(name.into()), ..Default::default() }; + assert_eq!(super::name("%t9"), None, "an extension that does not report it"); + record("%t9".into(), None, named("Fix the\nsidecar")); + assert_eq!(super::name("%t9").as_deref(), Some("Fix the sidecar")); + record("%t9".into(), None, Reported::default()); + assert_eq!(super::name("%t9").as_deref(), Some("Fix the sidecar"), "kept by a report without one"); + record("%t9".into(), None, named("")); + assert_eq!(super::name("%t9").as_deref(), Some(""), "unnamed"); + } + #[test] fn a_failed_run_ends_in_a_failed_idle_observation_with_its_error() { let failed = |error: &str| Reported { error: Some(error.into()), ..Default::default() }; diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index 9bad1fe..1cd282b 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -503,8 +503,11 @@ impl Watcher { change("@dingger_since", &pane.written_since, track.since.map(|s| s.to_string()).unwrap_or_default()); change("@dingger_error", &pane.written_error, error.to_string()); change("@agent", &pane.written_agent, pane.agent.map(detect_core::agent_label).unwrap_or("").to_string()); - let name = crate::names::display_name(pane.agent, &pane.pane_title, &pane.cwd, host_name()) - .unwrap_or_default(); + // The name its extension reports ("" for an unnamed session), else + // what the terminal title says (anyone can set a title). + let name = hooks::name(&pane.pane_id).unwrap_or_else(|| { + crate::names::display_name(pane.agent, &pane.pane_title, &pane.cwd, host_name()).unwrap_or_default() + }); change("@agent_name", &pane.written_name, name); // The session reference, for restore; it goes with the agent. match (pane.agent, hooks::session(&pane.pane_id)) { diff --git a/menubar/panel.swift b/menubar/panel.swift index 174d30e..35494e0 100644 --- a/menubar/panel.swift +++ b/menubar/panel.swift @@ -472,9 +472,23 @@ private final class ToggleView: ClickView { override func hoverChanged() { label.textColor = hovered ? mochaText : mochaSubtext } } +private extension Optional where Wrapped == CGFloat { + var orZero: CGFloat { self ?? 0 } +} + +/// A document view whose content starts at the top. +private final class FlippedView: NSView { + override var isFlipped: Bool { true } +} + final class PanelController { private let panel: NSPanel + /// The list (offer, rows, overflow), which scrolls once the panel would + /// reach below the screen; the summary line stays under it. private let stack = NSStackView() + private let scroll = NSScrollView() + private let summary = NSStackView() + private var scrollHeight: NSLayoutConstraint! private let onClick: (String) -> Void private let onMarkUnread: (String) -> Void private let onToggleFull: () -> Void @@ -506,15 +520,39 @@ final class PanelController { let effect = darkHUD() panel.contentView = effect - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 0 - stack.translatesAutoresizingMaskIntoConstraints = false - effect.addSubview(stack) + for column in [stack, summary] { + column.orientation = .vertical + column.alignment = .leading + column.spacing = 0 + column.translatesAutoresizingMaskIntoConstraints = false + } + let document = FlippedView() + document.translatesAutoresizingMaskIntoConstraints = false + document.addSubview(stack) + scroll.documentView = document + scroll.drawsBackground = false + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.scrollerStyle = .overlay + scroll.translatesAutoresizingMaskIntoConstraints = false + effect.addSubview(scroll) + effect.addSubview(summary) + scrollHeight = scroll.heightAnchor.constraint(equalToConstant: 0) NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: inset), - stack.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -inset), - stack.topAnchor.constraint(equalTo: effect.topAnchor, constant: inset), + scroll.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: inset), + scroll.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -inset), + scroll.topAnchor.constraint(equalTo: effect.topAnchor, constant: inset), + scrollHeight, + document.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor), + document.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor), + document.topAnchor.constraint(equalTo: scroll.contentView.topAnchor), + stack.leadingAnchor.constraint(equalTo: document.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: document.trailingAnchor), + stack.topAnchor.constraint(equalTo: document.topAnchor), + stack.bottomAnchor.constraint(equalTo: document.bottomAnchor), + summary.leadingAnchor.constraint(equalTo: scroll.leadingAnchor), + summary.trailingAnchor.constraint(equalTo: scroll.trailingAnchor), + summary.topAnchor.constraint(equalTo: scroll.bottomAnchor), ]) // Remember where the user put it; first time, top right of the screen. @@ -535,9 +573,11 @@ final class PanelController { guard text != rendered else { return false } rendered = text - stack.arrangedSubviews.forEach { view in - stack.removeArrangedSubview(view) - view.removeFromSuperview() + for column in [stack, summary] { + column.arrangedSubviews.forEach { view in + column.removeArrangedSubview(view) + view.removeFromSuperview() + } } if let offer = content.offer, let title = content.offerTitle { add(offerLine(title: title, detail: offer.detail, restoring: content.restoring), fullWidth: true) @@ -552,17 +592,34 @@ final class PanelController { string: "+\(content.overflow) more waiting, see the menu bar", attributes: [.font: NSFont.systemFont(ofSize: 11), .foregroundColor: mochaSubtext]))) } - if content.showsSummary { add(summaryLine(content), fullWidth: true) } + if content.showsSummary { + let line = summaryLine(content) + summary.addArrangedSubview(line) + line.widthAnchor.constraint(equalTo: summary.widthAnchor).isActive = true + } - // Grow or shrink downward from the top edge. + // Grow or shrink downward from the top edge, down to the bottom of + // the screen; past that the list scrolls. stack.layoutSubtreeIfNeeded() - let height = stack.fittingSize.height + inset * 2 + summary.layoutSubtreeIfNeeded() + let list = stack.arrangedSubviews.isEmpty ? 0 : stack.fittingSize.height + let below = summary.arrangedSubviews.isEmpty ? 0 : summary.fittingSize.height let top = panel.frame.maxY + let room = top - (panel.screen ?? NSScreen.main).map { $0.visibleFrame.minY + 16 }.orZero + // At least a few rows, wherever the panel was dragged. + let shown = max(min(list, 150), min(list, room - below - inset * 2)) + scrollHeight.constant = shown + let height = shown + below + inset * 2 panel.setFrame(NSRect(x: panel.frame.minX, y: top - height, width: panelWidth, height: height), display: true) + listScrolls = shown < list return true } + /// Whether the list is taller than the panel shows (tests). + private(set) var listScrolls = false + var frame: NSRect { panel.frame } + private func add(_ view: NSView, fullWidth: Bool = false) { stack.addArrangedSubview(view) if fullWidth { view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } @@ -785,6 +842,19 @@ func panelSelftest() -> [String] { check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"), "toggle offered only when allowed, labelled by layout") + // A long list scrolls instead of running off the screen. + _ = NSApplication.shared + if let area = NSScreen.main?.visibleFrame { + let panel = PanelController(persistFrame: false, onClick: { _ in }, onToggleFull: {}) + panel.render(PanelContent(rows: (0..<80).map { row("long\($0)", .idle) }, full: true, canToggle: true)) + check(panel.frame.minY >= area.minY && panel.frame.maxY <= area.maxY && panel.listScrolls, + "panel: 80 rows stay on screen and scroll (\(panel.frame) in \(area))") + panel.render(PanelContent(rows: (0..<3).map { row("short\($0)", .idle) }, full: true, canToggle: true)) + check(!panel.listScrolls && panel.frame.height < 300, "panel: 3 rows shown whole (\(panel.frame))") + } else { + check(false, "panel: no screen to measure against") + } + // A failed run. var broken = row("b", .done) broken.error = "401: invalid x-api-key" diff --git a/tests/pi-live.sh b/tests/pi-live.sh index 04ef04a..3c8604d 100755 --- a/tests/pi-live.sh +++ b/tests/pi-live.sh @@ -302,6 +302,21 @@ expect "a run that works: done" "st fail" done 80 expect "with no failure" "t show -pv -t main:fail @dingger_error 2>/dev/null" "" t kill-window -t main:fail +# Its name is the session's, whatever the terminal title says. +t new-window -d -t main: -n named -c "$D/work3" +t send-keys -t main:named "pi $NO_MCP" Enter +expect "names: an agent of its own" "st named" idle 150 +name() { t show -pv -t main:named @agent_name 2>/dev/null; } +expect "an unnamed session: no name" name "" 30 +t select-pane -t main:named -T "π - Not its name - work3"; "$DINGGER" poke; sleep 1 +expect "a title cannot name it" name "" +type_line named "/name Find the sidecar" +expect "/name names it" name "Find the sidecar" 50 +t select-pane -t main:named -T "pi:c"; "$DINGGER" poke; sleep 1 +expect "a title written over keeps the name" name "Find the sidecar" +expect "the menu shows it" "\"$MENU\" --print | grep -c 'Find the sidecar'" 1 +t kill-window -t main:named + echo "2. screen detection (pi without the extension)" t send-keys -t main:plain "PI_CODING_AGENT_DIR=$D/agent-plain pi $NO_MCP" Enter expect "identified as pi" "t show -pv -t main:plain @agent 2>/dev/null" pi 150 -- cgit v1.2.3