Slice 4 of Round 4 — the visible polish. With Slices 1–3 the agent
preamble + non-dismissible banner cover the "agent must know +
user must see" loop, but a closed HUD with no banner visible can
still hide the warning. This slice paints the menu-bar icon red so
the warning is glanceable from anywhere on the screen.
Option A from the slice plan (single-writer-safe):
- `MenuBarIconController.applyHealth(_ health: OverallHealth)` paints
`button.contentTintColor = NSColor.systemRed` for `.loud` and
`.critical`, clears the tint for `.ok` and `.soft`. Template images
recolor automatically with `contentTintColor` so no second icon
asset is needed.
- `currentHealth: OverallHealth?` exposed read-only for tests.
- `AppDelegate.runBootHealth` calls `menuBarController?.applyHealth(
snapshot.overallHealth)` after computing the degradation summary,
so the icon updates on every probe sweep (cold boot + Status panel
re-probe).
Single-writer invariant for HudState (HUD-08) is intact:
`contentTintColor` lives on `NSStatusItemButton`, not in HudState.
`scripts/check-single-writer-hudstate.sh` is green.
1 new test in MenuBarIconControllerTests:
* test_applyHealthTintsRedForLoudAndCritical — exercises all four
OverallHealth cases and asserts contentTintColor toggles correctly.
Boundary gates 18/18 green; App target compiles cleanly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
155 lines
6.6 KiB
Swift
155 lines
6.6 KiB
Swift
import AppKit
|
|
import QuartzCore
|
|
import AgentCore
|
|
|
|
/// Owns the `NSStatusItem` and drives Core Animation on its button layer for the
|
|
/// five `HudState` transitions. Follows RESEARCH.md §Question 7 lines 753-869.
|
|
///
|
|
/// Critical gotchas cited from RESEARCH.md:
|
|
/// - Set `button.image` and animate `transform`/`opacity` via `CABasicAnimation`;
|
|
/// NEVER swap `layer.contents` (Q7 rule).
|
|
/// - After changing `layer.anchorPoint`, re-anchor the frame to the button's
|
|
/// bounds — otherwise rotation around the center jumps to bottom-left
|
|
/// (Q7 gotcha #2, Apple DevForums thread 88341).
|
|
/// - `isTemplate = true` set in code is defense-in-depth against asset-catalog
|
|
/// misconfiguration (D-13).
|
|
@MainActor
|
|
public final class MenuBarIconController {
|
|
public let statusItem: NSStatusItem
|
|
/// The context menu. Exposed so voice subsystem can add the mute-wake-word toggle (Plan 06-05).
|
|
public private(set) var contextMenu: NSMenu
|
|
private var currentState: HudState = .idle
|
|
private var onLeftClick: (() -> Void)?
|
|
private var lastAccessibilityAnnouncement: Date = .distantPast
|
|
|
|
public init(statusItem: NSStatusItem, contextMenu: NSMenu) {
|
|
self.statusItem = statusItem
|
|
self.contextMenu = contextMenu
|
|
configureButton()
|
|
}
|
|
|
|
/// Wired by `AppDelegate` — opens/dismisses the HUD panel.
|
|
public func setLeftClickAction(_ action: @escaping () -> Void) {
|
|
self.onLeftClick = action
|
|
}
|
|
|
|
/// Current state, exposed read-only for tests.
|
|
public var state: HudState { currentState }
|
|
|
|
/// Round 4 — current overall boot-health, exposed read-only for tests.
|
|
/// `nil` until `applyHealth(_:)` is first called.
|
|
public private(set) var currentHealth: OverallHealth?
|
|
|
|
/// Round 4 — apply a boot-health tint to the menu-bar icon. `.loud` and
|
|
/// `.critical` paint a red `contentTintColor` so the icon visibly
|
|
/// communicates that something is wrong even when no HUD is open.
|
|
/// `.ok` and `.soft` clear the tint (system label color resumes).
|
|
///
|
|
/// Why `contentTintColor` and not a separate image: template images
|
|
/// already swap colors with `contentTintColor` (Apple's documented
|
|
/// behavior), so we get the red without bundling a second asset.
|
|
/// Critical: we don't write to HudState — the single-writer invariant
|
|
/// for HudState (HUD-08) stays intact.
|
|
public func applyHealth(_ health: OverallHealth) {
|
|
currentHealth = health
|
|
guard let button = statusItem.button else { return }
|
|
switch health {
|
|
case .loud, .critical:
|
|
button.contentTintColor = NSColor.systemRed
|
|
case .ok, .soft:
|
|
button.contentTintColor = nil
|
|
}
|
|
}
|
|
|
|
/// Transition to a new HUD state. Same-state transitions are no-ops.
|
|
public func transition(to newState: HudState) {
|
|
guard newState != currentState else { return }
|
|
guard let button = statusItem.button, let layer = button.layer else { return }
|
|
|
|
layer.add(CAAnimationFactory.makeStateCrossfade(), forKey: "crossfade")
|
|
layer.removeAnimation(forKey: "state.breath")
|
|
layer.removeAnimation(forKey: "state.rotate")
|
|
layer.removeAnimation(forKey: "state.shimmer")
|
|
layer.removeAnimation(forKey: "state.glow")
|
|
|
|
switch newState {
|
|
case .idle:
|
|
break
|
|
case .listening:
|
|
if let a = CAAnimationFactory.makeBreath() { layer.add(a, forKey: "state.breath") }
|
|
case .thinking:
|
|
if let a = CAAnimationFactory.makeRotate() { layer.add(a, forKey: "state.rotate") }
|
|
case .speaking:
|
|
if let a = CAAnimationFactory.makeShimmer() { layer.add(a, forKey: "state.shimmer") }
|
|
case .awaitingConfirmation:
|
|
if let a = CAAnimationFactory.makeGlow() { layer.add(a, forKey: "state.glow") }
|
|
case .reconfiguring, .booting:
|
|
// TODO(Phase 3/4): dedicated animations; these two share .idle's
|
|
// profile per HUD-08 "reconfiguring literally lowest" precedence
|
|
// (RESEARCH Open Q #4). No animation added.
|
|
break
|
|
}
|
|
|
|
currentState = newState
|
|
button.setAccessibilityLabel(newState.voiceOverLabel)
|
|
announceRateLimited(newState.voiceOverLabel)
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func configureButton() {
|
|
guard let button = statusItem.button else { return }
|
|
button.image = NSImage(named: "Icon-MenuBar-Template")
|
|
button.image?.isTemplate = true
|
|
|
|
button.wantsLayer = true
|
|
if let layer = button.layer {
|
|
layer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
|
|
// Re-anchor after changing anchorPoint (Q7 gotcha #2 — rotation around
|
|
// the center jumps to bottom-left without this).
|
|
layer.frame = button.bounds
|
|
}
|
|
|
|
button.setAccessibilityLabel(currentState.voiceOverLabel)
|
|
button.setAccessibilityHelp("Click to summon or dismiss Jarvis. Right-click for more options.")
|
|
button.setAccessibilityRole(.button)
|
|
|
|
// `sendAction:to:` lets us inspect NSApp.currentEvent for left vs right click.
|
|
button.target = self
|
|
button.action = #selector(handleClick(_:))
|
|
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
|
}
|
|
|
|
@objc private func handleClick(_ sender: NSStatusBarButton) {
|
|
guard let event = NSApp.currentEvent else { return }
|
|
let isRightClick = event.type == .rightMouseUp
|
|
|| event.modifierFlags.contains(.control)
|
|
if isRightClick {
|
|
// Pop up the context menu directly via the status item without
|
|
// assigning it as `statusItem.menu`. The previous "temporarily
|
|
// assign + performClick + clear" pattern was racy: NSStatusItem
|
|
// can latch the menu on a later runloop pass, after which left-
|
|
// and right-clicks both surface the menu and the left-click
|
|
// action never fires. `popUpMenu(_:)` is documented (deprecated
|
|
// in 10.14, but still functional through Tahoe) and only
|
|
// displays the menu for the current click — no persistent state.
|
|
statusItem.popUpMenu(contextMenu)
|
|
} else {
|
|
onLeftClick?()
|
|
}
|
|
}
|
|
|
|
private func announceRateLimited(_ label: String) {
|
|
// UI-SPEC line 421: VoiceOver announcements limited to once per 3s to
|
|
// avoid thinking→speaking→thinking churn spamming the user.
|
|
let now = Date()
|
|
guard now.timeIntervalSince(lastAccessibilityAnnouncement) >= 3.0 else { return }
|
|
lastAccessibilityAnnouncement = now
|
|
NSAccessibility.post(
|
|
element: NSApp as Any,
|
|
notification: .announcementRequested,
|
|
userInfo: [.announcement: label]
|
|
)
|
|
}
|
|
}
|