aboutsummaryrefslogtreecommitdiff
path: root/menubar/main.swift
diff options
context:
space:
mode:
Diffstat (limited to 'menubar/main.swift')
-rw-r--r--menubar/main.swift351
1 files changed, 140 insertions, 211 deletions
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)