diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift index 471690c..de01dfa 100644 --- a/App/AppDelegate.swift +++ b/App/AppDelegate.swift @@ -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 diff --git a/App/HUD/BannerContent.swift b/App/HUD/BannerContent.swift index 5429f91..3df71d5 100644 --- a/App/HUD/BannerContent.swift +++ b/App/HUD/BannerContent.swift @@ -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. diff --git a/App/HUD/HUDBannerCoordinator.swift b/App/HUD/HUDBannerCoordinator.swift index e32cc39..3afaf0b 100644 --- a/App/HUD/HUDBannerCoordinator.swift +++ b/App/HUD/HUDBannerCoordinator.swift @@ -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() { diff --git a/App/Settings/SettingsView.swift b/App/Settings/SettingsView.swift index 22f6f31..cb9bdbd 100644 --- a/App/Settings/SettingsView.swift +++ b/App/Settings/SettingsView.swift @@ -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 { diff --git a/App/Settings/SettingsWindowController.swift b/App/Settings/SettingsWindowController.swift index 619bb32..2afdae2 100644 --- a/App/Settings/SettingsWindowController.swift +++ b/App/Settings/SettingsWindowController.swift @@ -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) diff --git a/App/Tests/AppTests/HUDBannerCoordinatorTests.swift b/App/Tests/AppTests/HUDBannerCoordinatorTests.swift index 8753757..97f7021 100644 --- a/App/Tests/AppTests/HUDBannerCoordinatorTests.swift +++ b/App/Tests/AppTests/HUDBannerCoordinatorTests.swift @@ -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" + ) + } } diff --git a/App/Tests/AppTests/WizardStateTests.swift b/App/Tests/AppTests/WizardStateTests.swift index 5e0b9c7..a17ccab 100644 --- a/App/Tests/AppTests/WizardStateTests.swift +++ b/App/Tests/AppTests/WizardStateTests.swift @@ -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") + } } diff --git a/App/Wizard/WizardState.swift b/App/Wizard/WizardState.swift index fadaa2d..eccc29b 100644 --- a/App/Wizard/WizardState.swift +++ b/App/Wizard/WizardState.swift @@ -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) } } } diff --git a/packages/DevOverlay/Sources/DevOverlay/DevOverlayView.swift b/packages/DevOverlay/Sources/DevOverlay/DevOverlayView.swift index 06c0cc0..d066c02 100644 --- a/packages/DevOverlay/Sources/DevOverlay/DevOverlayView.swift +++ b/packages/DevOverlay/Sources/DevOverlay/DevOverlayView.swift @@ -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) diff --git a/packages/DevOverlay/Sources/DevOverlay/DevOverlayWindow.swift b/packages/DevOverlay/Sources/DevOverlay/DevOverlayWindow.swift index a03bcfe..44df95f 100644 --- a/packages/DevOverlay/Sources/DevOverlay/DevOverlayWindow.swift +++ b/packages/DevOverlay/Sources/DevOverlay/DevOverlayWindow.swift @@ -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