feat(app): v0.1 UX surfaces — startup panels + InputMonitoring banner + PTT #156
No files matched your search
+203
-15
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user