feat(app): wire push-to-talk hotkey (VOICE-13) — closes #87
The push-to-talk path was fully implemented but completely unwired:
`VoiceController.pttDown()` / `pttUp()` and `PushToTalk.bind(...)` all
existed as dead code because no production caller ever bound a hotkey.
Three changes plug the gap end-to-end:
- `WizardState.pttHotkey` — persisted PTT shortcut under a separate
UserDefaults key (`Jarvis.pttHotkey`) from the summon-Jarvis hotkey.
Ships unbound (nil): CLAUDE.md hotkey-hygiene forbids pre-binding
because Cmd+Shift+J and Option+Space collide with too many tools.
- `AppDelegate.rebindPTTFromState` — pulls the persisted shortcut and
`inputMonitoringGranted` flag, then calls `PushToTalk.bind(...)`.
Called from three sites: installVoice tail (after `pushToTalk` is
constructed), the foreground TCC re-grant path (so a global monitor
is acquired under fresh TCC), and the Settings Save/Clear handlers.
- `SettingsView.pttHotkeySection` — same Save/Clear pattern as the
summon hotkey, fed by a separate `onPTTHotkeyChanged` callback to
avoid coupling the two paths.
Audio-graph isolation: PTT.bind installs `NSEvent.addGlobalMonitorFor
Events` callbacks only; the press path routes through
`VoiceController.pttDown`, which uses the audio graph the wake-word DAG
already owns. So this lane is safe to land in parallel with the Voice
CRIT branch (`fix/voice-crit-v0.1`) — no audio-graph surgery.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
48fb8b9bab
commit
112f584f02
5 files changed
+240
-24
No files matched your search
+52
-3
@@ -1550,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)
|
||||
@@ -2925,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()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -2945,6 +2956,39 @@ 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
|
||||
|
||||
@@ -3057,9 +3101,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// 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.hotkey != nil, !state.inputMonitoringGranted {
|
||||
if let state = wizardState, !state.inputMonitoringGranted {
|
||||
state.inputMonitoringGranted = true
|
||||
bindHotkeyFromWizard()
|
||||
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)
|
||||
|
||||
+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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user