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:
james.maesandClaude Opus 4.7 committed 2026-05-12 17:07:12 -05:00
1 parent 48fb8b9bab
commit 112f584f02
5 files changed
+240 -24

No files matched your search

+52 -3
View File
@@ -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
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)
+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)
}
}
}