feat(app): v0.1 UX surfaces — startup panels + InputMonitoring banner + PTT #156

Merged
KofTwentyTwo merged 3 commits from feat/app-ux-surfaces-v0.1 into develop 2026-05-13 11:32:16 +00:00
10 changed files with 518 additions and 41 deletions

No files matched your search

+203 -15
View File
@@ -297,6 +297,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// so the SwiftUI model state survives the panel cycling.
private var statusPanel: StatusPanel?
/// Issue #89 — startup-surfaces mode. `.diagnostic` opens Status panel +
/// DevOverlay alongside the HUD on app start; `.minimal` opens only the
/// HUD. Defaults to `.diagnostic` for Debug builds (developer's
/// observability surface is one launch away, not three menu clicks) and
/// `.minimal` for Release builds. Settable by injection in tests; tests
/// also use the `XCTestConfigurationFilePath` short-circuit below to
/// keep test runs from popping panels.
enum StartupSurfaces { case minimal, diagnostic }
var startupSurfacesMode: StartupSurfaces = {
#if DEBUG
return .diagnostic
#else
return .minimal
#endif
}()
/// Set to `true` when `applicationWillFinishLaunching` decided to open
/// the onboarding wizard. The `bootHealthTask` tail reads this to skip
/// auto-opening Status + DevOverlay over the top of the wizard.
private var startupWizardWillOpen: Bool = false
/// Drains `orchToReplayChannel` into `replayLog`. Pre-CR-02 this Task
/// `for await _ in channel`'d into `/dev/null` and the channel had no
/// producer either — ME-04's contract was structurally unverifiable
@@ -724,6 +745,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
wizardController = OnboardingWizardController(state: state)
settingsWindowController = SettingsWindowController(state: state)
let willOpenWizard = !apiKeyStored
startupWizardWillOpen = willOpenWizard
if willOpenWizard {
openWizard(firstLaunch: true)
}
@@ -899,6 +921,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
bootHealthTask = Task { @MainActor [weak self] in
await self?.selfKnowledgeInstallTask?.value
await self?.runBootHealth()
// 17. Issue #89 — open Status panel + DevOverlay alongside
// the HUD on app start when the startup-surfaces mode is
// `.diagnostic`. Runs after `runBootHealth` so the first
// probe sweep has populated state before either panel
// reads it. Skipped during the first-launch wizard so the
// two panels don't stack on top of the wizard window.
self?.openStartupSurfacesIfDiagnostic()
}
// 18. Issue #88 / SHELL-06 — re-probe Input Monitoring on app
// foreground. `NSEvent.addGlobalMonitorForEvents` silently
// no-ops on TCC denial; if the user grants access in System
// Settings (via our deep-link), we need to clear the
// non-dismissible banner without forcing them to relaunch.
// The query-only probe (`IOHIDCheckAccess`) reads the cached
// TCC decision without prompting.
NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.reprobeInputMonitoring()
}
}
}
@@ -1504,10 +1550,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
dormantVoiceContinuation = nil
// PTT hotkey binding is deferred to the wizard / Settings UI —
// unbound at launch. (VOICE-13.)
// unbound at launch. (VOICE-13 / #87.)
let ptt = PushToTalk(controller: vc)
pushToTalk = ptt
// Issue #87: if `WizardState.pttHotkey` was loaded from
// UserDefaults during launch, bind it now that the controller is
// live. No-op if the user has never bound a PTT key.
rebindPTTFromState()
// Menu-bar wake-word mute toggle. (VOICE-12.)
if let menu = menuBarController?.contextMenu {
muteWakeWord = MuteWakeWord(controller: vc, wakeWordDAG: wakeWordDAG, menuBarMenu: menu)
@@ -2879,6 +2930,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// monitor so the new shortcut takes effect immediately
// without requiring a relaunch.
self?.bindHotkeyFromWizard()
},
onPTTHotkeyChanged: { [weak self] _ in
// Issue #87: same pattern as the summon-Jarvis hotkey —
// `WizardState.pttHotkey` is already persisted; rebind
// PushToTalk so the new shortcut takes effect immediately.
self?.rebindPTTFromState()
}
)
}
@@ -2899,6 +2956,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
/// Issue #87 / VOICE-13 — bind the configured push-to-talk hotkey to
/// the live `PushToTalk` instance. Reads `WizardState.pttHotkey` and
/// `inputMonitoringGranted`, unbinds first, then re-installs the
/// global+local monitors. Idempotent: safe to call when nothing has
/// changed (PushToTalk.bind always unbinds first).
///
/// No-op when:
/// - `pushToTalk` is nil (voice subsystem still installing — the
/// install tail calls this method once `pushToTalk` is live).
/// - `WizardState.pttHotkey` is nil (user hasn't bound a PTT key
/// yet — default ships unbound to avoid colliding with
/// Cmd+Shift+J or Option+Space, which Chrome/Slack/Alfred/Raycast
/// own in different combinations).
///
/// Audio-graph isolation: binding monitors does NOT touch the audio
/// graph — `NSEvent.addGlobalMonitorForEvents` just installs key
/// callbacks. The press path routes through `VoiceController.pttDown`,
/// which uses the same audio graph the wake-word DAG already owns;
/// so rebinding is safe regardless of voice subsystem state.
@MainActor
private func rebindPTTFromState() {
guard let ptt = pushToTalk else { return }
guard let state = wizardState, let shortcut = state.pttHotkey else {
ptt.unbind()
return
}
ptt.bind(
keyCode: shortcut.keyCode,
modifiers: shortcut.modifierFlags,
inputMonitoringGranted: state.inputMonitoringGranted
)
}
// MARK: - Menu-bar actions
/// Lazily constructed on first toggle so the panel doesn't allocate
@@ -2910,23 +3001,120 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var devOverlayWindow: DevOverlayWindow?
private func toggleDevOverlay() {
if devOverlayWindow == nil {
let hudCoordinator = self.hudStateCoordinator
// HudState lives in the App target; the overlay package only
// gets a string. Closure-captured weak reference so the
// overlay never extends the coordinator's lifetime.
let reader: @MainActor () -> String = { [weak hudCoordinator] in
hudCoordinator?.currentStateForTests.rawValue ?? "—"
}
devOverlayWindow = DevOverlayWindow(
emitter: devSnapshotEmitter,
bootHealthOrchestrator: bootHealthOrchestrator,
hudStateReader: reader
)
}
ensureDevOverlayWindow(initialTab: 0)
devOverlayWindow?.toggle()
}
/// Idempotent constructor for the lazy `DevOverlayWindow`. Returns the
/// existing instance on subsequent calls; `initialTab` is honored only
/// on the first call (later calls are ignored — SwiftUI's TabView
/// `selection` is `@State`-driven after first render).
private func ensureDevOverlayWindow(initialTab: Int) {
guard devOverlayWindow == nil else { return }
let hudCoordinator = self.hudStateCoordinator
// HudState lives in the App target; the overlay package only
// gets a string. Closure-captured weak reference so the
// overlay never extends the coordinator's lifetime.
let reader: @MainActor () -> String = { [weak hudCoordinator] in
hudCoordinator?.currentStateForTests.rawValue ?? "—"
}
devOverlayWindow = DevOverlayWindow(
emitter: devSnapshotEmitter,
bootHealthOrchestrator: bootHealthOrchestrator,
hudStateReader: reader,
initialTab: initialTab
)
}
/// Issue #89 — open Status panel + DevOverlay alongside the HUD on app
/// start when `startupSurfacesMode == .diagnostic`. Called from the
/// `bootHealthTask` tail so both panels read settled state. Skipped:
/// - When `.minimal` (Release-build default).
/// - When the onboarding wizard is open — the wizard is a blocking
/// surface and stacking two panels behind it would be noisy.
/// - When running under XCTest — panels would pop during unit-test
/// hosts, breaking timing-sensitive tests.
///
/// Positioning: HUD already centers itself; Status panel goes to the
/// top-right of the main screen's visible frame and DevOverlay goes
/// bottom-right so the three surfaces don't overlap on a 1440×900
/// display. The DevOverlay opens to the Logs tab (index 3) so the
/// operator sees activity stream immediately.
@MainActor
private func openStartupSurfacesIfDiagnostic() {
guard startupSurfacesMode == .diagnostic else { return }
if startupWizardWillOpen { return }
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { return }
// Status panel — top-right.
openStatusPanel()
if let screenFrame = NSScreen.main?.visibleFrame, let panel = statusPanel {
let inset: CGFloat = 16
let size = panel.frame.size
let origin = NSPoint(
x: screenFrame.maxX - size.width - inset,
y: screenFrame.maxY - size.height - inset
)
panel.setFrameOrigin(origin)
}
// DevOverlay — bottom-right, opened to Logs tab.
ensureDevOverlayWindow(initialTab: 3)
devOverlayWindow?.show()
if let screenFrame = NSScreen.main?.visibleFrame, let window = devOverlayWindow {
let inset: CGFloat = 16
let size = window.frameSize
let origin = NSPoint(
x: screenFrame.maxX - size.width - inset,
y: screenFrame.minY + inset
)
window.setFrameOrigin(origin)
}
systemLogger?.info("openStartupSurfacesIfDiagnostic: opened Status + DevOverlay (mode=diagnostic)")
}
/// Issue #88 / SHELL-06 — query-only Input Monitoring TCC probe wired
/// to the `NSApplication.didBecomeActiveNotification` observer set up
/// in `applicationWillFinishLaunching`. Two transitions are handled:
///
/// - Was-denied → now-granted (most common: user clicked the deep
/// link, toggled access on, returned to Jarvis). Programmatically
/// dismisses the non-dismissible `.inputMonitoringDenied` banner
/// via `HUDBannerCoordinator.dismiss(id:)`. We also re-bind the
/// existing global hotkey if one was previously configured, since
/// the original bind happened under denied access and silently
/// fell back to local-only.
///
/// - Was-granted → now-denied (rarer: user toggled access off in
/// System Settings while Jarvis was background). Enqueues
/// `.inputMonitoringDenied`. The coordinator dedupes by id so a
/// re-probe while the banner is still showing is a no-op.
///
/// `IOHIDCheckAccess` reads the cached TCC decision without prompting,
/// so this is safe to call on every foreground hop.
@MainActor
private func reprobeInputMonitoring() {
let granted = hidProbe.isListenEventAccessGranted()
if granted {
bannerCoordinator?.dismiss(id: BannerContent.inputMonitoringDenied.id)
// Rebind hotkey under fresh TCC: the previous bind installed
// local monitors only (global silently nil-tokened on denial).
// Re-running `bindHotkeyFromWizard` reinstalls global monitors.
if let state = wizardState, !state.inputMonitoringGranted {
state.inputMonitoringGranted = true
if state.hotkey != nil { bindHotkeyFromWizard() }
// Issue #87: same reason as the global hotkey — the
// initial PTT bind (if any) installed local monitors
// only. Rebind so the global monitor token is acquired
// under fresh TCC.
rebindPTTFromState()
}
} else {
bannerCoordinator?.enqueue(.inputMonitoringDenied)
}
}
/// Copies a debug state snapshot to the system clipboard. Writes ONLY
/// boolean presence indicators (`apiKeyStored`, `inputMonitoringGranted`,
/// `hotkeyBound`). The API key VALUE is never fetched into a local
+9 -1
View File
@@ -54,6 +54,13 @@ public extension BannerContent {
)
/// Input Monitoring TCC denied — global hotkey degrades to local-only.
///
/// SHELL-06 / #88: non-dismissible. The user can't click this away on
/// a denial because letting them silently restore the "global hotkey
/// silently no-ops" failure mode is the defect this banner exists to
/// prevent. The banner is cleared programmatically by AppDelegate's
/// `reprobeInputMonitoring()` on app foreground after the user grants
/// access in System Settings.
static let inputMonitoringDenied = BannerContent(
id: "input-monitoring-denied",
priority: 2,
@@ -62,7 +69,8 @@ public extension BannerContent {
action: .init(
label: "Open System Settings",
url: URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")
)
),
nonDismissible: true
)
/// Hotkey registration failed — another app claimed the shortcut.
+37
View File
@@ -89,6 +89,43 @@ public final class HUDBannerCoordinator {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: item)
}
/// Programmatically dismiss a banner by id, bypassing the
/// `nonDismissible` flag. Use ONLY when the underlying condition was
/// fixed (e.g., Input Monitoring re-granted after the user opened
/// System Settings) — never as a way to let the user click past a
/// non-dismissible warning. (SHELL-06 / #88 re-probe path.)
///
/// If the banner is currently showing, it's drained the same way
/// `dismissCurrent()` drains (3 0 0 ms gap before the next queued banner
/// renders). If it's only in the queue, it's silently removed. No-op
/// when no banner with `id` is known.
public func dismiss(id: String) {
// Drop a queued copy (e.g. enqueued during the 300ms drain) so it
// doesn't surface after we clear the current banner.
queue.removeAll { $0.id == id }
guard let c = current, c.id == id else { return }
// NOTE: do NOT add to `dismissedThisLaunch`. Programmatic dismiss
// means the condition was resolved (e.g. TCC re-granted); if it
// recurs (user revokes again), we need to be able to re-enqueue.
// The user-driven `dismissCurrent()` path remembers dismissal
// because the user explicitly clicked away from a banner they
// accepted; the programmatic path is the opposite intent.
current = nil
panel.orderOut(nil)
drainWorkItem?.cancel()
drainWorkItem = nil
guard !queue.isEmpty else { return }
let next = queue.removeFirst()
let item = DispatchWorkItem { [weak self] in
guard let self else { return }
if self.current != nil { return }
if self.dismissedThisLaunch.contains(next.id) { return }
self.showBanner(next)
}
drainWorkItem = item
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: item)
}
/// Clear everything — used when the entire app is about to quit or when
/// a test needs to reset the coordinator.
public func clear() {
+107 -13
View File
@@ -23,8 +23,31 @@ struct SettingsView: View {
let validator: AnthropicKeyValidator
let onGrantInputMonitoring: () -> Bool
let onHotkeyChanged: (Shell.KeyboardShortcut?) -> Void
/// Issue #87 — fires when the user binds, clears, or changes the PTT
/// hotkey. Defaults to a no-op so existing callers that don't pass
/// this still compile.
let onPTTHotkeyChanged: (Shell.KeyboardShortcut?) -> Void
let onClose: () -> Void
init(
state: WizardState,
keychain: any KeychainStore,
validator: AnthropicKeyValidator,
onGrantInputMonitoring: @escaping () -> Bool,
onHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void,
onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in },
onClose: @escaping () -> Void
) {
self.state = state
self.keychain = keychain
self.validator = validator
self.onGrantInputMonitoring = onGrantInputMonitoring
self.onHotkeyChanged = onHotkeyChanged
self.onPTTHotkeyChanged = onPTTHotkeyChanged
self.onClose = onClose
}
@State private var apiKeyDraft: String = ""
@State private var apiKeyStatus: APIKeyStatus = .unchanged
@State private var apiKeyEditing: Bool = false
@@ -32,6 +55,12 @@ struct SettingsView: View {
@State private var hotkeyDraft: Shell.KeyboardShortcut?
@State private var hotkeyError: String?
/// Issue #87 — PTT hotkey draft, mirrored from `state.pttHotkey` on
/// `onAppear`. Separate from `hotkeyDraft` so binding one doesn't
/// clobber the other.
@State private var pttHotkeyDraft: Shell.KeyboardShortcut?
@State private var pttHotkeyError: String?
enum APIKeyStatus: Equatable {
case unchanged
case validating
@@ -41,30 +70,35 @@ struct SettingsView: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text("Settings").font(.system(size: 20, weight: .semibold))
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Settings").font(.system(size: 20, weight: .semibold))
apiKeySection
Divider()
permissionsSection
Divider()
hotkeySection
apiKeySection
Divider()
permissionsSection
Divider()
hotkeySection
Divider()
pttHotkeySection
Spacer()
HStack {
Spacer()
Button("Done") { onClose() }
.keyboardShortcut(.defaultAction)
HStack {
Spacer()
Button("Done") { onClose() }
.keyboardShortcut(.defaultAction)
}
}
.padding(24)
}
.padding(24)
.frame(width: 520, height: 540)
.frame(width: 520, height: 620)
.onAppear {
// Refresh truth on each open — same reasoning as the wizard's
// refresh on open: TCC grants and Keychain entries can change
// between sessions.
state.refresh()
hotkeyDraft = state.hotkey
pttHotkeyDraft = state.pttHotkey
}
}
@@ -282,6 +316,66 @@ struct SettingsView: View {
}
}
// MARK: - PTT hotkey (#87 / VOICE-13)
/// Push-to-talk shortcut. Ships unbound — the user must explicitly
/// pick one. Independent of the summon-Jarvis hotkey above; bound to
/// `PushToTalk` via AppDelegate's `rebindPTTFromState`.
private var pttHotkeySection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionHeader(
"Push-to-talk hotkey",
subtitle: "Hold to talk; release to submit. Bypasses the wake word. Ships unbound — pick a combo that doesn't collide with your other apps (Cmd+Shift+J, Option+Space are taken on most setups)."
)
HStack(spacing: 8) {
ShortcutRecorderView(
shortcut: $pttHotkeyDraft,
errorMessage: $pttHotkeyError
)
.frame(height: 36)
.frame(maxWidth: .infinity)
if pttHotkeyDraft != nil {
Button("Save") {
state.pttHotkey = pttHotkeyDraft
onPTTHotkeyChanged(pttHotkeyDraft)
}
.disabled(pttHotkeyDraft == state.pttHotkey)
}
if state.pttHotkey != nil {
Button("Clear") {
state.pttHotkey = nil
pttHotkeyDraft = nil
onPTTHotkeyChanged(nil)
}
.foregroundColor(Color(NSColor.systemRed))
}
}
if let s = state.pttHotkey {
Text("Current: \(s.displayString)")
.font(.system(size: 12).monospaced())
.foregroundColor(Color(NSColor.secondaryLabelColor))
} else {
Text("No PTT hotkey bound — voice activates via wake word only.")
.font(.system(size: 12))
.foregroundColor(Color(NSColor.secondaryLabelColor))
}
if let err = pttHotkeyError {
Label(err, systemImage: "exclamationmark.triangle")
.font(.system(size: 12))
.foregroundColor(Color(NSColor.systemOrange))
}
if let warn = pttHotkeyDraft.flatMap(CollisionDetector.check) {
Label(warn.message, systemImage: "exclamationmark.triangle.fill")
.font(.system(size: 12))
.foregroundColor(Color(NSColor.systemOrange))
}
}
}
// MARK: - Helpers
private func sectionHeader(_ title: String, subtitle: String) -> some View {
+3 -1
View File
@@ -30,7 +30,8 @@ public final class SettingsWindowController {
keychain: any KeychainStore,
validator: AnthropicKeyValidator,
onGrantInputMonitoring: @escaping () -> Bool,
onHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void
onHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void,
onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in }
) {
// Refresh truth before view creation: TCC grants and Keychain entries
// can change between sessions, same reasoning as the wizard's
@@ -49,6 +50,7 @@ public final class SettingsWindowController {
validator: validator,
onGrantInputMonitoring: onGrantInputMonitoring,
onHotkeyChanged: onHotkeyChanged,
onPTTHotkeyChanged: onPTTHotkeyChanged,
onClose: { [weak self] in self?.close() }
)
let hosting = NSHostingController(rootView: rootView)
@@ -143,4 +143,59 @@ final class HUDBannerCoordinatorTests: XCTestCase {
}
wait(for: [exp], timeout: 2.0)
}
// MARK: - #88 / SHELL-06 — programmatic dismiss(id:)
/// #88: `dismiss(id:)` clears a non-dismissible banner (used by
/// AppDelegate's foreground re-probe when TCC was re-granted).
func test_dismissByIdClearsNonDismissibleBanner() {
let panel = HUDBannerPanel()
let coordinator = HUDBannerCoordinator(panel: panel)
coordinator.enqueue(.inputMonitoringDenied)
XCTAssertEqual(coordinator.currentBanner?.id, "input-monitoring-denied")
// User-triggered dismiss must NOT clear a non-dismissible banner.
coordinator.dismissCurrent()
XCTAssertEqual(
coordinator.currentBanner?.id,
"input-monitoring-denied",
"non-dismissible banner must ignore user dismissCurrent"
)
// Programmatic dismiss-by-id MUST clear it.
coordinator.dismiss(id: "input-monitoring-denied")
XCTAssertNil(
coordinator.currentBanner,
"#88: dismiss(id:) must clear a non-dismissible banner when condition resolved"
)
}
/// #88: `dismiss(id:)` must NOT mark the id as dismissed-this-launch
/// — the condition can recur (user revokes again) and we need to be
/// able to re-enqueue.
func test_dismissByIdAllowsReEnqueueIfConditionRecurs() {
let panel = HUDBannerPanel()
let coordinator = HUDBannerCoordinator(panel: panel)
coordinator.enqueue(.inputMonitoringDenied)
coordinator.dismiss(id: "input-monitoring-denied")
XCTAssertNil(coordinator.currentBanner)
// Condition recurs (re-probe finds denial again).
coordinator.enqueue(.inputMonitoringDenied)
XCTAssertEqual(
coordinator.currentBanner?.id,
"input-monitoring-denied",
"#88: programmatic dismiss must NOT poison the id — re-enqueue must work"
)
}
/// #88: `dismiss(id:)` on an unknown id is a no-op.
func test_dismissByIdUnknownIsNoop() {
let panel = HUDBannerPanel()
let coordinator = HUDBannerCoordinator(panel: panel)
coordinator.enqueue(.keychainEmpty)
coordinator.dismiss(id: "nonexistent")
XCTAssertEqual(
coordinator.currentBanner?.id,
"keychain-empty",
"#88: dismiss(id:) on unknown id must not affect current banner"
)
}
}
+55
View File
@@ -42,4 +42,59 @@ final class WizardStateTests: XCTestCase {
state.hotkey = Shell.KeyboardShortcut(keyCode: 38, modifiers: [.command, .shift])
XCTAssertEqual(state.firstUnresolvedStage, .complete)
}
// MARK: - #87 / VOICE-13 — PTT hotkey persistence
/// PTT hotkey defaults to nil (ships unbound per CLAUDE.md hotkey
/// hygiene — Cmd+Shift+J collides with Chrome/Slack/VSCode and
/// Option+Space collides with Alfred/Raycast).
func test_pttHotkeyDefaultsToNil() {
let defaults = UserDefaults(suiteName: "PTTHotkeyTest-\(UUID().uuidString)")!
defaults.removeObject(forKey: "Jarvis.pttHotkey")
let state = WizardState(
keychain: FakeKeychain(apiKeyStored: false),
userDefaults: defaults
)
XCTAssertNil(state.pttHotkey, "#87: PTT hotkey ships unbound")
}
/// PTT hotkey persists to UserDefaults under its own key, independent
/// of the summon-Jarvis hotkey.
func test_pttHotkeyPersistsAndReloads() {
let suite = "PTTHotkeyTest-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
let shortcut = Shell.KeyboardShortcut(keyCode: 49, modifiers: [.control, .option])
let writer = WizardState(
keychain: FakeKeychain(apiKeyStored: true),
userDefaults: defaults
)
writer.pttHotkey = shortcut
// Distinct WizardState instance reads the same UserDefaults suite
// and must see the persisted PTT hotkey.
let reader = WizardState(
keychain: FakeKeychain(apiKeyStored: true),
userDefaults: defaults
)
XCTAssertEqual(reader.pttHotkey, shortcut, "#87: PTT hotkey must round-trip through UserDefaults")
}
/// PTT and summon-Jarvis hotkeys are independent — clearing one does
/// not affect the other.
func test_pttHotkeyIsIndependentOfSummonHotkey() {
let suite = "PTTHotkeyTest-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
let state = WizardState(
keychain: FakeKeychain(apiKeyStored: true),
userDefaults: defaults
)
let summon = Shell.KeyboardShortcut(keyCode: 38, modifiers: [.command, .shift])
let ptt = Shell.KeyboardShortcut(keyCode: 49, modifiers: [.control, .option])
state.hotkey = summon
state.pttHotkey = ptt
state.hotkey = nil
XCTAssertNil(state.hotkey)
XCTAssertEqual(state.pttHotkey, ptt, "#87: clearing summon hotkey must not affect PTT hotkey")
}
}
+23 -7
View File
@@ -29,7 +29,20 @@ public final class WizardState: ObservableObject {
@Published public var inputMonitoringProbed: Bool = false
@Published public var inputMonitoringGranted: Bool = false
@Published public var hotkey: Shell.KeyboardShortcut? {
didSet { persistHotkey(hotkey) }
didSet { persistHotkey(hotkey, key: Self.hotkeyDefaultsKey) }
}
/// Issue #87 / VOICE-13 — push-to-talk hotkey. Ships unbound (nil) by
/// default; the user binds one via Settings. Persisted under a
/// distinct UserDefaults key from `hotkey` so the summon-Jarvis
/// shortcut and the PTT shortcut are independent.
///
/// No default value: CLAUDE.md hotkey-hygiene guidance forbids
/// pre-binding because Cmd+Shift+J collides with Chrome/Slack/VSCode
/// and Option+Space collides with Alfred/Raycast. The user picks one
/// that doesn't conflict with their setup.
@Published public var pttHotkey: Shell.KeyboardShortcut? {
didSet { persistHotkey(pttHotkey, key: Self.pttHotkeyDefaultsKey) }
}
private let keychain: any KeychainStore
@@ -39,6 +52,8 @@ public final class WizardState: ObservableObject {
/// UserDefaults key for the persisted hotkey. JSON-encoded blob under
/// the standard registered domain so it survives quit/reopen.
private static let hotkeyDefaultsKey = "Jarvis.hotkey"
/// Issue #87: PTT hotkey lives under a separate key from `hotkey`.
private static let pttHotkeyDefaultsKey = "Jarvis.pttHotkey"
public init(
keychain: any KeychainStore = SystemKeychainStore(),
@@ -69,7 +84,8 @@ public final class WizardState: ObservableObject {
inputMonitoringProbed = true
}
hotkey = loadPersistedHotkey()
hotkey = loadPersistedHotkey(key: Self.hotkeyDefaultsKey)
pttHotkey = loadPersistedHotkey(key: Self.pttHotkeyDefaultsKey)
currentStage = firstUnresolvedStage
}
@@ -83,20 +99,20 @@ public final class WizardState: ObservableObject {
// MARK: - Hotkey persistence
private func loadPersistedHotkey() -> Shell.KeyboardShortcut? {
guard let data = userDefaults.data(forKey: Self.hotkeyDefaultsKey) else {
private func loadPersistedHotkey(key: String) -> Shell.KeyboardShortcut? {
guard let data = userDefaults.data(forKey: key) else {
return nil
}
return try? JSONDecoder().decode(Shell.KeyboardShortcut.self, from: data)
}
private func persistHotkey(_ shortcut: Shell.KeyboardShortcut?) {
private func persistHotkey(_ shortcut: Shell.KeyboardShortcut?, key: String) {
guard let shortcut else {
userDefaults.removeObject(forKey: Self.hotkeyDefaultsKey)
userDefaults.removeObject(forKey: key)
return
}
if let data = try? JSONEncoder().encode(shortcut) {
userDefaults.set(data, forKey: Self.hotkeyDefaultsKey)
userDefaults.set(data, forKey: key)
}
}
}
@@ -40,12 +40,19 @@ public struct DevOverlayView: View {
// pattern for read-only observation of an injected model.
public let viewModel: DevOverlayViewModel
public init(viewModel: DevOverlayViewModel) {
/// Initial selected tab. Tag indices match the `.tag(_:)` calls below:
/// 0 = Agent, 1 = Tools, 2 = Health, 3 = Logs. Issue #89 / v0.1 startup
/// flow opens straight to Logs so the operator sees activity stream on
/// first launch instead of a blank Agent tab.
@State private var selection: Int
public init(viewModel: DevOverlayViewModel, initialTab: Int = 0) {
self.viewModel = viewModel
_selection = State(initialValue: initialTab)
}
public var body: some View {
TabView {
TabView(selection: $selection) {
AgentPane(viewModel: viewModel)
.tabItem { Label("Agent", systemImage: "brain") }
.tag(0)
@@ -71,7 +71,8 @@ public final class DevOverlayWindow {
emitter: DevSnapshotEmitter?,
bootHealthOrchestrator: BootHealthOrchestrator? = nil,
hudStateReader: (@MainActor () -> String)? = nil,
viewModel: DevOverlayViewModel = DevOverlayViewModel()
viewModel: DevOverlayViewModel = DevOverlayViewModel(),
initialTab: Int = 0
) {
self.viewModel = viewModel
self.bridge = DevOverlayBridge(viewModel: viewModel)
@@ -98,7 +99,9 @@ public final class DevOverlayWindow {
p.hidesOnDeactivate = false
p.title = "Jarvis DevOverlay"
p.isReleasedWhenClosed = false
p.contentView = NSHostingView(rootView: DevOverlayView(viewModel: viewModel))
p.contentView = NSHostingView(
rootView: DevOverlayView(viewModel: viewModel, initialTab: initialTab)
)
p.orderOut(nil)
self.panel = p
@@ -133,6 +136,18 @@ public final class DevOverlayWindow {
if isVisible { hide() } else { show() }
}
/// Issue #89: AppDelegate positions Status + DevOverlay non-overlapping
/// on launch. Exposes the underlying `NSPanel.setFrameOrigin` without
/// leaking the panel itself (KVC on the private `panel` field would
/// work but couples callers to the property name).
public func setFrameOrigin(_ origin: NSPoint) {
panel.setFrameOrigin(origin)
}
/// Read-only access to the panel frame size so callers can compute an
/// inset-from-screen-edge origin without poking at private state.
public var frameSize: NSSize { panel.frame.size }
// MARK: - Polling (BootHealth + HudState)
/// 1 Hz read loop. BootHealth is queried via `lastSnapshot()` (no