From 462fe197ca970293bb6eb56b1ad40d0f6ab42392 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Mon, 13 Jul 2026 16:14:42 +0100 Subject: [PATCH 1/3] feat(voice): global "speak selection" hotkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A configurable hotkey (STACKNUDGE_SPEAK_HOTKEY, default cmd+opt+s) reads the current selection aloud via stackvox — in any app, not just the browser. - SpeakSelection: captures the focused element's AX selected-text, falling back to synthesizing Cmd-C + reading the pasteboard (then restoring it) for apps that don't expose AX. Speaks via Speaker with --normalize; re-pressing (or pressing with nothing selected) cancels the current read. - Hotkey: parameterize the hotkey id so a second global hotkey can register, and gate each handler on its own id (handlers on the app target see all hotkey events). - Speaker: add a --normalize option and cancel() (talks to the daemon socket via nc, so it works regardless of installed CLI version; no-op if the daemon predates `cancel`). - Config: STACKNUDGE_SPEAK_HOTKEY. Co-Authored-By: Claude Opus 4.8 (1M context) --- panel/Config.swift | 2 + panel/Hotkey.swift | 20 ++++++++-- panel/Panel.swift | 2 + panel/SpeakSelection.swift | 79 ++++++++++++++++++++++++++++++++++++++ panel/Speaker.swift | 29 +++++++++++++- 5 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 panel/SpeakSelection.swift diff --git a/panel/Config.swift b/panel/Config.swift index d5d3719..68a844c 100644 --- a/panel/Config.swift +++ b/panel/Config.swift @@ -5,6 +5,7 @@ import Foundation // notify.sh shell-sources it; we just need the subset relevant to the panel. struct PanelConfig { var hotkeySpec: String = "cmd+opt+n" + var speakSelectionHotkeySpec: String = "cmd+opt+s" var bannerEnabled: Bool = true var soundEnabled: Bool = true var activateImmediately: Bool = false @@ -28,6 +29,7 @@ struct PanelConfig { .trimmingCharacters(in: .whitespaces)) switch key { case "STACKNUDGE_PANEL_HOTKEY": config.hotkeySpec = value + case "STACKNUDGE_SPEAK_HOTKEY": config.speakSelectionHotkeySpec = value case "STACKNUDGE_BANNER": config.bannerEnabled = value.lowercased() != "false" case "STACKNUDGE_SOUND": config.soundEnabled = value.lowercased() != "false" case "STACKNUDGE_ACTIVATE_IMMEDIATELY": config.activateImmediately = value.lowercased() == "true" diff --git a/panel/Hotkey.swift b/panel/Hotkey.swift index 2a73089..9a45182 100644 --- a/panel/Hotkey.swift +++ b/panel/Hotkey.swift @@ -9,10 +9,12 @@ final class Hotkey { private var hotKeyRef: EventHotKeyRef? private var handlerRef: EventHandlerRef? private let onTrigger: () -> Void + private let hotKeyIDValue: UInt32 - init?(spec: String, onTrigger: @escaping () -> Void) { + init?(spec: String, id: UInt32 = 1, onTrigger: @escaping () -> Void) { guard let parsed = Hotkey.parse(spec) else { return nil } self.onTrigger = onTrigger + self.hotKeyIDValue = id guard install(modifiers: parsed.modifiers, keyCode: parsed.keyCode) else { return nil } @@ -24,7 +26,7 @@ final class Hotkey { } private func install(modifiers: UInt32, keyCode: UInt32) -> Bool { - let hotKeyID = EventHotKeyID(signature: OSType(0x534E4447), id: 1) // 'SNDG' + let hotKeyID = EventHotKeyID(signature: OSType(0x534E4447), id: hotKeyIDValue) // 'SNDG' let regStatus = RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef) @@ -35,10 +37,20 @@ final class Hotkey { let context = Unmanaged.passUnretained(self).toOpaque() let installStatus = InstallEventHandler( GetApplicationEventTarget(), - { _, _, ctx in + { _, event, ctx in guard let ctx = ctx else { return noErr } let me = Unmanaged.fromOpaque(ctx).takeUnretainedValue() - DispatchQueue.main.async { me.onTrigger() } + // Every handler installed on the app target sees all hotkey + // events, so only fire for this instance's own id. + var pressed = EventHotKeyID() + if let event = event { + GetEventParameter(event, EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), nil, + MemoryLayout.size, nil, &pressed) + } + if pressed.id == me.hotKeyIDValue { + DispatchQueue.main.async { me.onTrigger() } + } return noErr }, 1, &eventType, context, &handlerRef diff --git a/panel/Panel.swift b/panel/Panel.swift index 522c404..956f2eb 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -526,6 +526,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // smaller-radius rect corners poke past the SwiftUI capsule curve. private weak var contentBlurView: NSVisualEffectView? private var hotkey: Hotkey? + private var speakHotkey: Hotkey? private let store = EventStore() private let sessions = SessionStore() let nav = PanelNav() @@ -647,6 +648,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, let config = PanelConfig.load() nav.hotkeyDisplay = config.hotkeySpec _ = registerHotkey(spec: config.hotkeySpec) + speakHotkey = Hotkey(spec: config.speakSelectionHotkeySpec, id: 2) { SpeakSelection.trigger() } startListener() menuBar = MenuBarController(panelController: self) diff --git a/panel/SpeakSelection.swift b/panel/SpeakSelection.swift new file mode 100644 index 0000000..d1b585d --- /dev/null +++ b/panel/SpeakSelection.swift @@ -0,0 +1,79 @@ +import AppKit +import ApplicationServices + +// Global "speak the current selection" action, driven by a hotkey. Reads the +// focused element's selected text via the Accessibility API; when that's empty +// (some apps don't expose it), falls back to synthesizing ⌘C and reading the +// pasteboard, restoring the original clipboard afterwards. The text is spoken +// through the stackvox daemon. Pressing the hotkey again interrupts the current +// read; pressing it with nothing selected just stops. +enum SpeakSelection { + + static func trigger() { + let text = selectedText()?.trimmingCharacters(in: .whitespacesAndNewlines) + Speaker.cancel() // interrupt whatever's currently playing + guard let text, !text.isEmpty else { return } // nothing selected → acts as stop + Speaker.speak(text, normalize: true) + } + + // MARK: - Selection capture + + private static func selectedText() -> String? { + if let ax = axSelectedText(), !ax.isEmpty { return ax } + return clipboardSelectedText() + } + + /// kAXSelectedTextAttribute on the system-wide focused UI element. + private static func axSelectedText() -> String? { + let system = AXUIElementCreateSystemWide() + var focused: AnyObject? + guard AXUIElementCopyAttributeValue( + system, kAXFocusedUIElementAttribute as CFString, &focused) == .success, + let focused, + CFGetTypeID(focused) == AXUIElementGetTypeID() + else { return nil } + + let element = focused as! AXUIElement + var value: AnyObject? + guard AXUIElementCopyAttributeValue( + element, kAXSelectedTextAttribute as CFString, &value) == .success + else { return nil } + return value as? String + } + + /// Fallback: save the clipboard, synthesize ⌘C, read the copied text, then + /// restore the original clipboard so we don't clobber it. + private static func clipboardSelectedText() -> String? { + let pasteboard = NSPasteboard.general + let saved = pasteboard.string(forType: .string) + let beforeCount = pasteboard.changeCount + + sendCommandC() + + // Poll briefly for the copy to land, then read it. + var copied: String? + let deadline = Date().addingTimeInterval(0.4) + while Date() < deadline { + if pasteboard.changeCount != beforeCount { + copied = pasteboard.string(forType: .string) + break + } + usleep(15_000) + } + + pasteboard.clearContents() + if let saved { pasteboard.setString(saved, forType: .string) } + return copied + } + + private static func sendCommandC() { + let source = CGEventSource(stateID: .combinedSessionState) + let cKey: CGKeyCode = 8 // 'c' + let down = CGEvent(keyboardEventSource: source, virtualKey: cKey, keyDown: true) + down?.flags = .maskCommand + let up = CGEvent(keyboardEventSource: source, virtualKey: cKey, keyDown: false) + up?.flags = .maskCommand + down?.post(tap: .cghidEventTap) + up?.post(tap: .cghidEventTap) + } +} diff --git a/panel/Speaker.swift b/panel/Speaker.swift index aa562ac..9db43c3 100644 --- a/panel/Speaker.swift +++ b/panel/Speaker.swift @@ -14,7 +14,7 @@ enum Speaker { // shebang pointing at /Users/runner/... that doesn't exist on user // machines. Always go through `python3 ` so the shebang // is bypassed and python3's own binary (a real Mach-O) handles loading. - static func speak(_ text: String, voice: String? = nil, speed: String? = nil) { + static func speak(_ text: String, voice: String? = nil, speed: String? = nil, normalize: Bool = false) { let venvBin = "\(NSHomeDirectory())/.stack-nudge/venv/bin" let python = "\(venvBin)/python3" let stackvox = "\(venvBin)/stackvox" @@ -41,7 +41,10 @@ enum Speaker { let resolvedSpeed = speed ?? config["STACKNUDGE_VOICE_SPEED"] ?? "1.1" let say = Process() say.executableURL = URL(fileURLWithPath: python) - say.arguments = [stackvox, "say", "--voice", resolvedVoice, "--speed", resolvedSpeed, text] + var sayArgs = [stackvox, "say", "--voice", resolvedVoice, "--speed", resolvedSpeed] + if normalize { sayArgs += ["--normalize", "--no-markdown"] } + sayArgs.append(text) + say.arguments = sayArgs say.environment = env say.standardOutput = FileHandle.nullDevice say.standardError = FileHandle.nullDevice @@ -59,6 +62,28 @@ enum Speaker { } } + // Tell the daemon to stop the current utterance (and drop its queue) without + // shutting it down. Talks to the socket directly so it works regardless of + // the installed CLI version; a no-op if the daemon predates `cancel`. + static func cancel() { + let socketPath = "\(NSHomeDirectory())/.cache/stackvox/daemon.sock" + guard FileManager.default.fileExists(atPath: socketPath) else { return } + let nc = Process() + nc.executableURL = URL(fileURLWithPath: "/usr/bin/nc") + nc.arguments = ["-U", "-w", "2", socketPath] + let stdin = Pipe() + nc.standardInput = stdin + nc.standardOutput = FileHandle.nullDevice + nc.standardError = FileHandle.nullDevice + do { + try nc.run() + stdin.fileHandleForWriting.write(Data("{\"command\":\"cancel\"}\n".utf8)) + stdin.fileHandleForWriting.closeFile() + } catch { + // best-effort + } + } + // Play a /System/Library/Sounds/*.aiff chime. The afplay path is identical // to what notify.sh used; we keep it as a Process call (rather than NSSound) // because afplay terminates on app quit — fixing the "bell keeps ringing From bcdab234aae860f11a967d9e1eb7459d6b1444e2 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Mon, 13 Jul 2026 16:39:32 +0100 Subject: [PATCH 2/3] fix(voice): don't let one hotkey handler swallow the other's events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The id-gating handler returned noErr even on a non-matching id. In Carbon that marks the event handled and stops chain propagation, so with two hotkeys registered whichever handler ran first consumed the event and the other hotkey (e.g. ⌘⌥N to open the panel) stopped firing. Return eventNotHandledErr on a non-match so the event reaches the handler whose id matches. Co-Authored-By: Claude Opus 4.8 (1M context) --- panel/Hotkey.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/panel/Hotkey.swift b/panel/Hotkey.swift index 9a45182..effffd7 100644 --- a/panel/Hotkey.swift +++ b/panel/Hotkey.swift @@ -48,9 +48,13 @@ final class Hotkey { EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &pressed) } - if pressed.id == me.hotKeyIDValue { - DispatchQueue.main.async { me.onTrigger() } + guard pressed.id == me.hotKeyIDValue else { + // Not ours — pass it down the handler chain so the other + // hotkey still fires. Returning noErr here would mark the + // event handled and swallow it. + return OSStatus(eventNotHandledErr) } + DispatchQueue.main.async { me.onTrigger() } return noErr }, 1, &eventType, context, &handlerRef From b19e9bc87e45d535b382f3a913aebd21ca0962f7 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Mon, 13 Jul 2026 17:12:36 +0100 Subject: [PATCH 3/3] feat(voice): add the read-aloud shortcut to the Settings Voice section Surface and configure the speak-selection hotkey in Settings, mirroring the panel-hotkey recorder: a "Read aloud shortcut" row in the Voice section shows the current binding and records a new one (persists STACKNUDGE_SPEAK_HOTKEY, re-registers via setSpeakHotkey). - SettingsRow.speakHotkey + placement in the Voice group. - Recorder state (recordingSpeakHotkey / speakHotkeyDisplay / speakHotkeyError) and start/cancel/commit, mirroring the panel hotkey. - Panel key-capture routes the recorded combo to whichever hotkey is recording; registerSpeakHotkey re-registers the id:2 global hotkey. Co-Authored-By: Claude Opus 4.8 (1M context) --- panel/Panel.swift | 28 ++++++++++++++++++++++------ panel/PanelNav.swift | 33 +++++++++++++++++++++++++++++++-- panel/Settings.swift | 11 ++++++++++- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/panel/Panel.swift b/panel/Panel.swift index 956f2eb..d1bcde8 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -648,7 +648,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, let config = PanelConfig.load() nav.hotkeyDisplay = config.hotkeySpec _ = registerHotkey(spec: config.hotkeySpec) - speakHotkey = Hotkey(spec: config.speakSelectionHotkeySpec, id: 2) { SpeakSelection.trigger() } + nav.speakHotkeyDisplay = config.speakSelectionHotkeySpec + _ = registerSpeakHotkey(spec: config.speakSelectionHotkeySpec) startListener() menuBar = MenuBarController(panelController: self) @@ -691,6 +692,9 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, nav.setHotkey = { [weak self] spec in self?.registerHotkey(spec: spec) ?? false } + nav.setSpeakHotkey = { [weak self] spec in + self?.registerSpeakHotkey(spec: spec) ?? false + } nav.refreshOutcomes = { [weak self] in self?.refreshOutcomes() } nav.refreshPullRequests = { [weak self] in self?.refreshPullRequests() } nav.startGithubSignIn = { [weak self] in self?.startGithubSignIn() } @@ -2120,6 +2124,17 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return true } + private func registerSpeakHotkey(spec: String) -> Bool { + let new = Hotkey(spec: spec, id: 2) { SpeakSelection.trigger() } + guard let new else { + FileHandle.standardError.write(Data( + "stack-nudge-panel: failed to register speak hotkey '\(spec)'\n".utf8)) + return false + } + speakHotkey = new // releasing the old instance unregisters it via deinit + return true + } + // MARK: - Config file watcher @@ -2256,19 +2271,20 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // While recording a hotkey, capture the next combo. Arrow keys / Tab // bail out gracefully — otherwise users who entered record mode by // mistake would be stuck on row 0 with all their keypresses swallowed. - if nav.recordingHotkey { + if nav.recordingHotkey || nav.recordingSpeakHotkey { + let recordingSpeak = nav.recordingSpeakHotkey let plainNav = mods.intersection([.command, .control, .option, .shift]).isEmpty if plainNav { switch event.keyCode { case KeyCode.escape: - nav.cancelRecordingHotkey() + if recordingSpeak { nav.cancelRecordingSpeakHotkey() } else { nav.cancelRecordingHotkey() } return true case KeyCode.upArrow: - nav.cancelRecordingHotkey() + if recordingSpeak { nav.cancelRecordingSpeakHotkey() } else { nav.cancelRecordingHotkey() } nav.selectPrevRow() return true case KeyCode.downArrow, KeyCode.tab: - nav.cancelRecordingHotkey() + if recordingSpeak { nav.cancelRecordingSpeakHotkey() } else { nav.cancelRecordingHotkey() } nav.selectNextRow() return true default: @@ -2280,7 +2296,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // bare keys, and we don't want to bind plain "n" by accident. guard !captured.isEmpty else { return true } if let spec = Hotkey.encode(eventModifiers: mods.rawValue, keyCode: event.keyCode) { - nav.commitHotkey(spec) + if recordingSpeak { nav.commitSpeakHotkey(spec) } else { nav.commitHotkey(spec) } } return true } diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 9759af4..e29b3b0 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -138,7 +138,7 @@ enum SettingsRow: Hashable { case banner, muteWhenFocused, pinPanel, keepOpenWhenEmpty, launchAtLogin case widget, widgetCorner, widgetOpacity, widgetContent, mascot, theme case soundEnabled, agentDoneSound, permissionSound - case voiceEnabled, voice, voiceSpeed, downloadVoiceModel + case voiceEnabled, voice, voiceSpeed, speakHotkey, downloadVoiceModel case quotaTracking, quotaAlerts, alertThreshold, pollFrequency, contextAlert, showRemaining case githubLinks, hideShipped, disconnectGithub case historyPerSession @@ -175,6 +175,9 @@ final class PanelNav: ObservableObject { @Published var hotkeyDisplay: String = "cmd+opt+n" @Published var recordingHotkey: Bool = false @Published var hotkeyError: String? + @Published var speakHotkeyDisplay: String = "cmd+opt+s" + @Published var recordingSpeakHotkey: Bool = false + @Published var speakHotkeyError: String? @Published var bannerEnabled: Bool = true @Published var soundEnabled: Bool = true @Published var voiceEnabled: Bool = false @@ -578,6 +581,7 @@ final class PanelNav: ObservableObject { // without owning the Hotkey instance directly. Returns true if the // new spec registered successfully. var setHotkey: ((String) -> Bool)? + var setSpeakHotkey: ((String) -> Bool)? // Selectable thresholds for the Usage "Alert threshold" cycle row. // Sorted ascending so left/right arrows feel intuitive. @@ -624,7 +628,7 @@ final class PanelNav: ObservableObject { .banner, .muteWhenFocused, .pinPanel, .keepOpenWhenEmpty, .launchAtLogin, .widget, .widgetCorner, .widgetOpacity, .widgetContent, .mascot, .theme, .soundEnabled, .agentDoneSound, .permissionSound, - .voiceEnabled] + .voiceEnabled, .speakHotkey] rows += voiceModelCached ? [.voice, .voiceSpeed] : [.downloadVoiceModel] rows += [.quotaTracking, .quotaAlerts, .alertThreshold, .pollFrequency, .contextAlert, .showRemaining, .githubLinks, .hideShipped, .disconnectGithub, @@ -665,6 +669,7 @@ final class PanelNav: ObservableObject { func loadFromConfig() { let config = ConfigFile.read() hotkeyDisplay = config["STACKNUDGE_PANEL_HOTKEY"] ?? "cmd+opt+n" + speakHotkeyDisplay = config["STACKNUDGE_SPEAK_HOTKEY"] ?? "cmd+opt+s" bannerEnabled = ConfigFile.bool(config, "STACKNUDGE_BANNER", default: true) soundEnabled = ConfigFile.bool(config, "STACKNUDGE_SOUND", default: true) voiceEnabled = ConfigFile.bool(config, "STACKNUDGE_VOICE", default: false) @@ -919,6 +924,7 @@ final class PanelNav: ObservableObject { case .permissions: actions?.checkPermissions() case .update: actions?.beginUpdate() case .hotkey: startRecordingHotkey() + case .speakHotkey: startRecordingSpeakHotkey() case .downloadVoiceModel: // The pre-download row is an action: Enter triggers (or cancels) // the model download. @@ -990,6 +996,8 @@ final class PanelNav: ObservableObject { case .hotkey: // Cycle on the hotkey row also enters record mode. startRecordingHotkey() + case .speakHotkey: + startRecordingSpeakHotkey() case .banner: bannerEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_BANNER", value: bannerEnabled ? "true" : "false") @@ -1160,4 +1168,25 @@ final class PanelNav: ObservableObject { ConfigFile.write(key: "STACKNUDGE_PANEL_HOTKEY", value: spec) recordingHotkey = false } + + func startRecordingSpeakHotkey() { + recordingSpeakHotkey = true + speakHotkeyError = nil + } + + func cancelRecordingSpeakHotkey() { + recordingSpeakHotkey = false + } + + func commitSpeakHotkey(_ spec: String) { + guard let setSpeakHotkey, setSpeakHotkey(spec) else { + speakHotkeyError = "‘\(spec)’ is unavailable — already bound by another app" + recordingSpeakHotkey = false + return + } + speakHotkeyError = nil + speakHotkeyDisplay = spec + ConfigFile.write(key: "STACKNUDGE_SPEAK_HOTKEY", value: spec) + recordingSpeakHotkey = false + } } diff --git a/panel/Settings.swift b/panel/Settings.swift index c266e84..83053c8 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -75,6 +75,15 @@ struct SettingsView: View { section("Voice") { row(.voiceEnabled, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") + row(.speakHotkey, label: "Read aloud shortcut", kind: .cycle, + value: nav.recordingSpeakHotkey ? "Press combo…" : nav.speakHotkeyDisplay) + if let error = nav.speakHotkeyError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal, 14) + .padding(.top, 2) + } if nav.voiceModelCached { row(.voice, label: "Voice", kind: .cycle, value: voiceLabel, enabled: nav.voiceEnabled) row(.voiceSpeed, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed), enabled: nav.voiceEnabled) @@ -129,7 +138,7 @@ struct SettingsView: View { } PageFooter { - if nav.recordingHotkey { + if nav.recordingHotkey || nav.recordingSpeakHotkey { FooterHint(label: "Press a combo with ⌘ / ⇧ / ⌥ / ⌃", keys: [], primary: true) FooterHint(label: "Cancel", keys: ["Esc"]) } else {