Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions panel/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
22 changes: 19 additions & 3 deletions panel/Hotkey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
Expand All @@ -35,9 +37,23 @@ 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<Hotkey>.fromOpaque(ctx).takeUnretainedValue()
// 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<EventHotKeyID>.size, nil, &pressed)
}
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
},
Expand Down
28 changes: 23 additions & 5 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -647,6 +648,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
let config = PanelConfig.load()
nav.hotkeyDisplay = config.hotkeySpec
_ = registerHotkey(spec: config.hotkeySpec)
nav.speakHotkeyDisplay = config.speakSelectionHotkeySpec
_ = registerSpeakHotkey(spec: config.speakSelectionHotkeySpec)

startListener()
menuBar = MenuBarController(panelController: self)
Expand Down Expand Up @@ -689,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() }
Expand Down Expand Up @@ -2118,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

Expand Down Expand Up @@ -2254,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:
Expand All @@ -2278,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
}
Expand Down
33 changes: 31 additions & 2 deletions panel/PanelNav.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
}
11 changes: 10 additions & 1 deletion panel/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
79 changes: 79 additions & 0 deletions panel/SpeakSelection.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
29 changes: 27 additions & 2 deletions panel/Speaker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ enum Speaker {
// shebang pointing at /Users/runner/... that doesn't exist on user
// machines. Always go through `python3 <script-path>` 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"
Expand All @@ -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
Expand All @@ -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
Expand Down