aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-20 14:24:11 +0300
committerIgor Soarez <igor@soarez.org>2026-10-20 14:24:11 +0300
commit0f8438547e50e10b3c09f17356b1d88b93718b79 (patch)
tree325e8b45af647af653c10318197b0ad361d277a7
parent6bccc5f786c479a335c55ad83487e077195cff44 (diff)
floating panel: always-on-top agent HUD with a global shortcut
a non-activating HUD panel (every Space, over full-screen apps, never takes focus) listing the agents waiting for you, with working and idle summarized on one line; rows jump to the agent, drag moves the panel and it remembers its place. modes: when agents are waiting (default), always, off; ctrl+opt+ cmd+e shows or hides it on top of the mode, and a manual hide lasts until another agent starts waiting. the shortcut is a carbon hotkey (no accessibility permission; clashes are reported in the menu) configured in ~/.config/dingger/config.toml, reloaded on save. the menu bar app is split into model, panel, hotkey and main. tested: --selftest checks the visibility rules (it fails if a newly waiting agent no longer re-shows a dismissed panel); e2e step 13 checks the panel content through the real binary.
-rw-r--r--README.md20
-rw-r--r--menubar/hotkey.swift146
-rw-r--r--menubar/main.swift351
-rw-r--r--menubar/model.swift200
-rw-r--r--menubar/panel.swift314
-rwxr-xr-xtests/e2e.sh14
6 files changed, 830 insertions, 215 deletions
diff --git a/README.md b/README.md
index 7fda838..c2a2a71 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,16 @@ than a poll is still a completion.
- **Menu bar** (`menubar/main.swift`): `×1 ✓3 ◐2` in herdr's colors; the
menu lists every agent in priority order, oldest waiting first. Clicking a
row jumps to that pane.
+- **Floating panel** (`menubar/panel.swift`): an always-on-top HUD that never
+ takes focus, on every Space and over full-screen apps. It lists the agents
+ waiting for you (blocked, done) and summarizes the rest
+ (`◐ 2 working · ○ 15 idle`); click a row to jump there, drag to move it.
+ **⌃⌥⌘E** shows or hides it from anywhere. Modes (menu bar, "Show Panel
+ Automatically"): *When agents are waiting* (default; appears when
+ something is blocked or done, hides when you have attended to everything),
+ *Always*, *Off*. Hiding it with the shortcut lasts until another agent
+ starts waiting. Change the shortcut in `~/.config/dingger/config.toml`
+ (menu bar, Settings…); it reloads when you save.
- **tmux status line** (`packaging/tmux/dingger.tmux`): the same summary after
the clock. Clicking it jumps to the top waiting agent (the menu's top row),
skipping the window you already look at.
@@ -71,8 +81,8 @@ dingger manifests update # refresh manifests from herdr's catalog
cargo build --release
cp target/release/dingger ~/.local/bin/dingger # do not strip: invalidates
codesign --force --sign - ~/.local/bin/dingger # the signature, macOS kills it
-swiftc -O -o target/dingger-menu menubar/main.swift # menu bar, bundled as
- # ~/Applications/dingger.app
+swiftc -O -o target/dingger-menu menubar/*.swift # menu bar + panel, bundled
+ # as ~/Applications/dingger.app
```
- launchd agents (start at login, restart on exit):
@@ -94,7 +104,8 @@ tests/e2e.sh # end to end against an isolated tmux server
30 s, so everything asserted must arrive through events), fake agents driven
over the hook protocol, and a real tmux client whose focus it controls. It
asserts every status transition, attendance by navigation, both click paths
-(including the real menu bar binary), the status-line segment, latency with a
+(including the real menu bar binary), the floating panel's visibility rules
+and content, the status-line segment, latency with a
deliberately slow notifier, and state surviving a daemon restart. It never
touches your tmux server.
@@ -102,6 +113,7 @@ touches your tmux server.
- `crates/detect-core`: the detection engine and 22 manifests, from herdr.
- `crates/dinggerd`: the `dingger` binary (watcher, hooks, focus, CLI).
-- `menubar/`: the menu bar app.
+- `menubar/`: the menu bar app (`model.swift` rows and colors, `panel.swift`
+ the floating panel, `hotkey.swift` the global shortcut and settings).
- `packaging/`: launchd agents and the tmux snippet.
- `tests/e2e.sh`: the end-to-end suite.
diff --git a/menubar/hotkey.swift b/menubar/hotkey.swift
new file mode 100644
index 0000000..e6a5aa7
--- /dev/null
+++ b/menubar/hotkey.swift
@@ -0,0 +1,146 @@
+// The global shortcut and the settings file it comes from.
+//
+// The shortcut uses Carbon's RegisterEventHotKey: no Accessibility or Input
+// Monitoring permission, the key is consumed, and a clash with another app
+// is reported instead of failing silently.
+
+import AppKit
+import Carbon.HIToolbox
+
+// MARK: settings file
+
+struct Settings: Equatable {
+ var hotkey = "ctrl+opt+cmd+e"
+
+ static var directory: String {
+ environment["DINGGER_CONFIG_DIR"] ?? (NSHomeDirectory() + "/.config/dingger")
+ }
+ static var path: String { directory + "/config.toml" }
+
+ static let template = """
+ # dingger settings. The menu bar app reloads this file when you save it.
+
+ [panel]
+ # Global shortcut that shows or hides the floating panel.
+ # Modifiers: ctrl, opt, cmd, shift. Keys: a-z, 0-9, f1-f12, space.
+ hotkey = "ctrl+opt+cmd+e"
+
+ """
+
+ /// Create the file with defaults and comments if it does not exist yet.
+ static func ensureFile() {
+ guard !FileManager.default.fileExists(atPath: path) else { return }
+ try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
+ try? template.write(toFile: path, atomically: true, encoding: .utf8)
+ }
+
+ /// Read `key = "value"` lines; unknown keys are ignored.
+ static func load() -> Settings {
+ var settings = Settings()
+ guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return settings }
+ for line in text.split(separator: "\n") {
+ let parts = line.split(separator: "=", maxSplits: 1).map {
+ $0.trimmingCharacters(in: .whitespaces)
+ }
+ guard parts.count == 2, !parts[0].hasPrefix("#") else { continue }
+ let value = parts[1].trimmingCharacters(in: CharacterSet(charactersIn: "\""))
+ if parts[0] == "hotkey" { settings.hotkey = value }
+ }
+ return settings
+ }
+
+ static func modified() -> Date? {
+ (try? FileManager.default.attributesOfItem(atPath: path))?[.modificationDate] as? Date
+ }
+}
+
+// MARK: shortcut parsing
+
+struct Hotkey: Equatable {
+ let keyCode: UInt32
+ let carbonModifiers: UInt32
+ let modifierFlags: NSEvent.ModifierFlags
+ let key: String
+
+ /// How macOS writes the shortcut, e.g. "⌃⌥⌘E".
+ var display: String {
+ var text = ""
+ if modifierFlags.contains(.control) { text += "⌃" }
+ if modifierFlags.contains(.option) { text += "⌥" }
+ if modifierFlags.contains(.shift) { text += "⇧" }
+ if modifierFlags.contains(.command) { text += "⌘" }
+ return text + key.uppercased()
+ }
+
+ private static let keyCodes: [String: Int] = {
+ var codes: [String: Int] = [
+ "a": kVK_ANSI_A, "b": kVK_ANSI_B, "c": kVK_ANSI_C, "d": kVK_ANSI_D, "e": kVK_ANSI_E,
+ "f": kVK_ANSI_F, "g": kVK_ANSI_G, "h": kVK_ANSI_H, "i": kVK_ANSI_I, "j": kVK_ANSI_J,
+ "k": kVK_ANSI_K, "l": kVK_ANSI_L, "m": kVK_ANSI_M, "n": kVK_ANSI_N, "o": kVK_ANSI_O,
+ "p": kVK_ANSI_P, "q": kVK_ANSI_Q, "r": kVK_ANSI_R, "s": kVK_ANSI_S, "t": kVK_ANSI_T,
+ "u": kVK_ANSI_U, "v": kVK_ANSI_V, "w": kVK_ANSI_W, "x": kVK_ANSI_X, "y": kVK_ANSI_Y,
+ "z": kVK_ANSI_Z,
+ "0": kVK_ANSI_0, "1": kVK_ANSI_1, "2": kVK_ANSI_2, "3": kVK_ANSI_3, "4": kVK_ANSI_4,
+ "5": kVK_ANSI_5, "6": kVK_ANSI_6, "7": kVK_ANSI_7, "8": kVK_ANSI_8, "9": kVK_ANSI_9,
+ "space": kVK_Space,
+ ]
+ let functionKeys = [kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6,
+ kVK_F7, kVK_F8, kVK_F9, kVK_F10, kVK_F11, kVK_F12]
+ for (index, code) in functionKeys.enumerated() { codes["f\(index + 1)"] = code }
+ return codes
+ }()
+
+ /// Parse "ctrl+opt+cmd+e". Requires at least one modifier and one key.
+ static func parse(_ spec: String) -> Hotkey? {
+ var carbon: UInt32 = 0
+ var flags: NSEvent.ModifierFlags = []
+ var key: String?
+ for part in spec.lowercased().split(separator: "+").map({ $0.trimmingCharacters(in: .whitespaces) }) {
+ switch part {
+ case "ctrl", "control": carbon |= UInt32(controlKey); flags.insert(.control)
+ case "opt", "option", "alt": carbon |= UInt32(optionKey); flags.insert(.option)
+ case "cmd", "command": carbon |= UInt32(cmdKey); flags.insert(.command)
+ case "shift": carbon |= UInt32(shiftKey); flags.insert(.shift)
+ default:
+ guard key == nil, keyCodes[part] != nil else { return nil }
+ key = part
+ }
+ }
+ guard let key, let code = keyCodes[key], carbon != 0 else { return nil }
+ return Hotkey(keyCode: UInt32(code), carbonModifiers: carbon, modifierFlags: flags, key: key)
+ }
+}
+
+// MARK: registration
+
+/// One process-wide global shortcut.
+enum GlobalHotkey {
+ private static var reference: EventHotKeyRef?
+ private static var action: (() -> Void)?
+ private static var handlerInstalled = false
+
+ /// Register `hotkey`, replacing any previous one. Returns false if another
+ /// app already owns the combination.
+ static func register(_ hotkey: Hotkey, action newAction: @escaping () -> Void) -> Bool {
+ unregister()
+ action = newAction
+ if !handlerInstalled {
+ var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard),
+ eventKind: UInt32(kEventHotKeyPressed))
+ InstallEventHandler(GetApplicationEventTarget(), { _, _, _ in
+ DispatchQueue.main.async { GlobalHotkey.action?() }
+ return noErr
+ }, 1, &spec, nil, nil)
+ handlerInstalled = true
+ }
+ let id = EventHotKeyID(signature: OSType(0x4447_4752), id: 1) // "DGGR"
+ let status = RegisterEventHotKey(hotkey.keyCode, hotkey.carbonModifiers, id,
+ GetApplicationEventTarget(), 0, &reference)
+ return status == noErr
+ }
+
+ static func unregister() {
+ if let reference { UnregisterEventHotKey(reference) }
+ reference = nil
+ }
+}
diff --git a/menubar/main.swift b/menubar/main.swift
index 5fb0ab6..e7532f6 100644
--- a/menubar/main.swift
+++ b/menubar/main.swift
@@ -1,250 +1,130 @@
-// dingger menu bar: herdr's agent panel as a macOS status item.
+// dingger menu bar: herdr's agent panel as a macOS status item, plus a
+// floating always-on-top panel (panel.swift) toggled by a global shortcut
+// (hotkey.swift). Rows and colors come from model.swift.
//
-// Reads the pane options the dingger daemon writes (@agent, @agent_state,
-// @dingger_since) and renders herdr's Symbols style with herdr's palette:
+// Reads the pane options the dingger daemon writes and renders herdr's
+// Symbols style with herdr's palette:
//
// blocked × red done ✓ teal working ◐ yellow idle ○ green
//
-// in herdr's priority order (blocked > done > working > idle), oldest
-// waiting first. Clicking a row runs `dingger focus --pane`, the same tested
-// code path as the tmux status-line click.
+// in herdr's priority order, oldest waiting first. Clicking a row runs
+// `dingger focus --pane`, the same tested code path as the tmux status-line
+// click.
//
// 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>")
+// --print-panel print what the floating panel would show, then exit
// --click <pane> perform a row click for <pane>, then exit
+// --selftest check the panel visibility rules, then exit
import AppKit
-// MARK: herdr's palette (catppuccin mocha on dark, latte on light)
-
-func herdrColor(dark: (CGFloat, CGFloat, CGFloat), light: (CGFloat, CGFloat, CGFloat)) -> NSColor {
- NSColor(name: nil) { appearance in
- let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
- let (r, g, b) = isDark ? dark : light
- return NSColor(srgbRed: r / 255, green: g / 255, blue: b / 255, alpha: 1)
- }
+func log(_ message: String) {
+ FileHandle.standardError.write(Data("\(Date()) \(message)\n".utf8))
}
-let herdrRed = herdrColor(dark: (243, 139, 168), light: (210, 15, 57))
-let herdrTeal = herdrColor(dark: (148, 226, 213), light: (23, 146, 153))
-let herdrYellow = herdrColor(dark: (249, 226, 175), light: (223, 142, 29))
-let herdrGreen = herdrColor(dark: (166, 227, 161), light: (64, 160, 43))
-let herdrGray = herdrColor(dark: (108, 112, 134), light: (156, 160, 176))
-
-// MARK: herdr's status vocabulary
-
-enum Status: String {
- case blocked, done, working, idle, unknown
+final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
+ private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ private let menu = NSMenu()
+ private var panel: PanelController!
+ private var visibility = PanelVisibility(
+ mode: PanelMode(rawValue: UserDefaults.standard.string(forKey: "panelMode") ?? "") ?? .auto)
+ private var settings = Settings()
+ private var settingsModified: Date?
+ private var hotkey: Hotkey?
+ private var hotkeyRegistered = false
+ private var rows: [Row] = []
- var symbol: String {
- switch self {
- case .blocked: return "×"
- case .done: return "✓"
- case .working: return "◐"
- case .idle: return "○"
- case .unknown: return "·"
- }
+ func applicationDidFinishLaunching(_: Notification) {
+ menu.delegate = self
+ item.menu = menu
+ panel = PanelController { [weak self] paneId in self?.jump(to: paneId) }
+ Settings.ensureFile()
+ reloadSettings()
+ refresh()
+ let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in self?.refresh() }
+ RunLoop.main.add(timer, forMode: .common)
}
- var color: NSColor {
- switch self {
- case .blocked: return herdrRed
- case .done: return herdrTeal
- case .working: return herdrYellow
- case .idle: return herdrGreen
- case .unknown: return herdrGray
- }
- }
+ // MARK: settings and shortcut
- var priority: Int {
- switch self {
- case .blocked: return 4
- case .done: return 3
- case .working: return 2
- case .idle: return 1
- case .unknown: return 0
+ private func reloadSettings() {
+ settingsModified = Settings.modified()
+ settings = Settings.load()
+ guard let parsed = Hotkey.parse(settings.hotkey) else {
+ GlobalHotkey.unregister()
+ hotkey = nil
+ hotkeyRegistered = false
+ log("settings: invalid hotkey \"\(settings.hotkey)\"")
+ return
}
+ hotkey = parsed
+ hotkeyRegistered = GlobalHotkey.register(parsed) { [weak self] in self?.togglePanel() }
+ log("settings: hotkey \(parsed.display) \(hotkeyRegistered ? "registered" : "in use by another app")")
}
-}
-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 session: String
- let window: String
- let since: Int
+ // MARK: refresh
- var location: String { "\(session) · \(window)" }
- var waiting: Bool { status == .blocked || status == .done }
+ private func refresh() {
+ if Settings.modified() != settingsModified { reloadSettings() }
+ rows = fetchRows()
+ item.button?.attributedTitle = title(for: rows)
+ var tip: [String] = []
+ for status in [Status.blocked, .done, .working, .idle] {
+ let count = rows.filter { $0.status == status }.count
+ if count > 0 { tip.append("\(count) \(status.rawValue)") }
+ }
+ let tipText = tip.isEmpty ? "No agents" : tip.joined(separator: " · ")
+ item.button?.toolTip = tipText
+ item.button?.setAccessibilityLabel("dingger, " + tipText)
- /// 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
+ visibility.update(waiting: Set(rows.filter(\.waiting).map(\.paneId)))
+ applyPanel()
}
- /// 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))")
+ private func applyPanel() {
+ if visibility.visible {
+ panel.render(PanelContent(rows: rows))
+ if !panel.isVisible {
+ panel.show()
+ log("panel: shown (\(visibility.mode.rawValue), forced: \(String(describing: visibility.forced)))")
+ }
+ } else if panel.isVisible {
+ panel.hide()
+ log("panel: hidden (\(visibility.mode.rawValue), forced: \(String(describing: visibility.forced)))")
}
- 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
-
-let environment = ProcessInfo.processInfo.environment
-func run(_ path: String, _ args: [String]) -> String? {
- let process = Process()
- process.executableURL = URL(fileURLWithPath: path)
- process.arguments = args
- let pipe = Pipe()
- process.standardOutput = pipe
- process.standardError = FileHandle.nullDevice
- do {
- try process.run()
- } catch {
- return nil
+ @objc private func togglePanel() {
+ visibility.toggle()
+ applyPanel()
}
- let data = pipe.fileHandleForReading.readDataToEndOfFile()
- process.waitUntilExit()
- guard process.terminationStatus == 0 else { return nil }
- return String(data: data, encoding: .utf8)
-}
-func tmux(_ args: [String]) -> String? {
- var all = ["-u"]
- if let socket = environment["DINGGER_TMUX_SOCKET"] {
- all += ["-L", socket]
+ @objc private func selectMode(_ sender: NSMenuItem) {
+ guard let raw = sender.representedObject as? String, let mode = PanelMode(rawValue: raw) else { return }
+ visibility.setMode(mode)
+ UserDefaults.standard.set(mode.rawValue, forKey: "panelMode")
+ applyPanel()
}
- return run(environment["DINGGER_TMUX"] ?? "/opt/homebrew/bin/tmux", all + args)
-}
-
-let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/bin/dingger")
-/// Every agent pane, in herdr's priority order, oldest waiting first.
-func fetchRows() -> [Row] {
- let format = [
- "#{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 >= 9, !f[1].isEmpty else { return nil }
- let status = Status(rawValue: f[2]) ?? .unknown
- guard status != .unknown else { return nil }
- 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)
+ @objc private func openSettings() {
+ Settings.ensureFile()
+ _ = run("/usr/bin/open", ["-t", Settings.path])
}
- 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.location < $1.location
- }
-}
-/// Show the pane and mark it attended; the same code path as the tmux
-/// status-line click.
-func focus(_ paneId: String) {
- _ = run(dinggerBinary, ["focus", "--pane", paneId])
-}
-
-// MARK: rendering
-
-/// herdr-style summary: `×1 ✓3 ◐2` in priority order; `○` when every agent
-/// is idle; `·` when there are none.
-func title(for rows: [Row]) -> NSAttributedString {
- let font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium)
- let text = NSMutableAttributedString()
- for status in [Status.blocked, .done, .working] {
- let count = rows.filter { $0.status == status }.count
- guard count > 0 else { continue }
- if text.length > 0 {
- text.append(NSAttributedString(string: " ", attributes: [.font: font]))
- }
- text.append(NSAttributedString(
- string: "\(status.symbol)\(count)",
- attributes: [.font: font, .foregroundColor: status.color]))
- }
- if text.length == 0 {
- let status: Status = rows.isEmpty ? .unknown : .idle
- text.append(NSAttributedString(
- string: status.symbol, attributes: [.font: font, .foregroundColor: status.color]))
- }
- return text
-}
-
-func plainTitle(for rows: [Row]) -> String {
- title(for: rows).string
-}
-
-/// 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, weight: .medium)
- let text = NSMutableAttributedString()
- text.append(NSAttributedString(
- string: "\(row.status.symbol) ", attributes: [.font: mono, .foregroundColor: row.status.color]))
- let labelColor = row.name.isEmpty ? NSColor.secondaryLabelColor : NSColor.labelColor
- text.append(NSAttributedString(
- string: row.label, attributes: [.font: font, .foregroundColor: labelColor]))
- return text
-}
-
-// MARK: app
-
-final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
- private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
- private let menu = NSMenu()
-
- func applicationDidFinishLaunching(_: Notification) {
- menu.delegate = self
- item.menu = menu
- refreshTitle()
- let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
- self?.refreshTitle()
+ private func jump(to paneId: String) {
+ DispatchQueue.global().async {
+ focus(paneId)
+ DispatchQueue.main.async { self.refresh() }
}
- RunLoop.main.add(timer, forMode: .common)
}
- private func refreshTitle() {
- let rows = fetchRows()
- item.button?.attributedTitle = title(for: rows)
- var tip: [String] = []
- for status in [Status.blocked, .done, .working, .idle] {
- let count = rows.filter { $0.status == status }.count
- if count > 0 { tip.append("\(count) \(status.rawValue)") }
- }
- let tipText = tip.isEmpty ? "No agents" : tip.joined(separator: " · ")
- item.button?.toolTip = tipText
- item.button?.setAccessibilityLabel("dingger, " + tipText)
- }
+ // MARK: menu (rebuilt from fresh data every time it opens)
- // Rebuilt from fresh data every time the menu opens.
func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()
- let rows = fetchRows()
+ rows = fetchRows()
if rows.isEmpty {
let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "")
none.isEnabled = false
@@ -263,6 +143,44 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
entry.representedObject = row.paneId
menu.addItem(entry)
}
+
+ menu.addItem(.separator())
+ let toggle = NSMenuItem(title: visibility.visible ? "Hide Panel" : "Show Panel",
+ action: #selector(togglePanel), keyEquivalent: "")
+ toggle.target = self
+ if let hotkey, hotkeyRegistered {
+ toggle.keyEquivalent = hotkey.key.count == 1 ? hotkey.key : ""
+ toggle.keyEquivalentModifierMask = hotkey.modifierFlags
+ }
+ menu.addItem(toggle)
+
+ let modeMenu = NSMenu()
+ for mode in PanelMode.allCases {
+ let option = NSMenuItem(title: mode.label, action: #selector(selectMode(_:)), keyEquivalent: "")
+ option.target = self
+ option.representedObject = mode.rawValue
+ option.state = visibility.mode == mode ? .on : .off
+ modeMenu.addItem(option)
+ }
+ let modeItem = NSMenuItem(title: "Show Panel Automatically", action: nil, keyEquivalent: "")
+ modeItem.submenu = modeMenu
+ menu.addItem(modeItem)
+
+ if let hotkey, !hotkeyRegistered {
+ let clash = NSMenuItem(title: "Shortcut \(hotkey.display) is used by another app", action: nil,
+ keyEquivalent: "")
+ clash.isEnabled = false
+ menu.addItem(clash)
+ } else if hotkey == nil {
+ let invalid = NSMenuItem(title: "Shortcut \"\(settings.hotkey)\" is not valid", action: nil,
+ keyEquivalent: "")
+ invalid.isEnabled = false
+ menu.addItem(invalid)
+ }
+ let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",")
+ settingsItem.target = self
+ menu.addItem(settingsItem)
+
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Quit dingger", action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q"))
@@ -270,14 +188,21 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
@objc private func rowClicked(_ sender: NSMenuItem) {
guard let paneId = sender.representedObject as? String else { return }
- DispatchQueue.global().async {
- focus(paneId)
- DispatchQueue.main.async { self.refreshTitle() }
- }
+ jump(to: paneId)
}
}
let arguments = CommandLine.arguments
+if arguments.contains("--selftest") {
+ var failures = panelSelftest()
+ if Hotkey.parse("ctrl+opt+cmd+e")?.display != "⌃⌥⌘E" { failures.append("hotkey: parse ctrl+opt+cmd+e") }
+ if Hotkey.parse("e") != nil { failures.append("hotkey: a key without modifiers is rejected") }
+ if Hotkey.parse("ctrl+opt+bogus") != nil { failures.append("hotkey: unknown keys are rejected") }
+ if Hotkey.parse("cmd+shift+f12")?.display != "⇧⌘F12" { failures.append("hotkey: parse cmd+shift+f12") }
+ failures.forEach { print("FAIL \($0)") }
+ print(failures.isEmpty ? "selftest ok" : "selftest: \(failures.count) failed")
+ exit(failures.isEmpty ? 0 : 1)
+}
if arguments.contains("--print") {
let rows = fetchRows()
print("title: \(plainTitle(for: rows))")
@@ -286,6 +211,10 @@ if arguments.contains("--print") {
}
exit(0)
}
+if arguments.contains("--print-panel") {
+ print(PanelContent(rows: fetchRows()).plainText)
+ exit(0)
+}
if let index = arguments.firstIndex(of: "--click"), index + 1 < arguments.count {
focus(arguments[index + 1])
exit(0)
diff --git a/menubar/model.swift b/menubar/model.swift
new file mode 100644
index 0000000..b4a54a0
--- /dev/null
+++ b/menubar/model.swift
@@ -0,0 +1,200 @@
+// Shared model for the menu bar and the floating panel: herdr's palette and
+// status vocabulary, agent rows read from tmux, and the focus action.
+
+import AppKit
+
+// MARK: herdr's palette (catppuccin mocha on dark, latte on light)
+
+func herdrColor(dark: (CGFloat, CGFloat, CGFloat), light: (CGFloat, CGFloat, CGFloat)) -> NSColor {
+ NSColor(name: nil) { appearance in
+ let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ let (r, g, b) = isDark ? dark : light
+ return NSColor(srgbRed: r / 255, green: g / 255, blue: b / 255, alpha: 1)
+ }
+}
+
+let herdrRed = herdrColor(dark: (243, 139, 168), light: (210, 15, 57))
+let herdrTeal = herdrColor(dark: (148, 226, 213), light: (23, 146, 153))
+let herdrYellow = herdrColor(dark: (249, 226, 175), light: (223, 142, 29))
+let herdrGreen = herdrColor(dark: (166, 227, 161), light: (64, 160, 43))
+let herdrGray = herdrColor(dark: (108, 112, 134), light: (156, 160, 176))
+
+// MARK: herdr's status vocabulary
+
+enum Status: String {
+ case blocked, done, working, idle, unknown
+
+ var symbol: String {
+ switch self {
+ case .blocked: return "×"
+ case .done: return "✓"
+ case .working: return "◐"
+ case .idle: return "○"
+ case .unknown: return "·"
+ }
+ }
+
+ var color: NSColor {
+ switch self {
+ case .blocked: return herdrRed
+ case .done: return herdrTeal
+ case .working: return herdrYellow
+ case .idle: return herdrGreen
+ case .unknown: return herdrGray
+ }
+ }
+
+ var priority: Int {
+ switch self {
+ case .blocked: return 4
+ case .done: return 3
+ case .working: return 2
+ case .idle: return 1
+ case .unknown: return 0
+ }
+ }
+}
+
+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 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
+
+let environment = ProcessInfo.processInfo.environment
+
+func run(_ path: String, _ args: [String]) -> String? {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: path)
+ process.arguments = args
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = FileHandle.nullDevice
+ do {
+ try process.run()
+ } catch {
+ return nil
+ }
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else { return nil }
+ return String(data: data, encoding: .utf8)
+}
+
+func tmux(_ args: [String]) -> String? {
+ var all = ["-u"]
+ if let socket = environment["DINGGER_TMUX_SOCKET"] {
+ all += ["-L", socket]
+ }
+ return run(environment["DINGGER_TMUX"] ?? "/opt/homebrew/bin/tmux", all + args)
+}
+
+let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/bin/dingger")
+
+/// Every agent pane, in herdr's priority order, oldest waiting first.
+func fetchRows() -> [Row] {
+ let format = [
+ "#{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 >= 9, !f[1].isEmpty else { return nil }
+ let status = Status(rawValue: f[2]) ?? .unknown
+ guard status != .unknown else { return nil }
+ 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.location < $1.location
+ }
+}
+
+/// Show the pane and mark it attended; the same code path as the tmux
+/// status-line click.
+func focus(_ paneId: String) {
+ _ = run(dinggerBinary, ["focus", "--pane", paneId])
+}
+
+// MARK: rendering
+
+/// herdr-style summary: `×1 ✓3 ◐2` in priority order; `○` when every agent
+/// is idle; `·` when there are none.
+func title(for rows: [Row]) -> NSAttributedString {
+ let font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium)
+ let text = NSMutableAttributedString()
+ for status in [Status.blocked, .done, .working] {
+ let count = rows.filter { $0.status == status }.count
+ guard count > 0 else { continue }
+ if text.length > 0 {
+ text.append(NSAttributedString(string: " ", attributes: [.font: font]))
+ }
+ text.append(NSAttributedString(
+ string: "\(status.symbol)\(count)",
+ attributes: [.font: font, .foregroundColor: status.color]))
+ }
+ if text.length == 0 {
+ let status: Status = rows.isEmpty ? .unknown : .idle
+ text.append(NSAttributedString(
+ string: status.symbol, attributes: [.font: font, .foregroundColor: status.color]))
+ }
+ return text
+}
+
+func plainTitle(for rows: [Row]) -> String {
+ title(for: rows).string
+}
+
+/// 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, weight: .medium)
+ let text = NSMutableAttributedString()
+ text.append(NSAttributedString(
+ string: "\(row.status.symbol) ", attributes: [.font: mono, .foregroundColor: row.status.color]))
+ let labelColor = row.name.isEmpty ? NSColor.secondaryLabelColor : NSColor.labelColor
+ text.append(NSAttributedString(
+ string: row.label, attributes: [.font: font, .foregroundColor: labelColor]))
+ return text
+}
diff --git a/menubar/panel.swift b/menubar/panel.swift
new file mode 100644
index 0000000..ad5ee99
--- /dev/null
+++ b/menubar/panel.swift
@@ -0,0 +1,314 @@
+// The floating panel: an always-on-top HUD with the agents waiting for you.
+//
+// - Never takes focus: a non-activating panel, so typing stays in the
+// terminal. Visible on every Space and over full-screen apps.
+// - Modes: off, on, auto (shown while something is blocked or done).
+// The global shortcut shows or hides it on top of the mode.
+// - Compact: blocked and done agents in full; working and idle as one
+// summary line. The menu keeps the full list.
+
+import AppKit
+
+// MARK: visibility (pure, tested by --selftest)
+
+enum PanelMode: String, CaseIterable {
+ case off, on, auto
+
+ var label: String {
+ switch self {
+ case .off: return "Off"
+ case .on: return "Always"
+ case .auto: return "When agents are waiting"
+ }
+ }
+}
+
+struct PanelVisibility: Equatable {
+ var mode: PanelMode = .auto
+ /// Set by the shortcut: true = shown, false = hidden, nil = follow mode.
+ var forced: Bool?
+ /// Pane ids of agents currently blocked or done.
+ var waiting: Set<String> = []
+
+ var visible: Bool {
+ forced ?? (mode == .on || (mode == .auto && !waiting.isEmpty))
+ }
+
+ /// The shortcut: flip what is on screen right now.
+ mutating func toggle() {
+ forced = !visible
+ }
+
+ mutating func setMode(_ newMode: PanelMode) {
+ mode = newMode
+ forced = nil
+ }
+
+ /// A newly waiting agent cancels a manual hide (it needs you), except in
+ /// mode off, where nothing shows unless asked for.
+ mutating func update(waiting newWaiting: Set<String>) {
+ if forced == false, !newWaiting.subtracting(waiting).isEmpty {
+ forced = nil
+ }
+ waiting = newWaiting
+ }
+}
+
+/// Run the visibility rules against expectations; returns failure messages.
+func panelSelftest() -> [String] {
+ var failures: [String] = []
+ func check(_ condition: Bool, _ message: String) {
+ if !condition { failures.append(message) }
+ }
+
+ var auto = PanelVisibility(mode: .auto)
+ check(!auto.visible, "auto: hidden with nothing waiting")
+ auto.update(waiting: ["a"])
+ check(auto.visible, "auto: shown when an agent waits")
+ auto.toggle()
+ check(!auto.visible, "auto: shortcut hides")
+ auto.update(waiting: ["a"])
+ check(!auto.visible, "auto: stays hidden for the same waiting agents")
+ auto.update(waiting: ["a", "b"])
+ check(auto.visible, "auto: a newly waiting agent shows it again")
+ auto.update(waiting: [])
+ check(!auto.visible, "auto: hides when nothing waits")
+ auto.toggle()
+ check(auto.visible, "auto: shortcut shows it with nothing waiting")
+ auto.update(waiting: [])
+ check(auto.visible, "auto: a manual show stays")
+ auto.toggle()
+ check(!auto.visible, "auto: shortcut hides again")
+
+ var off = PanelVisibility(mode: .off)
+ off.update(waiting: ["a"])
+ check(!off.visible, "off: hidden even when an agent waits")
+ off.toggle()
+ check(off.visible, "off: shortcut shows")
+ off.toggle()
+ off.update(waiting: ["a", "b"])
+ check(!off.visible, "off: a new waiting agent does not show it")
+
+ var on = PanelVisibility(mode: .on)
+ check(on.visible, "on: shown with nothing waiting")
+ on.toggle()
+ check(!on.visible, "on: shortcut hides")
+ on.update(waiting: ["a"])
+ check(on.visible, "on: a newly waiting agent shows it again")
+
+ var changed = PanelVisibility(mode: .auto, forced: false, waiting: ["a"])
+ changed.setMode(.on)
+ check(changed.visible && changed.forced == nil, "mode change clears the shortcut override")
+ return failures
+}
+
+// MARK: compact content (pure, printed by --print-panel)
+
+struct PanelContent {
+ static let maxRows = 8
+
+ let waiting: [Row]
+ let overflow: Int
+ let working: Int
+ let idle: Int
+
+ init(rows: [Row]) {
+ let waitingRows = rows.filter(\.waiting)
+ waiting = Array(waitingRows.prefix(Self.maxRows))
+ overflow = max(0, waitingRows.count - Self.maxRows)
+ working = rows.filter { $0.status == .working }.count
+ idle = rows.filter { $0.status == .idle }.count
+ }
+
+ /// "◐ 2 working · ○ 15 idle", or "No agents".
+ var summary: NSAttributedString {
+ let font = NSFont.systemFont(ofSize: 11)
+ let text = NSMutableAttributedString()
+ for (status, count) in [(Status.working, working), (.idle, idle)] where count > 0 {
+ if text.length > 0 {
+ text.append(NSAttributedString(
+ string: " · ", attributes: [.font: font, .foregroundColor: NSColor.tertiaryLabelColor]))
+ }
+ text.append(NSAttributedString(
+ string: "\(status.symbol) ", attributes: [.font: font, .foregroundColor: status.color]))
+ text.append(NSAttributedString(
+ string: "\(count) \(status.rawValue)",
+ attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor]))
+ }
+ if waiting.isEmpty {
+ let lead = text.length > 0 ? "Nothing waiting · " : "No agents waiting"
+ text.insert(NSAttributedString(
+ string: lead, attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor]), at: 0)
+ }
+ return text
+ }
+
+ var plainText: String {
+ var lines = waiting.map { "\($0.status.symbol) \($0.status.rawValue)\t\($0.label)\t\($0.subtitle)" }
+ if overflow > 0 { lines.append("+\(overflow) more waiting") }
+ lines.append(summary.string)
+ return lines.joined(separator: "\n")
+ }
+}
+
+// MARK: window
+
+private let panelWidth: CGFloat = 440
+private let inset: CGFloat = 8
+
+/// One clickable agent row; a drag anywhere moves the panel instead.
+private final class PanelRowView: NSView {
+ private let paneId: String
+ private let onClick: (String) -> Void
+ private var dragged = false
+ private var hovered = false { didSet { needsDisplay = true } }
+
+ init(row: Row, onClick: @escaping (String) -> Void) {
+ paneId = row.paneId
+ self.onClick = onClick
+ super.init(frame: .zero)
+ let title = NSTextField(labelWithAttributedString: rowTitle(row))
+ title.lineBreakMode = .byTruncatingTail
+ let subtitle = NSTextField(labelWithString: row.subtitle)
+ subtitle.font = .systemFont(ofSize: 11)
+ subtitle.textColor = .secondaryLabelColor
+ subtitle.lineBreakMode = .byTruncatingTail
+ let stack = NSStackView(views: [title, subtitle])
+ stack.orientation = .vertical
+ stack.alignment = .leading
+ stack.spacing = 1
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(stack)
+ NSLayoutConstraint.activate([
+ stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
+ stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8),
+ stack.topAnchor.constraint(equalTo: topAnchor, constant: 5),
+ stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5),
+ ])
+ toolTip = row.name.isEmpty ? nil : row.name
+ }
+
+ required init?(coder: NSCoder) { fatalError("not used") }
+
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ trackingAreas.forEach(removeTrackingArea)
+ addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways],
+ owner: self))
+ }
+
+ override func mouseEntered(with event: NSEvent) { hovered = true }
+ override func mouseExited(with event: NSEvent) { hovered = false }
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard hovered else { return }
+ NSColor.white.withAlphaComponent(0.08).setFill()
+ NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill()
+ }
+
+ override func mouseDown(with event: NSEvent) { dragged = false }
+
+ override func mouseDragged(with event: NSEvent) {
+ if !dragged {
+ dragged = true
+ window?.performDrag(with: event)
+ }
+ }
+
+ override func mouseUp(with event: NSEvent) {
+ if !dragged { onClick(paneId) }
+ }
+}
+
+final class PanelController {
+ private let panel: NSPanel
+ private let stack = NSStackView()
+ private let onClick: (String) -> Void
+
+ init(onClick: @escaping (String) -> Void) {
+ self.onClick = onClick
+ panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: 60),
+ styleMask: [.nonactivatingPanel, .borderless],
+ backing: .buffered, defer: false)
+ panel.isFloatingPanel = true
+ panel.level = .floating
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+ panel.hidesOnDeactivate = false
+ panel.isMovableByWindowBackground = true
+ panel.backgroundColor = .clear
+ panel.isOpaque = false
+ panel.hasShadow = true
+ panel.appearance = NSAppearance(named: .vibrantDark)
+
+ let effect = NSVisualEffectView()
+ effect.material = .hudWindow
+ effect.blendingMode = .behindWindow
+ effect.state = .active
+ effect.wantsLayer = true
+ effect.layer?.cornerRadius = 12
+ effect.layer?.masksToBounds = true
+ panel.contentView = effect
+
+ stack.orientation = .vertical
+ stack.alignment = .leading
+ stack.spacing = 0
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ effect.addSubview(stack)
+ 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),
+ ])
+
+ // Remember where the user put it; first time, top right of the screen.
+ if !panel.setFrameUsingName("dingger.panel"), let screen = NSScreen.main {
+ let area = screen.visibleFrame
+ panel.setFrameTopLeftPoint(NSPoint(x: area.maxX - panelWidth - 16, y: area.maxY - 16))
+ }
+ panel.setFrameAutosaveName("dingger.panel")
+ }
+
+ var isVisible: Bool { panel.isVisible }
+
+ func render(_ content: PanelContent) {
+ stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
+ for row in content.waiting {
+ let view = PanelRowView(row: row, onClick: onClick)
+ stack.addArrangedSubview(view)
+ view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+ }
+ if content.overflow > 0 {
+ stack.addArrangedSubview(caption(NSAttributedString(
+ string: "+\(content.overflow) more waiting, see the menu bar",
+ attributes: [.font: NSFont.systemFont(ofSize: 11),
+ .foregroundColor: NSColor.secondaryLabelColor])))
+ }
+ stack.addArrangedSubview(caption(content.summary))
+
+ // Grow or shrink downward from the top edge.
+ stack.layoutSubtreeIfNeeded()
+ let height = stack.fittingSize.height + inset * 2
+ let top = panel.frame.maxY
+ panel.setFrame(NSRect(x: panel.frame.minX, y: top - height, width: panelWidth, height: height),
+ display: true)
+ }
+
+ private func caption(_ text: NSAttributedString) -> NSView {
+ let label = NSTextField(labelWithAttributedString: text)
+ let box = NSView()
+ label.translatesAutoresizingMaskIntoConstraints = false
+ box.addSubview(label)
+ NSLayoutConstraint.activate([
+ label.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 8),
+ label.trailingAnchor.constraint(lessThanOrEqualTo: box.trailingAnchor, constant: -8),
+ label.topAnchor.constraint(equalTo: box.topAnchor, constant: 6),
+ label.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -4),
+ ])
+ return box
+ }
+
+ func show() { panel.orderFrontRegardless() }
+ func hide() { panel.orderOut(nil) }
+}
diff --git a/tests/e2e.sh b/tests/e2e.sh
index 015d6aa..054bd51 100755
--- a/tests/e2e.sh
+++ b/tests/e2e.sh
@@ -282,6 +282,20 @@ else
fail "node not found; cannot test the pi extension"
fi
+# --- 13. the floating panel: visibility rules and compact content -----------
+echo "13. floating panel"
+if [ -x "$MENUBAR" ]; then
+ mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; }
+ expect "panel visibility rules (selftest)" "mb --selftest | tail -1" "selftest ok"
+ expect "panel lists the waiting agent" "mb --print-panel | head -1 | cut -f1,2 | tr '\t' '|'" "✓ done|pi"
+ expect "panel summarizes the rest" "mb --print-panel | tail -1" "○ 3 idle"
+ DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A2"
+ expect "A2 idle after the jump" "opt $A2 @agent_state" idle
+ expect "panel with nothing waiting" "mb --print-panel" "Nothing waiting · ○ 4 idle"
+else
+ fail "menu bar binary not found at $MENUBAR"
+fi
+
echo
echo "$PASSES passed, $FAILS failed"
[ "$FAILS" -eq 0 ] || { echo "--- daemon log tail"; tail -30 "$DIR/daemon.log"; exit 1; }