diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-20 14:24:11 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-20 14:24:11 +0300 |
| commit | 0f8438547e50e10b3c09f17356b1d88b93718b79 (patch) | |
| tree | 325e8b45af647af653c10318197b0ad361d277a7 | |
| parent | 6bccc5f786c479a335c55ad83487e077195cff44 (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.md | 20 | ||||
| -rw-r--r-- | menubar/hotkey.swift | 146 | ||||
| -rw-r--r-- | menubar/main.swift | 351 | ||||
| -rw-r--r-- | menubar/model.swift | 200 | ||||
| -rw-r--r-- | menubar/panel.swift | 314 | ||||
| -rwxr-xr-x | tests/e2e.sh | 14 |
6 files changed, 830 insertions, 215 deletions
@@ -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; } |
