aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md10
-rw-r--r--crates/dinggerd/assets/pi/pi-agent-state.ts13
-rw-r--r--crates/dinggerd/src/hooks.rs30
-rw-r--r--crates/dinggerd/src/watcher.rs7
-rw-r--r--menubar/panel.swift98
-rwxr-xr-xtests/pi-live.sh15
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<String>,
/// How full its context window is: whole percent ("" when unknown).
context: Option<String>,
+ /// The agent's session name, as reported ("" for none).
+ name: Option<String>,
/// The error that ended its last failed run, as one line.
error: Option<String>,
/// When the last report arrived.
@@ -189,6 +194,8 @@ struct HookParams {
aborted: Option<bool>,
#[serde(default)]
error: Option<String>,
+ #[serde(default)]
+ name: Option<String>,
}
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<String>,
+ name: Option<String>,
}
/// A reported state, and whether it ends a run the user cancelled.
@@ -344,6 +353,9 @@ fn record(pane_id: String, state: Option<AgentState>, 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<String> {
+ 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<String> {
reports().get(pane_id).and_then(|pane| pane.error.clone())
@@ -528,6 +546,18 @@ mod tests {
}
#[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() };
record("%t8".into(), Some(AgentState::Working), failed("not the end of a run"));
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