aboutsummaryrefslogtreecommitdiff
path: root/menubar
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-22 00:02:16 +0300
committerIgor Soarez <igor@soarez.org>2026-10-22 00:02:16 +0300
commit7649396bf30a65096fc3944e1404835e661f00af (patch)
treecaea05531b4f2615425455261962fd433a6eea96 /menubar
parentaa4d6fcd9eac0d0fe051d6307ed6009adefbb2e1 (diff)
panel: on/off is persistent; what it shows is a separate setting
hiding the panel with the shortcut used to last only until another agent started waiting: one action meant both "not now" and "off", with an invisible override on top of a three-way mode. now there are two visible settings: the panel is on or off (ctrl+opt+cmd+e and a checkmarked "Show Panel" item), and while on it shows always or only when agents are waiting ("Panel Shows"). off stays off. turning it on with nothing to show peeks for 3 seconds. the old mode setting migrates. selftest covers the rules; it fails if a waiting agent can show a panel that is off.
Diffstat (limited to 'menubar')
-rw-r--r--menubar/main.swift71
-rw-r--r--menubar/panel.swift160
2 files changed, 130 insertions, 101 deletions
diff --git a/menubar/main.swift b/menubar/main.swift
index c1891ab..183c5db 100644
--- a/menubar/main.swift
+++ b/menubar/main.swift
@@ -32,8 +32,7 @@ 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 visibility = Menubar.loadVisibility()
private var settings = Settings()
private var settingsModified: Date?
private var hotkey: Hotkey?
@@ -43,6 +42,22 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var prefersFull = UserDefaults.standard.bool(forKey: "panelFull")
private var rows: [Row] = []
+ /// The panel settings; the first run after the on/off split migrates the
+ /// old three-way mode.
+ private static func loadVisibility() -> PanelVisibility {
+ let defaults = UserDefaults.standard
+ guard defaults.object(forKey: "panelEnabled") != nil else {
+ let migrated = PanelVisibility.migrated(fromMode: defaults.string(forKey: "panelMode") ?? "auto")
+ defaults.set(migrated.enabled, forKey: "panelEnabled")
+ defaults.set(migrated.policy.rawValue, forKey: "panelPolicy")
+ defaults.removeObject(forKey: "panelMode")
+ return migrated
+ }
+ return PanelVisibility(
+ enabled: defaults.bool(forKey: "panelEnabled"),
+ policy: PanelPolicy(rawValue: defaults.string(forKey: "panelPolicy") ?? "") ?? .whenWaiting)
+ }
+
func applicationDidFinishLaunching(_: Notification) {
menu.delegate = self
item.menu = menu
@@ -96,34 +111,42 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
item.button?.toolTip = tipText
item.button?.setAccessibilityLabel("dingger, " + tipText)
- visibility.update(waiting: Set(rows.filter(\.waiting).map(\.paneId)))
+ visibility.anyWaiting = rows.contains(where: \.waiting)
applyPanel()
}
private var panelContent: PanelContent {
PanelContent(rows: rows,
- full: panelShowsFull(mode: visibility.mode, prefersFull: prefersFull),
- canToggle: visibility.mode == .on)
+ full: panelShowsFull(policy: visibility.policy, prefersFull: prefersFull),
+ canToggle: visibility.policy == .always)
}
private func applyPanel() {
- if visibility.visible {
+ if visibility.visible(at: Date()) {
if panel.render(panelContent) {
let content = panelContent
log("panel: render \(content.rows.filter(\.waiting).count) waiting, \(content.full ? "full" : "compact")")
}
if !panel.isVisible {
panel.show()
- log("panel: shown (\(visibility.mode.rawValue), forced: \(String(describing: visibility.forced)))")
+ log("panel: shown (\(panelState))")
}
} else if panel.isVisible {
panel.hide()
- log("panel: hidden (\(visibility.mode.rawValue), forced: \(String(describing: visibility.forced)))")
+ log("panel: hidden (\(panelState))")
}
}
+ private var panelState: String {
+ "\(visibility.enabled ? "on" : "off"), \(visibility.policy.rawValue), "
+ + (visibility.anyWaiting ? "agents waiting" : "nothing waiting")
+ }
+
+ /// The shortcut and the menu item: turn the panel on or off, persistently.
@objc private func togglePanel() {
- visibility.toggle()
+ visibility.toggle(at: Date())
+ UserDefaults.standard.set(visibility.enabled, forKey: "panelEnabled")
+ log("panel: turned \(visibility.enabled ? "on" : "off")")
applyPanel()
}
@@ -142,10 +165,10 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
}
- @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")
+ @objc private func selectPolicy(_ sender: NSMenuItem) {
+ guard let raw = sender.representedObject as? String, let policy = PanelPolicy(rawValue: raw) else { return }
+ visibility.policy = policy
+ UserDefaults.standard.set(policy.rawValue, forKey: "panelPolicy")
applyPanel()
}
@@ -187,9 +210,9 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
menu.addItem(.separator())
- let toggle = NSMenuItem(title: visibility.visible ? "Hide Panel" : "Show Panel",
- action: #selector(togglePanel), keyEquivalent: "")
+ let toggle = NSMenuItem(title: "Show Panel", action: #selector(togglePanel), keyEquivalent: "")
toggle.target = self
+ toggle.state = visibility.enabled ? .on : .off
if let hotkey, hotkeyRegistered {
toggle.keyEquivalent = hotkey.key.count == 1 ? hotkey.key : ""
toggle.keyEquivalentModifierMask = hotkey.modifierFlags
@@ -206,17 +229,17 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
menu.addItem(jumpItem)
- let modeMenu = NSMenu()
- for mode in PanelMode.allCases {
- let option = NSMenuItem(title: mode.label, action: #selector(selectMode(_:)), keyEquivalent: "")
+ let policyMenu = NSMenu()
+ for policy in PanelPolicy.allCases {
+ let option = NSMenuItem(title: policy.label, action: #selector(selectPolicy(_:)), keyEquivalent: "")
option.target = self
- option.representedObject = mode.rawValue
- option.state = visibility.mode == mode ? .on : .off
- modeMenu.addItem(option)
+ option.representedObject = policy.rawValue
+ option.state = visibility.policy == policy ? .on : .off
+ policyMenu.addItem(option)
}
- let modeItem = NSMenuItem(title: "Show Panel Automatically", action: nil, keyEquivalent: "")
- modeItem.submenu = modeMenu
- menu.addItem(modeItem)
+ let policyItem = NSMenuItem(title: "Panel Shows", action: nil, keyEquivalent: "")
+ policyItem.submenu = policyMenu
+ menu.addItem(policyItem)
for (shortcut, registered, spec) in [(hotkey, hotkeyRegistered, settings.hotkey),
(jumpHotkey, jumpHotkeyRegistered, settings.jumpHotkey)] {
diff --git a/menubar/panel.swift b/menubar/panel.swift
index e6ff569..2b3cad2 100644
--- a/menubar/panel.swift
+++ b/menubar/panel.swift
@@ -2,10 +2,10 @@
//
// - 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 ("Always"), auto ("When agents are waiting"). The global
-// shortcut shows or hides it on top of the mode.
+// - On or off (the global shortcut and the menu toggle it; off stays off),
+// and while on: shown always, or only when agents are waiting.
// - Layout: compact (waiting agents in full, working and idle as one summary
-// line); in "Always" mode a toggle switches to the full list.
+// line); when shown always, a toggle switches to the full list.
// - Always dark: herdr's mocha palette with fixed colors on a dark tint, so
// text reads the same over any desktop.
@@ -13,52 +13,58 @@ import AppKit
// MARK: visibility (pure, tested by --selftest)
-enum PanelMode: String, CaseIterable {
- case off, on, auto
+/// What the panel shows while it is on.
+enum PanelPolicy: String, CaseIterable {
+ case always, whenWaiting
var label: String {
switch self {
- case .off: return "Off"
- case .on: return "Always"
- case .auto: return "When agents are waiting"
+ case .always: return "Always"
+ case .whenWaiting: return "Only When Agents Are Waiting"
}
}
}
+/// Two persistent settings and one fact:
+/// - enabled: the panel is on (the shortcut and the menu toggle it);
+/// - policy: while on, show it always or only when agents are waiting;
+/// - waiting: whether any agent is blocked or done.
+/// Off stays off: nothing shows the panel until it is turned back on.
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))
+ static let peekSeconds: TimeInterval = 3
+
+ var enabled = true
+ var policy: PanelPolicy = .whenWaiting
+ var anyWaiting = false
+ /// Turning the panel on with nothing to show shows it briefly, so the
+ /// shortcut visibly did something.
+ var peekUntil: Date?
+
+ func visible(at now: Date) -> Bool {
+ guard enabled else { return false }
+ if policy == .always || anyWaiting { return true }
+ return peekUntil.map { now < $0 } ?? false
}
- /// The shortcut: flip what is on screen right now.
- mutating func toggle() {
- forced = !visible
+ mutating func toggle(at now: Date) {
+ enabled.toggle()
+ peekUntil = enabled ? now.addingTimeInterval(Self.peekSeconds) : nil
}
- 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
+ /// Settings from before the on/off split: a three-way mode.
+ static func migrated(fromMode mode: String) -> PanelVisibility {
+ switch mode {
+ case "off": return PanelVisibility(enabled: false, policy: .whenWaiting)
+ case "on": return PanelVisibility(enabled: true, policy: .always)
+ default: return PanelVisibility(enabled: true, policy: .whenWaiting)
}
- waiting = newWaiting
}
}
-/// The full list is offered only in "Always" mode; otherwise compact.
-func panelShowsFull(mode: PanelMode, prefersFull: Bool) -> Bool {
- mode == .on && prefersFull
+/// The full list is offered only when the panel shows always; otherwise
+/// compact.
+func panelShowsFull(policy: PanelPolicy, prefersFull: Bool) -> Bool {
+ policy == .always && prefersFull
}
// MARK: content (pure, printed by --print-panel)
@@ -75,7 +81,7 @@ struct PanelContent {
let working: Int
let idle: Int
let full: Bool
- /// Whether the panel offers the compact/full toggle ("Always" mode).
+ /// Whether the panel offers the compact/full toggle (shown always).
let canToggle: Bool
init(rows all: [Row], full: Bool = false, canToggle: Bool = false) {
@@ -451,49 +457,49 @@ func panelSelftest() -> [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")
-
- check(!panelShowsFull(mode: .auto, prefersFull: true), "layout: compact when agents are waiting")
- check(!panelShowsFull(mode: .off, prefersFull: true), "layout: compact when shown by the shortcut in off")
- check(panelShowsFull(mode: .on, prefersFull: true), "layout: full in always when chosen")
- check(!panelShowsFull(mode: .on, prefersFull: false), "layout: compact in always by default")
+ let t0 = Date(timeIntervalSince1970: 1_000)
+ func at(_ seconds: TimeInterval) -> Date { t0.addingTimeInterval(seconds) }
+
+ var panel = PanelVisibility()
+ check(panel.enabled && panel.policy == .whenWaiting, "defaults: on, only when agents are waiting")
+ check(!panel.visible(at: t0), "waiting policy: hidden with nothing waiting")
+ panel.anyWaiting = true
+ check(panel.visible(at: t0), "waiting policy: shown when an agent waits")
+ panel.toggle(at: t0)
+ check(!panel.visible(at: t0), "off: the shortcut hides it")
+ panel.anyWaiting = false
+ panel.anyWaiting = true
+ check(!panel.visible(at: at(60)), "off: stays hidden when another agent starts waiting")
+ panel.toggle(at: at(60))
+ check(panel.visible(at: at(60)), "on again: shown, an agent is waiting")
+ panel.anyWaiting = false
+ check(!panel.visible(at: at(70)), "waiting policy: hides when nothing waits")
+
+ var peek = PanelVisibility(enabled: false, policy: .whenWaiting)
+ peek.toggle(at: t0)
+ check(peek.visible(at: at(1)), "turned on with nothing waiting: shown briefly")
+ check(!peek.visible(at: at(PanelVisibility.peekSeconds + 0.1)), "the brief show ends after the peek")
+ peek.toggle(at: at(10))
+ check(!peek.visible(at: at(10)) && peek.peekUntil == nil, "turned off: no peek")
+
+ var always = PanelVisibility(enabled: true, policy: .always)
+ check(always.visible(at: t0), "always: shown with nothing waiting")
+ always.toggle(at: t0)
+ always.anyWaiting = true
+ check(!always.visible(at: at(5)), "always, off: an agent waiting does not show it")
+ always.policy = .whenWaiting
+ check(!always.enabled, "changing what it shows keeps it off")
+
+ check(PanelVisibility.migrated(fromMode: "off") == PanelVisibility(enabled: false, policy: .whenWaiting),
+ "migration: off")
+ check(PanelVisibility.migrated(fromMode: "on") == PanelVisibility(enabled: true, policy: .always),
+ "migration: always")
+ check(PanelVisibility.migrated(fromMode: "auto") == PanelVisibility(enabled: true, policy: .whenWaiting),
+ "migration: when waiting")
+
+ check(!panelShowsFull(policy: .whenWaiting, prefersFull: true), "layout: compact when only waiting")
+ check(panelShowsFull(policy: .always, prefersFull: true), "layout: full in always when chosen")
+ check(!panelShowsFull(policy: .always, prefersFull: false), "layout: compact in always by default")
func row(_ id: String, _ status: Status) -> Row {
Row(paneId: id, agent: "pi", name: id, status: status, session: "s", window: id, since: 1)