From e5af597f93bf642c40a84fe32ec8a3d464eac006 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:39:37 +0200 Subject: [PATCH 1/7] Hotkey: chords carry a role and the monitors take a set `HotkeyMonitor.start` takes `[HotkeyRole: Hotkey]` and reports `HotkeyMonitorEvent { role, event }`. `HotkeyChordSet` owns one unchanged `HotkeyChordTracker` per role, feeds every keyboard transition to all of them and swallows the union; the tap monitor holds one behind its lock. The Carbon monitor registers one hot key per chord under one handler, with the role in the low bits of the hot key ID, and skips a chord it cannot register on its own. The coordinator remembers which role started the recording and lets only that role's release or cancel end it, so two nested chords hand over cleanly. No behaviour change: only the dictate chord is registered. The polish chord arrives in the next commit; #38's toggle chord is one enum case. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../PladderCore/DictationCoordinator.swift | 29 +++- Sources/PladderCore/HotkeyChordSet.swift | 94 +++++++++++ .../PladderCore/Protocols/HotkeyMonitor.swift | 29 +++- .../PladderSystem/CarbonHotkeyMonitor.swift | 152 +++++++++++------- .../PladderSystem/GlobalHotkeyMonitor.swift | 42 ++--- .../DictationCoordinatorTests.swift | 23 ++- .../HotkeyChordSetTests.swift | 100 ++++++++++++ 7 files changed, 374 insertions(+), 95 deletions(-) create mode 100644 Sources/PladderCore/HotkeyChordSet.swift create mode 100644 Tests/PladderCoreTests/HotkeyChordSetTests.swift diff --git a/Sources/PladderCore/DictationCoordinator.swift b/Sources/PladderCore/DictationCoordinator.swift index 62f7e65..cb4160a 100644 --- a/Sources/PladderCore/DictationCoordinator.swift +++ b/Sources/PladderCore/DictationCoordinator.swift @@ -233,6 +233,9 @@ public final class DictationCoordinator { /// The engine that was ready when the current recording started. Nil /// between cycles. private var cycleEngine: (any TranscriptionEngine)? + /// The chord that started the current recording. Only its release or + /// cancel ends the recording. Nil between cycles. + private var cycleRole: HotkeyRole? /// Half a second of silence. Transcribing it at key-down brings the /// Neural Engine up from idle while the user is still speaking; every @@ -367,25 +370,32 @@ public final class DictationCoordinator { private func startHotkey() { hotkeyTask?.cancel() hotkeyMonitor.stop() - let stream = hotkeyMonitor.start( - hotkey: hotkeyOverride ?? settings.hotkey, submitKey: settings.submitKey) + let chords: [HotkeyRole: Hotkey] = [.dictate: hotkeyOverride ?? settings.hotkey] + let stream = hotkeyMonitor.start(chords: chords, submitKey: settings.submitKey) hotkeyTask = Task { [weak self] in - for await event in stream { + for await tagged in stream { guard let self else { return } - switch event { - case .pressed: await self.hotkeyPressed() - case .released(let submit): self.hotkeyReleased(submit: submit) + switch tagged.event { + case .pressed: await self.hotkeyPressed(role: tagged.role) + // Only the chord that started the recording may end it: with + // nested chords the other tracker reports the hand-over as + // its own release. + case .released(let submit): + if tagged.role == self.cycleRole { self.hotkeyReleased(submit: submit) } // Another key went down right after the chord: the user typed // Cmd+C, not a dictation. Drop the audio without transcribing, // without a stop sound and without a timing line. - case .cancelled: await self.cancelRecording() + case .cancelled: + if tagged.role == self.cycleRole { await self.cancelRecording() } } } } } /// Public so tests and a menu item can drive the state machine directly. - public func hotkeyPressed() async { + /// `role` is the chord that was pressed; it decides what the dictation + /// goes through at release. + public func hotkeyPressed(role: HotkeyRole = .dictate) async { // `.copied` is the hint from the previous dictation, not a busy state: // a press replaces it rather than being dropped. switch state { @@ -397,6 +407,7 @@ public final class DictationCoordinator { // The engine that was ready at press transcribes this cycle, even if // the settings switch engines mid-recording. cycleEngine = loader.engine + cycleRole = role partialTranscript = nil // Read once, at press: switching the style mid-recording must not // leave the loop half live, with nothing warming the engine. @@ -486,6 +497,7 @@ public final class DictationCoordinator { let fedSamples = fedSampleCount fedSampleCount = 0 cycleEngine = nil + cycleRole = nil inFlight = Task { [weak self] in guard let self else { return } let stopped = ContinuousClock.now @@ -577,6 +589,7 @@ public final class DictationCoordinator { becomeIdle() abandonStreaming() cycleEngine = nil + cycleRole = nil let engine = loader.engine _ = await capture.stop() if let streaming = engine as? (any StreamingTranscriptionEngine) { diff --git a/Sources/PladderCore/HotkeyChordSet.swift b/Sources/PladderCore/HotkeyChordSet.swift new file mode 100644 index 0000000..a9d5817 --- /dev/null +++ b/Sources/PladderCore/HotkeyChordSet.swift @@ -0,0 +1,94 @@ +import Foundation + +/// One `HotkeyChordTracker` per role, fed the same keyboard transitions. +/// `GlobalHotkeyMonitor` owns one behind its lock; tests drive it directly. +/// Pure value type, no I/O. +/// +/// Every tracker sees every event and decides on its own, exactly as a lone +/// tracker does; an event is swallowed when any tracker swallows it. Events +/// come out in role order, so a single keystroke that moves two trackers +/// always reports them the same way round. +/// +/// Two chords that nest, Right Command and Right Command + Right Option say, +/// hand over from one tracker to the other the way one tracker treats a +/// foreign modifier: pressing Right Option while Right Command is held makes +/// the first report `.cancelled` (inside the interruption window) or +/// `.released` (after it), and the second `.pressed`. The coordinator only +/// lets the chord that started a recording end it, so the hand-over's +/// release cannot cut the second recording short. +public struct HotkeyChordSet: Sendable, Equatable { + public struct Outcome: Sendable, Equatable { + public var events: [HotkeyMonitorEvent] + /// True when the event must not reach other applications. + public var swallow: Bool + + public init(events: [HotkeyMonitorEvent] = [], swallow: Bool = false) { + self.events = events + self.swallow = swallow + } + } + + /// Parallel arrays, sorted by role, so the order of events is stable and + /// the type stays `Equatable` without a hand-written `==`. + private let roles: [HotkeyRole] + private var trackers: [HotkeyChordTracker] + + /// Empty chords are dropped: they never fire. + public init( + chords: [HotkeyRole: Hotkey], + submitKey: Hotkey = Hotkey(keyCodes: []), + interruptionWindow: Duration = .seconds(1) + ) { + let active = chords.filter { !$0.value.isEmpty }.sorted { $0.key < $1.key } + roles = active.map(\.key) + trackers = active.map { + HotkeyChordTracker(hotkey: $0.value, submitKey: submitKey, interruptionWindow: interruptionWindow) + } + } + + public mutating func keyDown( + _ key: UInt16, + isRepeat: Bool = false, + modifiers: Set, + at instant: ContinuousClock.Instant = .now + ) -> Outcome { + fanOut { $0.keyDown(key, isRepeat: isRepeat, modifiers: modifiers, at: instant) } + } + + public mutating func keyUp( + _ key: UInt16, modifiers: Set, at instant: ContinuousClock.Instant = .now + ) -> Outcome { + fanOut { $0.keyUp(key, modifiers: modifiers, at: instant) } + } + + public mutating func flagsChanged( + modifiers: Set, at instant: ContinuousClock.Instant = .now + ) -> Outcome { + fanOut { $0.flagsChanged(modifiers: modifiers, at: instant) } + } + + /// Every engaged chord is released; events were lost, so none says submit. + public mutating func reset() -> [HotkeyMonitorEvent] { + var events: [HotkeyMonitorEvent] = [] + for index in trackers.indices { + if let event = trackers[index].reset() { + events.append(HotkeyMonitorEvent(role: roles[index], event: event)) + } + } + return events + } + + private mutating func fanOut( + _ step: (inout HotkeyChordTracker) -> HotkeyChordTracker.Outcome + ) -> Outcome { + var outcome = Outcome() + for index in trackers.indices { + let single = step(&trackers[index]) + if let event = single.event { + outcome.events.append(HotkeyMonitorEvent(role: roles[index], event: event)) + } + outcome.swallow = outcome.swallow || single.swallow + } + return outcome + } +} diff --git a/Sources/PladderCore/Protocols/HotkeyMonitor.swift b/Sources/PladderCore/Protocols/HotkeyMonitor.swift index 7c26af1..203f7d1 100644 --- a/Sources/PladderCore/Protocols/HotkeyMonitor.swift +++ b/Sources/PladderCore/Protocols/HotkeyMonitor.swift @@ -1,12 +1,33 @@ import Foundation -/// Watches for the push-to-talk chord system wide and reports press and release. +/// Which chord fired. The coordinator starts a recording for either and +/// decides at release what the dictation goes through. +public enum HotkeyRole: String, Sendable, Hashable, CaseIterable, Comparable { + case dictate, polish + + public static func < (a: Self, b: Self) -> Bool { a.rawValue < b.rawValue } +} + +/// One tracker's transition, tagged with the chord it belongs to. +public struct HotkeyMonitorEvent: Sendable, Equatable { + public var role: HotkeyRole + public var event: HotkeyEvent + + public init(role: HotkeyRole, event: HotkeyEvent) { + self.role = role + self.event = event + } +} + +/// Watches for the push-to-talk chords system wide and reports press and release. public protocol HotkeyMonitor: Sendable { - /// Starts monitoring and returns a stream of events. `submitKey` is the - /// chord that, pressed while the hotkey is held, asks for Return after the + /// Starts monitoring and returns a stream of events, each tagged with the + /// role of the chord it belongs to. Each chord is matched on its own; a + /// chord in the set that is empty is ignored. `submitKey` is the chord + /// that, pressed while any of them is held, asks for Return after the /// paste; an empty chord turns that off. Cancelling the consuming task or /// calling `stop()` ends monitoring. - func start(hotkey: Hotkey, submitKey: Hotkey) -> AsyncStream + func start(chords: [HotkeyRole: Hotkey], submitKey: Hotkey) -> AsyncStream func stop() } diff --git a/Sources/PladderSystem/CarbonHotkeyMonitor.swift b/Sources/PladderSystem/CarbonHotkeyMonitor.swift index a8caa5e..3042823 100644 --- a/Sources/PladderSystem/CarbonHotkeyMonitor.swift +++ b/Sources/PladderSystem/CarbonHotkeyMonitor.swift @@ -3,7 +3,7 @@ import Foundation import PladderCore import os -/// Watches the push-to-talk chord with Carbon's `RegisterEventHotKey`, which +/// Watches the push-to-talk chords with Carbon's `RegisterEventHotKey`, which /// needs no permission at all. /// /// This is the fallback for accounts that cannot grant Accessibility: a @@ -24,6 +24,9 @@ import os /// keyboard, and posting the Return it asks for needs Accessibility anyway. /// `submitKey` is therefore ignored and every release says `submit: false`. /// +/// Each chord is its own hot key; a chord that cannot be registered is +/// skipped on its own, the others still work. +/// /// This stays a dumb registrar: an unregistrable chord is refused here, and it /// is `AppModel` that hands the coordinator the default chord instead, since /// the menu and the settings window have to name what is actually being @@ -35,12 +38,15 @@ import os /// actor. public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { private struct State { - var continuation: AsyncStream.Continuation? + var continuation: AsyncStream.Continuation? var registration: Registration? + /// The role behind each hot key ID of the current session. + var roles: [UInt32: HotkeyRole] = [:] /// Carbon repeats `kEventHotKeyPressed` while the key is held on some /// configurations, and a release can arrive with nothing pressed - /// after a `stop()`; this makes the stream strictly alternating. - var isPressed = false + /// after a `stop()`; this makes each role's events strictly + /// alternating. + var pressed: Set = [] /// Bumped by every `start`/`stop` so a registration that was scheduled /// onto the main thread and then superseded quietly undoes itself, and /// so events for an old hot key are ignored. @@ -67,45 +73,58 @@ public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { // MARK: HotkeyMonitor - public func start(hotkey: Hotkey, submitKey: Hotkey) -> AsyncStream { + public func start(chords: [HotkeyRole: Hotkey], submitKey: Hotkey) -> AsyncStream { // Starting twice replaces the previous session rather than stacking // registrations, the same rule `GlobalHotkeyMonitor` follows. stop() - let (stream, continuation) = AsyncStream.makeStream( + let (stream, continuation) = AsyncStream.makeStream( bufferingPolicy: .unbounded) let generation: UInt32 = lock.withLock { state.generation &+= 1 state.continuation = continuation - state.isPressed = false + state.pressed = [] + state.roles = [:] return state.generation } - // If the consumer drops the stream the hot key must still go away. - continuation.onTermination = { [weak self] _ in - self?.unregister(generation: generation) + var entries: [HotKeyEntry] = [] + for (role, chord) in chords.sorted(by: { $0.key < $1.key }) where !chord.isEmpty { + guard chord.canBeRegisteredWithoutAccessibility, + let keyCode = chord.regularKeyCodes.first else { + // Nothing to register for this role. The stream stays open so + // the coordinator behaves exactly as it does before a tap + // comes up; the settings window is where the user is told to + // pick a chord with a regular key. + let codes = chord.keyCodes.sorted().map(String.init).joined(separator: ", ") + Self.log.error( + """ + Without Accessibility the chord needs exactly one regular key and no Fn; \ + the \(role.rawValue, privacy: .public) chord [\(codes, privacy: .public)] cannot be registered. + """ + ) + continue + } + entries.append(HotKeyEntry( + role: role, + id: Self.hotKeyID(generation: generation, role: role), + keyCode: UInt32(keyCode), + modifiers: chord.carbonModifierMask)) + } + lock.withLock { + guard state.generation == generation else { return } + for entry in entries { state.roles[entry.id] = entry.role } } - guard hotkey.canBeRegisteredWithoutAccessibility, - let keyCode = hotkey.regularKeyCodes.first else { - // Nothing to register. The stream stays open and silent so the - // coordinator behaves exactly as it does before a tap comes up; - // the settings window is where the user is told to pick a chord - // with a regular key. - let codes = hotkey.keyCodes.sorted().map(String.init).joined(separator: ", ") - Self.log.error( - """ - Without Accessibility the chord needs exactly one regular key and no Fn; \ - the chord [\(codes, privacy: .public)] cannot be registered. - """ - ) - return stream + // If the consumer drops the stream the hot keys must still go away. + continuation.onTermination = { [weak self] _ in + self?.unregister(generation: generation) } - let modifiers = hotkey.carbonModifierMask - onMain { [weak self] in - self?.register(keyCode: UInt32(keyCode), modifiers: modifiers, generation: generation) + guard !entries.isEmpty else { return stream } + onMain { [weak self, entries] in + self?.register(entries, generation: generation) } return stream @@ -113,12 +132,13 @@ public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { public func stop() { let (continuation, registration) = lock.withLock { - () -> (AsyncStream.Continuation?, Registration?) in + () -> (AsyncStream.Continuation?, Registration?) in state.generation &+= 1 let result = (state.continuation, state.registration) state.continuation = nil state.registration = nil - state.isPressed = false + state.roles = [:] + state.pressed = [] return result } // `finish()` may run `onTermination` synchronously; the lock is @@ -130,16 +150,32 @@ public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { // MARK: Registration - /// The hot key and the event handler that feeds it. Neither Carbon type is - /// `Sendable`; both are only ever created and destroyed on the main - /// thread, so boxing them to hop there is safe. + /// The session's hot keys and the one event handler that feeds them all. + /// Neither Carbon type is `Sendable`; both are only ever created and + /// destroyed on the main thread, so boxing them to hop there is safe. private struct Registration: @unchecked Sendable { - var hotKey: EventHotKeyRef? + var hotKeys: [EventHotKeyRef] var handler: EventHandlerRef? } + /// One chord to register: which role it is, the ID its events carry, and + /// Carbon's spelling of it. + private struct HotKeyEntry: Sendable { + var role: HotkeyRole + var id: UInt32 + var keyCode: UInt32 + var modifiers: UInt32 + } + + /// The generation in the high bits and the role's index in the low three, + /// so one session's hot keys are told apart and an old session's ignored. + private static func hotKeyID(generation: UInt32, role: HotkeyRole) -> UInt32 { + let index = UInt32(HotkeyRole.allCases.firstIndex(of: role) ?? 0) + return (generation &<< 3) | index + } + /// Main thread only. - private func register(keyCode: UInt32, modifiers: UInt32, generation: UInt32) { + private func register(_ entries: [HotKeyEntry], generation: UInt32) { guard lock.withLock({ state.generation == generation && state.registration == nil }) else { return } @@ -161,21 +197,30 @@ public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { return } - var hotKey: EventHotKeyRef? - let id = EventHotKeyID(signature: Self.signature, id: generation) - let registered = RegisterEventHotKey( - keyCode, modifiers, id, GetApplicationEventTarget(), 0, &hotKey) - guard registered == noErr, hotKey != nil else { - if registered == OSStatus(eventHotKeyExistsErr) { - Self.log.error("The push-to-talk key is already in use by another app") - } else { - Self.log.error("Could not register the push-to-talk key (\(registered, privacy: .public))") + var hotKeys: [EventHotKeyRef] = [] + for entry in entries { + var hotKey: EventHotKeyRef? + let id = EventHotKeyID(signature: Self.signature, id: entry.id) + let registered = RegisterEventHotKey( + entry.keyCode, entry.modifiers, id, GetApplicationEventTarget(), 0, &hotKey) + guard registered == noErr, let hotKey else { + // This chord is lost; the others still work. + let role = entry.role.rawValue + if registered == OSStatus(eventHotKeyExistsErr) { + Self.log.error("The \(role, privacy: .public) key is already in use by another app") + } else { + Self.log.error("Could not register the \(role, privacy: .public) key (\(registered, privacy: .public))") + } + continue } + hotKeys.append(hotKey) + } + guard !hotKeys.isEmpty else { RemoveEventHandler(handler) return } - let registration = Registration(hotKey: hotKey, handler: handler) + let registration = Registration(hotKeys: hotKeys, handler: handler) let stale: Bool = lock.withLock { guard state.generation == generation else { return true } state.registration = registration @@ -195,9 +240,9 @@ public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { private static func tearDown(_ registration: Registration?) { guard let registration, - registration.hotKey != nil || registration.handler != nil else { return } + !registration.hotKeys.isEmpty || registration.handler != nil else { return } onMainThread { - if let hotKey = registration.hotKey { UnregisterEventHotKey(hotKey) } + for hotKey in registration.hotKeys { UnregisterEventHotKey(hotKey) } if let handler = registration.handler { RemoveEventHandler(handler) } } } @@ -222,20 +267,19 @@ public final class CarbonHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { let kind = GetEventKind(event) let (outcome, continuation) = lock.withLock { - () -> (HotkeyEvent?, AsyncStream.Continuation?) in + () -> (HotkeyMonitorEvent?, AsyncStream.Continuation?) in // An event for a hot key we have already replaced. - guard id.id == state.generation else { return (nil, nil) } + guard id.id >> 3 == state.generation & (UInt32.max >> 3), + let role = state.roles[id.id] else { return (nil, nil) } switch Int(kind) { case kEventHotKeyPressed: - guard !state.isPressed else { return (nil, nil) } - state.isPressed = true - return (.pressed, state.continuation) + guard state.pressed.insert(role).inserted else { return (nil, nil) } + return (HotkeyMonitorEvent(role: role, event: .pressed), state.continuation) case kEventHotKeyReleased: - guard state.isPressed else { return (nil, nil) } - state.isPressed = false + guard state.pressed.remove(role) != nil else { return (nil, nil) } // No send key here: posting the Return it asks for needs the // grant this monitor exists to do without. - return (.released(submit: false), state.continuation) + return (HotkeyMonitorEvent(role: role, event: .released(submit: false)), state.continuation) default: return (nil, nil) } diff --git a/Sources/PladderSystem/GlobalHotkeyMonitor.swift b/Sources/PladderSystem/GlobalHotkeyMonitor.swift index 00712c9..d9836fc 100644 --- a/Sources/PladderSystem/GlobalHotkeyMonitor.swift +++ b/Sources/PladderSystem/GlobalHotkeyMonitor.swift @@ -2,7 +2,7 @@ import CoreGraphics import Foundation import PladderCore -/// Watches the push-to-talk chord with a session-wide CGEvent tap. +/// Watches the push-to-talk chords with a session-wide CGEvent tap. /// /// A tap rather than `NSEvent` monitors because the chord may contain a regular /// key: when the user picks Control+Space, the Space must not also land in the @@ -20,14 +20,14 @@ import PladderCore /// `CGEvent.tapCreate` returns nil and we simply try again every couple of /// seconds, so the hotkey comes alive the moment the user ticks the box. /// -/// Which keys are down is `HotkeyChordTracker`'s business; this class only +/// Which keys are down is `HotkeyChordSet`'s business; this class only /// translates events and owns the tap. It is `@unchecked Sendable`: all mutable /// state lives behind `lock`, and the tap is created and torn down on the tap /// thread, whose run loop it is attached to. public final class GlobalHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { private struct State { - var continuation: AsyncStream.Continuation? - var tracker: HotkeyChordTracker? + var continuation: AsyncStream.Continuation? + var chords: HotkeyChordSet? var modifiers = ModifierKeyState() var tap: TapHandle? /// Bumped by every `start`/`stop` so an install that was scheduled onto @@ -54,17 +54,17 @@ public final class GlobalHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { // MARK: HotkeyMonitor - public func start(hotkey: Hotkey, submitKey: Hotkey) -> AsyncStream { + public func start(chords: [HotkeyRole: Hotkey], submitKey: Hotkey) -> AsyncStream { // Starting twice replaces the previous session rather than stacking taps. stop() - let (stream, continuation) = AsyncStream.makeStream( + let (stream, continuation) = AsyncStream.makeStream( bufferingPolicy: .unbounded) let generation: UInt64 = lock.withLock { state.generation &+= 1 state.continuation = continuation - state.tracker = HotkeyChordTracker(hotkey: hotkey, submitKey: submitKey) + state.chords = HotkeyChordSet(chords: chords, submitKey: submitKey) state.modifiers = ModifierKeyState() return state.generation } @@ -86,7 +86,7 @@ public final class GlobalHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { state.generation &+= 1 let result = (state.continuation, state.tap) state.continuation = nil - state.tracker = nil + state.chords = nil state.tap = nil return result } @@ -174,32 +174,32 @@ public final class GlobalHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { let keyCode = UInt16(truncatingIfNeeded: event.getIntegerValueField(.keyboardEventKeycode)) let flags = event.flags.rawValue - // One read per event, on the tap thread: the tracker times the + // One read per event, on the tap thread: the trackers time the // interruption window from it. let now = ContinuousClock.now let (outcome, continuation) = lock.withLock { - () -> (HotkeyChordTracker.Outcome, AsyncStream.Continuation?) in - guard state.tracker != nil else { return (.init(), nil) } - let outcome: HotkeyChordTracker.Outcome + () -> (HotkeyChordSet.Outcome, AsyncStream.Continuation?) in + guard state.chords != nil else { return (.init(), nil) } + let outcome: HotkeyChordSet.Outcome switch type { case .keyDown: let isRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) != 0 - outcome = state.tracker!.keyDown( + outcome = state.chords!.keyDown( keyCode, isRepeat: isRepeat, modifiers: state.modifiers.held(flags: flags), at: now) case .keyUp: - outcome = state.tracker!.keyUp( + outcome = state.chords!.keyUp( keyCode, modifiers: state.modifiers.held(flags: flags), at: now) case .flagsChanged: let modifiers = state.modifiers.update(changedKey: keyCode, flags: flags) - outcome = state.tracker!.flagsChanged(modifiers: modifiers, at: now) + outcome = state.chords!.flagsChanged(modifiers: modifiers, at: now) default: return (.init(), nil) } return (outcome, state.continuation) } - if let event = outcome.event { continuation?.yield(event) } + for event in outcome.events { continuation?.yield(event) } return outcome.swallow } @@ -207,14 +207,14 @@ public final class GlobalHotkeyMonitor: HotkeyMonitor, @unchecked Sendable { /// with a keyboard interrupt. Turn it back on and start from a clean slate, /// since events were missed while it was off. private func reenable() { - let (tap, event, continuation) = lock.withLock { - () -> (TapHandle?, HotkeyEvent?, AsyncStream.Continuation?) in - let event = state.tracker?.reset() + let (tap, events, continuation) = lock.withLock { + () -> (TapHandle?, [HotkeyMonitorEvent], AsyncStream.Continuation?) in + let events = state.chords?.reset() ?? [] state.modifiers = ModifierKeyState() - return (state.tap, event, state.continuation) + return (state.tap, events, state.continuation) } if let tap { CGEvent.tapEnable(tap: tap.port, enable: true) } - if let event { continuation?.yield(event) } + for event in events { continuation?.yield(event) } } // MARK: Helpers diff --git a/Tests/PladderCoreTests/DictationCoordinatorTests.swift b/Tests/PladderCoreTests/DictationCoordinatorTests.swift index fffeee5..465b228 100644 --- a/Tests/PladderCoreTests/DictationCoordinatorTests.swift +++ b/Tests/PladderCoreTests/DictationCoordinatorTests.swift @@ -66,22 +66,29 @@ final class FakeOutput: TextOutput, @unchecked Sendable { } final class FakeHotkey: HotkeyMonitor, @unchecked Sendable { - private var continuation: AsyncStream.Continuation? + private var continuation: AsyncStream.Continuation? /// How often `start` was called, and with what, so a monitor swap can be /// checked from the outside. private(set) var startCount = 0 - private(set) var lastHotkey: Hotkey? - func start(hotkey: Hotkey, submitKey: Hotkey) -> AsyncStream { + private(set) var lastChords: [HotkeyRole: Hotkey] = [:] + var lastHotkey: Hotkey? { lastChords[.dictate] } + func start(chords: [HotkeyRole: Hotkey], submitKey: Hotkey) -> AsyncStream { startCount += 1 - lastHotkey = hotkey - let (stream, cont) = AsyncStream.makeStream() + lastChords = chords + let (stream, cont) = AsyncStream.makeStream() continuation = cont return stream } func stop() { continuation?.finish() } - func press() { continuation?.yield(.pressed) } - func release(submit: Bool = false) { continuation?.yield(.released(submit: submit)) } - func cancel() { continuation?.yield(.cancelled) } + func press(_ role: HotkeyRole = .dictate) { + continuation?.yield(HotkeyMonitorEvent(role: role, event: .pressed)) + } + func release(_ role: HotkeyRole = .dictate, submit: Bool = false) { + continuation?.yield(HotkeyMonitorEvent(role: role, event: .released(submit: submit))) + } + func cancel(_ role: HotkeyRole = .dictate) { + continuation?.yield(HotkeyMonitorEvent(role: role, event: .cancelled)) + } } /// Counts the two calls the coordinator makes. The real controller's timing diff --git a/Tests/PladderCoreTests/HotkeyChordSetTests.swift b/Tests/PladderCoreTests/HotkeyChordSetTests.swift new file mode 100644 index 0000000..efe2a71 --- /dev/null +++ b/Tests/PladderCoreTests/HotkeyChordSetTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing +@testable import PladderCore + +private let rightOption: UInt16 = 0x3D +private let leftOption: UInt16 = 0x3A +private let leftControl: UInt16 = 0x3B +private let rightCommand: UInt16 = 0x36 +private let space: UInt16 = 0x31 +private let returnKey: UInt16 = 0x24 + +private func event(_ role: HotkeyRole, _ event: HotkeyEvent) -> HotkeyMonitorEvent { + HotkeyMonitorEvent(role: role, event: event) +} + +@Suite struct HotkeyChordSetTests { + @Test func eachChordReportsItsOwnRole() { + var set = HotkeyChordSet(chords: [.dictate: .optionSpace, .polish: Hotkey(leftControl, space)]) + #expect(set.flagsChanged(modifiers: [leftControl]) == .init()) + #expect(set.keyDown(space, modifiers: [leftControl]) == .init(events: [event(.polish, .pressed)], swallow: true)) + #expect(set.keyUp(space, modifiers: [leftControl]) == .init(events: [event(.polish, .released(submit: false))], swallow: true)) + #expect(set.flagsChanged(modifiers: []) == .init()) + + #expect(set.flagsChanged(modifiers: [leftOption]) == .init()) + #expect(set.keyDown(space, modifiers: [leftOption]) == .init(events: [event(.dictate, .pressed)], swallow: true)) + #expect(set.keyUp(space, modifiers: [leftOption]) == .init(events: [event(.dictate, .released(submit: false))], swallow: true)) + } + + @Test func swallowIsTheUnionOfTheTrackers() { + // Only the polish tracker swallows the Space; the set still drops it. + var set = HotkeyChordSet(chords: [.dictate: .rightCommand, .polish: Hotkey(leftControl, space)]) + #expect(set.flagsChanged(modifiers: [leftControl]) == .init()) + #expect(set.keyDown(space, modifiers: [leftControl]).swallow) + #expect(set.keyUp(space, modifiers: [leftControl]).swallow) + // A key neither chord owns passes through. + #expect(!set.keyDown(0x00, modifiers: []).swallow) + } + + @Test func anEmptyChordIsIgnored() { + var set = HotkeyChordSet(chords: [.dictate: .optionSpace, .polish: Hotkey(keyCodes: [])]) + var single = HotkeyChordTracker(hotkey: .optionSpace) + #expect(set.flagsChanged(modifiers: [leftOption]) == .init()) + #expect(single.flagsChanged(modifiers: [leftOption]) == .init()) + let setDown = set.keyDown(space, modifiers: [leftOption]) + let singleDown = single.keyDown(space, modifiers: [leftOption]) + #expect(setDown == .init(events: [event(.dictate, .pressed)], swallow: true)) + #expect(singleDown == .init(event: .pressed, swallow: true)) + #expect(set.keyUp(space, modifiers: [leftOption]).events == [event(.dictate, .released(submit: false))]) + } + + @Test func nestedChordsHandOverBetweenRoles() { + let start = ContinuousClock.now + var set = HotkeyChordSet(chords: [.dictate: .rightCommand, .polish: Hotkey(rightCommand, rightOption)]) + #expect(set.flagsChanged(modifiers: [rightCommand], at: start) == .init(events: [event(.dictate, .pressed)])) + // Right Option inside the window: the lone Right Command was the + // start of the longer chord, not a dictation. + #expect( + set.flagsChanged(modifiers: [rightCommand, rightOption], at: start + .milliseconds(100)) + == .init(events: [event(.dictate, .cancelled), event(.polish, .pressed)]) + ) + #expect( + set.flagsChanged(modifiers: [], at: start + .seconds(3)) + == .init(events: [event(.polish, .released(submit: false))]) + ) + } + + @Test func resetReleasesEveryEngagedChord() { + var set = HotkeyChordSet(chords: [.dictate: .rightCommand, .polish: Hotkey(leftControl, space)]) + _ = set.flagsChanged(modifiers: [leftControl]) + _ = set.keyDown(space, modifiers: [leftControl]) + #expect(set.reset() == [event(.polish, .released(submit: false))]) + // Nothing is engaged after a reset. + #expect(set.reset() == []) + } + + @Test func theSubmitKeyArmsEitherChord() { + var set = HotkeyChordSet( + chords: [.dictate: .rightCommand, .polish: Hotkey(leftControl, space)], + submitKey: Hotkey(returnKey)) + _ = set.flagsChanged(modifiers: [rightCommand]) + #expect(set.keyDown(returnKey, modifiers: [rightCommand]).swallow) + _ = set.keyUp(returnKey, modifiers: [rightCommand]) + #expect(set.flagsChanged(modifiers: []).events == [event(.dictate, .released(submit: true))]) + + _ = set.flagsChanged(modifiers: [leftControl]) + _ = set.keyDown(space, modifiers: [leftControl]) + #expect(set.keyDown(returnKey, modifiers: [leftControl]).swallow) + _ = set.keyUp(returnKey, modifiers: [leftControl]) + #expect(set.keyUp(space, modifiers: [leftControl]).events == [event(.polish, .released(submit: true))]) + } + + @Test func eventsComeInRoleOrder() { + // Built in the other order; the events still come out dictate first. + let start = ContinuousClock.now + var set = HotkeyChordSet(chords: [.polish: Hotkey(rightCommand, rightOption), .dictate: .rightCommand]) + _ = set.flagsChanged(modifiers: [rightCommand], at: start) + let handOver = set.flagsChanged(modifiers: [rightCommand, rightOption], at: start + .milliseconds(10)) + #expect(handOver.events.map(\.role) == [.dictate, .polish]) + } +} From 4a2ad200ad5294ea3df2a180751b292e4066bb2e Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:42:29 +0200 Subject: [PATCH 2/7] Polish: the polish hotkey, the refiner protocol and the coordinator branch `Settings.polishHotkey` is a second recordable chord, empty (off) by default. The coordinator registers it beside the dictate chord unless it is empty or equal to it; a press tags the cycle, publishes `willPolish` for the overlay and warms the refiner in a detached utility task while the user is still speaking. The release path gains one branch, after the pipeline and the empty guard: on a polish cycle with at least `minimumPolishWords` (4) words the state goes to `.polishing`, the injected `TranscriptRefiner` runs, and nil falls back to the processed text. On the normal path the only new work is one Bool read. The word gate sits in the coordinator so the fake-refiner tests cover it and the refiner never sees three words. `CycleTiming.polish` is nil on the normal path and the model's wall time on a polish cycle, zero when the gate skipped it, so the two paths can be logged apart. `.polishing` gets its arm in the menu bar glyph, the status line and the overlay, which keeps the pill up from release to paste on a polish cycle. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/Pladder/AppModel.swift | 1 + Sources/Pladder/MenuBarIcon.swift | 4 +- .../Pladder/Overlay/OverlayController.swift | 20 ++ Sources/Pladder/Overlay/OverlayView.swift | 14 +- .../Pladder/Resources/Localizable.xcstrings | 10 + .../PladderCore/DictationCoordinator.swift | 81 ++++- .../PladderCore/Models/DictationState.swift | 5 +- Sources/PladderCore/Models/Settings.swift | 9 +- .../Protocols/TranscriptRefiner.swift | 14 + .../DictationCoordinatorTests.swift | 326 +++++++++++++++++- 10 files changed, 466 insertions(+), 18 deletions(-) create mode 100644 Sources/PladderCore/Protocols/TranscriptRefiner.swift diff --git a/Sources/Pladder/AppModel.swift b/Sources/Pladder/AppModel.swift index 9afce92..d8bca55 100644 --- a/Sources/Pladder/AppModel.swift +++ b/Sources/Pladder/AppModel.swift @@ -411,6 +411,7 @@ final class AppModel { switch coordinator.state { case .recording: return String(localized: "Recording…") case .transcribing: return String(localized: "Transcribing…") + case .polishing: return String(localized: "Polishing…") case .inserting: return String(localized: "Inserting…") case .error(let failure): return String(localized: "Error: \(failure.text)") case .copied: return String(localized: "Copied — press ⌘V") diff --git a/Sources/Pladder/MenuBarIcon.swift b/Sources/Pladder/MenuBarIcon.swift index aa20ba4..c24b837 100644 --- a/Sources/Pladder/MenuBarIcon.swift +++ b/Sources/Pladder/MenuBarIcon.swift @@ -14,7 +14,7 @@ enum MenuBarIcon { /// Bars follow the input level, quantised to `levelSteps` so the /// image cache stays bounded. case recording(step: Int) - /// Transcribing or inserting: dimmed to read as "busy". + /// Transcribing, polishing or inserting: dimmed to read as "busy". case busy /// Engine unavailable or an error: slashed like `mic.slash`. case off @@ -32,7 +32,7 @@ enum MenuBarIcon { // input level means. let amp = CGFloat(WaveformMeter.amplitude(for: level)) self = .recording(step: Int((amp * CGFloat(Self.levelSteps - 1)).rounded())) - case .transcribing, .inserting: self = .busy + case .transcribing, .polishing, .inserting: self = .busy case .unavailable, .error: self = .off } } diff --git a/Sources/Pladder/Overlay/OverlayController.swift b/Sources/Pladder/Overlay/OverlayController.swift index e52049e..0baf764 100644 --- a/Sources/Pladder/Overlay/OverlayController.swift +++ b/Sources/Pladder/Overlay/OverlayController.swift @@ -114,6 +114,17 @@ final class OverlayController { if visible { scheduleHide(after: .zero, flight: false) } return } + // A polish cycle is seconds, not milliseconds: keep the pill up, + // say what is happening, and let `.polishing` and then `.idle` + // take over. + if coordinator.willPolish { + cancelSpinner() + model.partialTranscript = nil + model.state = .transcribing + cancelHide() + present(flight: true) + return + } // A new partial can re-run this while the spinner is already // armed or on screen; only the first `.transcribing` acts. guard spinnerTask == nil else { return } @@ -129,6 +140,15 @@ final class OverlayController { self.cancelHide() self.present(flight: true) } + case .polishing: + guard model.style != .menuBar else { + if visible { scheduleHide(after: .zero, flight: false) } + return + } + cancelSpinner() + model.state = state + cancelHide() + present(flight: true) case .inserting: // Milliseconds long, and `.idle` or `.copied` follows at once, so // nothing is shown and nothing is hidden here: hiding would diff --git a/Sources/Pladder/Overlay/OverlayView.swift b/Sources/Pladder/Overlay/OverlayView.swift index f490b47..c63fef3 100644 --- a/Sources/Pladder/Overlay/OverlayView.swift +++ b/Sources/Pladder/Overlay/OverlayView.swift @@ -97,6 +97,7 @@ private enum OverlayPhase: Equatable { case empty case recording case transcribing + case polishing case copied case error(DictationFailure) @@ -104,6 +105,7 @@ private enum OverlayPhase: Equatable { switch state { case .recording: self = .recording case .transcribing: self = .transcribing + case .polishing: self = .polishing case .copied: self = .copied // The controller never mirrors `.inserting` onto the model, so this // is only reached by a preview, and it draws nothing. @@ -290,6 +292,16 @@ struct OverlayPill: View { .font(.system(size: 13, weight: .medium, design: .rounded)) .foregroundStyle(.primary) } + case .polishing: + // Only a dictation started with the polish key gets here, and it + // waits seconds rather than milliseconds, so the pill says why. + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Polishing…") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(.primary) + } case .copied: // Nothing pasted the text, so the user has to. The one thing the // pill still says after a release: the paste that is normally the @@ -395,7 +407,7 @@ struct OverlayPill: View { .transition(.opacity) } } - case .transcribing: + case .transcribing, .polishing: ProgressView() .controlSize(.small) case .copied: diff --git a/Sources/Pladder/Resources/Localizable.xcstrings b/Sources/Pladder/Resources/Localizable.xcstrings index 0f4dfeb..b93ef56 100644 --- a/Sources/Pladder/Resources/Localizable.xcstrings +++ b/Sources/Pladder/Resources/Localizable.xcstrings @@ -741,6 +741,16 @@ } } }, + "Polishing…": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Überarbeiten…" + } + } + } + }, "Press keys…": { "localizations": { "de": { diff --git a/Sources/PladderCore/DictationCoordinator.swift b/Sources/PladderCore/DictationCoordinator.swift index cb4160a..5c704ae 100644 --- a/Sources/PladderCore/DictationCoordinator.swift +++ b/Sources/PladderCore/DictationCoordinator.swift @@ -4,7 +4,8 @@ import Observation /// The push-to-talk state machine. Owns no I/O itself; everything is injected. /// /// Flow: hotkey pressed -> capture starts -> hotkey released -> capture stops -> -/// engine transcribes -> pipeline processes -> output inserts -> idle. +/// engine transcribes -> pipeline processes -> (polish hotkey: model refines ->) +/// output inserts -> idle. @MainActor @Observable public final class DictationCoordinator { @@ -18,6 +19,10 @@ public final class DictationCoordinator { /// is cleared the moment the key is released. Nil in every other style. public private(set) var partialTranscript: String? + /// True from a polish-hotkey press until that cycle ends, so the overlay + /// can keep the pill up across the release. + public private(set) var willPolish = false + /// The transcribe → process → insert work for the most recent release. /// Exposed so callers (and tests) can await completion of a cycle. public private(set) var inFlight: Task? @@ -51,7 +56,9 @@ public final class DictationCoordinator { /// Command, which the default Option+Space then stands in for. The stored /// chord is left untouched and comes back the moment this is cleared, /// which is what happens when Accessibility is granted. Nil means "listen - /// for the stored chord". + /// for the stored chord". Applies to the dictate chord only; the polish + /// chord has no stand-in and is simply not registered when Carbon cannot + /// take it. public var hotkeyOverride: Hotkey? { didSet { guard hotkeyOverride != oldValue else { return } @@ -71,6 +78,9 @@ public final class DictationCoordinator { /// Minimum recording length worth transcribing. Taps shorter than this are /// treated as accidental. public var minimumDuration: TimeInterval = 0.3 + /// Transcripts shorter than this are pasted as they are: the model cannot + /// improve three words and would cost a second. + public var minimumPolishWords = 4 /// Recordings are cut off after this long. A release event can be lost for /// real, for example while a secure password field has focus and global /// monitors receive nothing, and this keeps the microphone from staying on. @@ -87,6 +97,9 @@ public final class DictationCoordinator { /// Silences the speakers while the mic is open, when the setting is on. /// Nil in tests and wherever the app does not want the behaviour at all. private let outputMuter: (any OutputMuter)? + /// The polish hotkey's second pass. Nil where there is none, which makes + /// the polish key a plain dictation. + private let refiner: (any TranscriptRefiner)? private var hotkeyMonitor: any HotkeyMonitor private let makePipeline: @Sendable (Settings) -> ProcessorPipeline /// Rebuilt when settings change so that no processor is constructed on the @@ -107,12 +120,22 @@ public final class DictationCoordinator { public var engine: Duration public var processing: Duration public var insert: Duration - - public init(captureStop: Duration, engine: Duration, processing: Duration, insert: Duration) { + /// Nil on the normal path; on a polish cycle the model's time, zero + /// when the transcript was too short for it. + public var polish: Duration? + + public init( + captureStop: Duration, + engine: Duration, + processing: Duration, + insert: Duration, + polish: Duration? = nil + ) { self.captureStop = captureStop self.engine = engine self.processing = processing self.insert = insert + self.polish = polish } } @@ -129,6 +152,7 @@ public final class DictationCoordinator { capture: any AudioCapture, output: any TextOutput, outputMuter: (any OutputMuter)? = nil, + refiner: (any TranscriptRefiner)? = nil, hotkeyMonitor: any HotkeyMonitor, makePipeline: @escaping @Sendable (Settings) -> ProcessorPipeline, onEvent: @escaping @Sendable (Event) -> Void = { _ in } @@ -137,6 +161,7 @@ public final class DictationCoordinator { self.capture = capture self.output = output self.outputMuter = outputMuter + self.refiner = refiner self.hotkeyMonitor = hotkeyMonitor self.makePipeline = makePipeline self.pipeline = makePipeline(settings) @@ -203,7 +228,8 @@ public final class DictationCoordinator { // entry. `AppModel.settings` ignores assignments that change nothing, // so this runs only on real changes. pipeline = makePipeline(settings) - if old.hotkey != settings.hotkey || old.submitKey != settings.submitKey { + if old.hotkey != settings.hotkey || old.submitKey != settings.submitKey + || old.polishHotkey != settings.polishHotkey { // The old key's release will never arrive on the new stream. // The submit key counts too: the restarted monitor would never // deliver the pending release for the old configuration. @@ -370,7 +396,12 @@ public final class DictationCoordinator { private func startHotkey() { hotkeyTask?.cancel() hotkeyMonitor.stop() - let chords: [HotkeyRole: Hotkey] = [.dictate: hotkeyOverride ?? settings.hotkey] + var chords: [HotkeyRole: Hotkey] = [.dictate: hotkeyOverride ?? settings.hotkey] + // A polish chord that is the dictate chord would fire both trackers + // at once; the settings row says why it does nothing. + if !settings.polishHotkey.isEmpty, settings.polishHotkey != chords[.dictate] { + chords[.polish] = settings.polishHotkey + } let stream = hotkeyMonitor.start(chords: chords, submitKey: settings.submitKey) hotkeyTask = Task { [weak self] in for await tagged in stream { @@ -408,6 +439,7 @@ public final class DictationCoordinator { // the settings switch engines mid-recording. cycleEngine = loader.engine cycleRole = role + willPolish = role == .polish partialTranscript = nil // Read once, at press: switching the style mid-recording must not // leave the loop half live, with nothing warming the engine. @@ -419,6 +451,7 @@ public final class DictationCoordinator { let levels = try await capture.start() guard state.isRecording else { // Cancelled or superseded while the mic was starting. + willPolish = false _ = await capture.stop() abandonStreaming() return @@ -435,6 +468,11 @@ public final class DictationCoordinator { // feed below does that for streaming engines). Both run while the // user is still speaking. Task { [weak self] in await self?.output.prepare() } + // The model's load is the one cost this feature can hide: about + // 700 ms cold, paid while the user is still speaking. + if willPolish, let refiner { + Task.detached(priority: .utility) { await refiner.prepare() } + } if let streaming = cycleEngine as? (any StreamingTranscriptionEngine) { try? await streaming.beginUtterance() startStreamingFeed(streaming, live: live) @@ -462,6 +500,7 @@ public final class DictationCoordinator { self.hotkeyReleased(submit: false) } } catch { + willPolish = false fail(.microphone(detail: error.localizedDescription)) } } @@ -514,7 +553,10 @@ public final class DictationCoordinator { submit: Bool, captureStop: Duration ) async { - defer { drainPendingUnloads() } + defer { + drainPendingUnloads() + willPolish = false + } // Streaming engines were already fed `fedSamples` while recording; // only the tail came through `stop()`. let totalDuration = Double(fedSamples + audio.samples.count) / CapturedAudio.sampleRate @@ -545,17 +587,29 @@ public final class DictationCoordinator { becomeIdle() return } + // The polish key's one branch; on the normal path it costs a Bool + // read. A refiner that cannot help returns nil and the text goes + // out as dictated. + var final = processed + if willPolish, let refiner, Self.wordCount(processed) >= minimumPolishWords { + state = .polishing + started = ContinuousClock.now + if let polished = await refiner.refine(processed) { final = polished } + timing.polish = ContinuousClock.now - started + } else if willPolish { + timing.polish = .zero + } state = .inserting // Don't double the junction: a transcript that already ends in // whitespace (e.g. "Tidy whitespace" disabled) carries its own // separator, so appending another makes a double space. - let needsSpace = settings.appendTrailingSpace && processed.last?.isWhitespace != true - let final = needsSpace ? processed + " " : processed + let needsSpace = settings.appendTrailingSpace && final.last?.isWhitespace != true + let toInsert = needsSpace ? final + " " : final started = ContinuousClock.now - let result = try await output.insert(final, submit: submit) + let result = try await output.insert(toInsert, submit: submit) timing.insert = ContinuousClock.now - started var inserted = transcript - inserted.text = processed + inserted.text = final lastTranscript = inserted onEvent(.inserted(inserted, timing)) if result == .copied { showCopied() } else { becomeIdle() } @@ -564,6 +618,10 @@ public final class DictationCoordinator { } } + private static func wordCount(_ text: String) -> Int { + text.split(whereSeparator: \.isWhitespace).count + } + /// Idle if the engine can take another dictation, otherwise unavailable /// with the engine's own reason. private func becomeIdle() { @@ -581,6 +639,7 @@ public final class DictationCoordinator { maxDurationTask?.cancel() stopWarmupLoop() partialTranscript = nil + willPolish = false // Same restore as at release, for the paths that never transcribe: // an interrupted chord, a hotkey change, `stop()`. if let outputMuter { diff --git a/Sources/PladderCore/Models/DictationState.swift b/Sources/PladderCore/Models/DictationState.swift index 525ce55..c585390 100644 --- a/Sources/PladderCore/Models/DictationState.swift +++ b/Sources/PladderCore/Models/DictationState.swift @@ -6,6 +6,9 @@ public enum DictationState: Equatable, Sendable { case unavailable(UnavailableReason) case recording(level: Float) case transcribing + /// The model is cleaning the transcript; only a dictation started with + /// the polish hotkey gets here. + case polishing case inserting /// Transcript is on the clipboard for the user to paste; shown briefly, /// then returns to idle. Reached when Pladder cannot paste it itself, @@ -21,7 +24,7 @@ public enum DictationState: Equatable, Sendable { public var isBusy: Bool { switch self { - case .recording, .transcribing, .inserting: return true + case .recording, .transcribing, .polishing, .inserting: return true default: return false } } diff --git a/Sources/PladderCore/Models/Settings.swift b/Sources/PladderCore/Models/Settings.swift index 024cd47..59032af 100644 --- a/Sources/PladderCore/Models/Settings.swift +++ b/Sources/PladderCore/Models/Settings.swift @@ -40,6 +40,9 @@ public struct Settings: Codable, Sendable, Equatable { /// end with Return, which sends a chat message or runs a command. Empty /// turns it off. public var submitKey: Hotkey + /// A dictation started with this chord runs through the on-device model + /// before it is pasted. Empty, the default, means there is no such chord. + public var polishHotkey: Hotkey /// Processor IDs that are turned off. Absent means enabled. public var disabledProcessors: Set public var dictionary: [DictionaryEntry] @@ -65,6 +68,7 @@ public struct Settings: Codable, Sendable, Equatable { engineID: EngineID, hotkey: Hotkey = .optionSpace, submitKey: Hotkey = .rightOption, + polishHotkey: Hotkey = Hotkey(keyCodes: []), disabledProcessors: Set = [], dictionary: [DictionaryEntry] = [], appendTrailingSpace: Bool = true, @@ -79,6 +83,7 @@ public struct Settings: Codable, Sendable, Equatable { self.engineID = engineID self.hotkey = hotkey self.submitKey = submitKey + self.polishHotkey = polishHotkey self.disabledProcessors = disabledProcessors self.dictionary = dictionary self.appendTrailingSpace = appendTrailingSpace @@ -94,7 +99,7 @@ public struct Settings: Codable, Sendable, Equatable { // Decoding tolerates missing keys so adding a field in a later version // never makes an existing settings file unreadable. private enum CodingKeys: String, CodingKey { - case engineID, hotkey, submitKey, disabledProcessors, dictionary, appendTrailingSpace, launchAtLogin, playSounds, appearance + case engineID, hotkey, submitKey, polishHotkey, disabledProcessors, dictionary, appendTrailingSpace, launchAtLogin, playSounds, appearance case overlayStyle, overlayGlass, overlayAnimationSpeed, muteOutputWhileDictating } @@ -107,6 +112,8 @@ public struct Settings: Codable, Sendable, Equatable { // Unlike the hotkey, an empty submit key is meaningful: it is how the // feature is switched off. submitKey = try c.decodeIfPresent(Hotkey.self, forKey: .submitKey) ?? .rightOption + // Empty means off, like the submit key. + polishHotkey = try c.decodeIfPresent(Hotkey.self, forKey: .polishHotkey) ?? Hotkey(keyCodes: []) disabledProcessors = try c.decodeIfPresent(Set.self, forKey: .disabledProcessors) ?? [] dictionary = try c.decodeIfPresent([DictionaryEntry].self, forKey: .dictionary) ?? [] appendTrailingSpace = try c.decodeIfPresent(Bool.self, forKey: .appendTrailingSpace) ?? true diff --git a/Sources/PladderCore/Protocols/TranscriptRefiner.swift b/Sources/PladderCore/Protocols/TranscriptRefiner.swift new file mode 100644 index 0000000..4b72b86 --- /dev/null +++ b/Sources/PladderCore/Protocols/TranscriptRefiner.swift @@ -0,0 +1,14 @@ +import Foundation + +/// A second pass over the processed transcript by something slow, such as an +/// on-device language model. Runs only for a dictation started with the +/// polish hotkey; the normal path never calls it. +public protocol TranscriptRefiner: Sendable { + /// Called at key-down of the polish hotkey, while the user is still + /// speaking, so the model's load is off the release path. Fire and forget. + func prepare() async + /// The polished text, or nil when the model could not help (unavailable, + /// refused, timed out, empty answer). Nil means "paste what came in". + /// Never throws: a cleanup step must not lose a dictation. + func refine(_ text: String) async -> String? +} diff --git a/Tests/PladderCoreTests/DictationCoordinatorTests.swift b/Tests/PladderCoreTests/DictationCoordinatorTests.swift index 465b228..ee4a105 100644 --- a/Tests/PladderCoreTests/DictationCoordinatorTests.swift +++ b/Tests/PladderCoreTests/DictationCoordinatorTests.swift @@ -91,6 +91,47 @@ final class FakeHotkey: HotkeyMonitor, @unchecked Sendable { } } +/// Stands in for the on-device model: records what it was asked, answers +/// `result` after `delay`. +final class FakeRefiner: TranscriptRefiner, @unchecked Sendable { + private let lock = NSLock() + private var _calls: [String] = [] + private var _prepareCount = 0 + private let result: String? + private let delay: Duration + var calls: [String] { lock.withLock { _calls } } + var prepareCount: Int { lock.withLock { _prepareCount } } + + init(result: String? = "polished", delay: Duration = .zero) { + self.result = result + self.delay = delay + } + + func prepare() async { lock.withLock { _prepareCount += 1 } } + + func refine(_ text: String) async -> String? { + lock.withLock { _calls.append(text) } + if delay > .zero { try? await Task.sleep(for: delay) } + return result + } +} + +/// Keeps every event the coordinator emits, for assertions about timing. +final class RecordedEvents: @unchecked Sendable { + private let lock = NSLock() + private var _events: [DictationCoordinator.Event] = [] + var events: [DictationCoordinator.Event] { lock.withLock { _events } } + func append(_ e: DictationCoordinator.Event) { lock.withLock { _events.append(e) } } + + /// The timing of the last `inserted` event, if there was one. + var lastTiming: DictationCoordinator.CycleTiming? { + for event in events.reversed() { + if case .inserted(_, let timing) = event { return timing } + } + return nil + } +} + /// Counts the two calls the coordinator makes. The real controller's timing /// is tested on its own; what matters here is that both ends are called, from /// every path that ends a recording. @@ -230,7 +271,9 @@ private func makeCoordinator( output: FakeOutput = FakeOutput(), capture: FakeCapture = FakeCapture(), hotkeyMonitor: FakeHotkey? = nil, - outputMuter: (any OutputMuter)? = nil + outputMuter: (any OutputMuter)? = nil, + refiner: (any TranscriptRefiner)? = nil, + events: RecordedEvents? = nil ) -> (DictationCoordinator, FakeOutput, FakeCapture) { let registry = EngineRegistry([ .init(id: EchoEngine.engineID, displayName: "Echo", detail: "") { @@ -244,10 +287,12 @@ private func makeCoordinator( capture: capture, output: output, outputMuter: outputMuter, + refiner: refiner, hotkeyMonitor: hotkeyMonitor ?? FakeHotkey(), makePipeline: { s in ProcessorPipeline([DictionaryReplacer(entries: s.dictionary), WhitespaceNormalizer()]) - } + }, + onEvent: { event in events?.append(event) } ) return (coordinator, output, capture) } @@ -832,6 +877,7 @@ final class EventLog: @unchecked Sendable { return } #expect(timing.engine >= .milliseconds(50)) + #expect(timing.polish == nil) #expect(output.inserted == ["hello world"]) } @@ -1102,6 +1148,280 @@ final class EventLog: @unchecked Sendable { } } +// MARK: - Polish hotkey + +@MainActor +@Suite struct PolishHotkeyTests { + /// Long enough to clear `minimumPolishWords`. + private static let sentence = "send it on Friday please" + private static let polishChord = Hotkey(0x3B, 0x31) + + private static func settings(polish: Hotkey = polishChord) -> Settings { + var s = Settings(engineID: EchoEngine.engineID) + s.polishHotkey = polish + return s + } + + @Test func polishHotkeyRoutesThroughTheRefiner() async { + let refiner = FakeRefiner() + let events = RecordedEvents() + let hotkey = FakeHotkey() + let (c, output, _) = makeCoordinator( + engineText: Self.sentence, settings: Self.settings(), + hotkeyMonitor: hotkey, refiner: refiner, events: events) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press(.polish) + #expect(await waitUntil { c.state.isRecording }) + hotkey.release(.polish) + #expect(await waitUntil { c.inFlight != nil }) + await c.inFlight?.value + #expect(output.inserted == ["polished "]) + #expect(refiner.calls == [Self.sentence]) + #expect(c.lastTranscript?.text == "polished") + #expect(events.lastTiming?.polish != nil) + } + + @Test func normalHotkeyNeverCallsTheRefiner() async { + let refiner = FakeRefiner() + let events = RecordedEvents() + let hotkey = FakeHotkey() + let (c, output, _) = makeCoordinator( + engineText: Self.sentence, settings: Self.settings(), + hotkeyMonitor: hotkey, refiner: refiner, events: events) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press() + #expect(await waitUntil { c.state.isRecording }) + #expect(!c.willPolish) + hotkey.release() + #expect(await waitUntil { c.inFlight != nil }) + await c.inFlight?.value + #expect(output.inserted == [Self.sentence + " "]) + #expect(refiner.calls.isEmpty) + #expect(refiner.prepareCount == 0) + #expect(events.lastTiming != nil) + #expect(events.lastTiming?.polish == nil) + } + + @Test func blankTranscriptSkipsTheRefiner() async { + let refiner = FakeRefiner() + let (c, output, _) = makeCoordinator(engineText: "", settings: Self.settings(), refiner: refiner) + c.start() + #expect(await waitUntil { c.state == .idle }) + await c.hotkeyPressed(role: .polish) + c.hotkeyReleased() + await c.inFlight?.value + #expect(output.inserted.isEmpty) + #expect(refiner.calls.isEmpty) + #expect(c.state == .idle) + } + + @Test func shortTranscriptSkipsTheRefiner() async { + let refiner = FakeRefiner() + let events = RecordedEvents() + let (c, output, _) = makeCoordinator( + engineText: "one two three", settings: Self.settings(), refiner: refiner, events: events) + c.start() + #expect(await waitUntil { c.state == .idle }) + await c.hotkeyPressed(role: .polish) + c.hotkeyReleased() + await c.inFlight?.value + #expect(output.inserted == ["one two three "]) + #expect(refiner.calls.isEmpty) + #expect(events.lastTiming?.polish == .zero) + } + + @Test func fourWordsAreRefined() async { + let refiner = FakeRefiner() + let (c, output, _) = makeCoordinator( + engineText: "one two three four", settings: Self.settings(), refiner: refiner) + c.start() + #expect(await waitUntil { c.state == .idle }) + await c.hotkeyPressed(role: .polish) + c.hotkeyReleased() + await c.inFlight?.value + #expect(refiner.calls == ["one two three four"]) + #expect(output.inserted == ["polished "]) + } + + @Test func refinerReturningNilPastesThePlainText() async { + // What an unavailable, refusing or timed-out model looks like here. + let refiner = FakeRefiner(result: nil) + let events = RecordedEvents() + let (c, output, _) = makeCoordinator( + engineText: Self.sentence, settings: Self.settings(), refiner: refiner, events: events) + c.start() + #expect(await waitUntil { c.state == .idle }) + await c.hotkeyPressed(role: .polish) + c.hotkeyReleased() + await c.inFlight?.value + #expect(refiner.calls == [Self.sentence]) + #expect(output.inserted == [Self.sentence + " "]) + #expect(c.lastTranscript?.text == Self.sentence) + guard case .inserted = events.events.last else { + Issue.record("expected an inserted event, got \(String(describing: events.events.last))") + return + } + #expect(c.state == .idle) + } + + @Test func withoutARefinerThePolishKeyIsAPlainDictation() async { + let (c, output, _) = makeCoordinator(engineText: Self.sentence, settings: Self.settings()) + c.start() + #expect(await waitUntil { c.state == .idle }) + await c.hotkeyPressed(role: .polish) + c.hotkeyReleased() + await c.inFlight?.value + #expect(output.inserted == [Self.sentence + " "]) + } + + @Test func polishPressWarmsTheRefiner() async { + let refiner = FakeRefiner() + let (c, _, _) = makeCoordinator(engineText: Self.sentence, settings: Self.settings(), refiner: refiner) + c.start() + #expect(await waitUntil { c.state == .idle }) + await c.hotkeyPressed(role: .polish) + #expect(await waitUntil { refiner.prepareCount == 1 }) + #expect(c.state.isRecording) + #expect(refiner.calls.isEmpty) + } + + @Test func polishingStateIsPublishedWhileTheModelRuns() async { + let refiner = FakeRefiner(delay: .milliseconds(200)) + let (c, output, _) = makeCoordinator(engineText: Self.sentence, settings: Self.settings(), refiner: refiner) + c.start() + #expect(await waitUntil { c.state == .idle }) + await c.hotkeyPressed(role: .polish) + #expect(c.willPolish) + c.hotkeyReleased() + #expect(await waitUntil { c.state == .polishing }) + #expect(c.willPolish) + #expect(output.inserted.isEmpty) + await c.inFlight?.value + #expect(c.state == .idle) + #expect(!c.willPolish) + #expect(output.inserted == ["polished "]) + } + + @Test func polishChordIsHandedToTheMonitor() async { + let fake = FakeHotkey() + let (c, _, _) = makeCoordinator(settings: Self.settings(), hotkeyMonitor: fake) + c.start() + #expect(await waitUntil { c.state == .idle }) + #expect(fake.lastChords == [.dictate: .optionSpace, .polish: Self.polishChord]) + } + + @Test func emptyPolishChordIsNotRegistered() async { + let fake = FakeHotkey() + let (c, _, _) = makeCoordinator(hotkeyMonitor: fake) + c.start() + #expect(await waitUntil { c.state == .idle }) + #expect(Array(fake.lastChords.keys) == [.dictate]) + } + + @Test func polishChordEqualToTheDictateChordIsNotRegistered() async { + let fake = FakeHotkey() + let (c, _, _) = makeCoordinator(settings: Self.settings(polish: .optionSpace), hotkeyMonitor: fake) + c.start() + #expect(await waitUntil { c.state == .idle }) + #expect(Array(fake.lastChords.keys) == [.dictate]) + } + + @Test func polishChordChangeRestartsTheMonitor() async { + let fake = FakeHotkey() + let (c, output, capture) = makeCoordinator(hotkeyMonitor: fake) + c.start() + #expect(await waitUntil { c.state == .idle }) + #expect(fake.startCount == 1) + await c.hotkeyPressed() + #expect(c.state.isRecording) + c.settings.polishHotkey = Self.polishChord + #expect(fake.startCount == 2) + #expect(fake.lastChords[.polish] == Self.polishChord) + // Dropped like a hotkey change: the restarted monitor would never + // deliver the pending release. + #expect(await waitUntil { c.state == .idle }) + #expect(await capture.stopCount == 1) + #expect(output.inserted.isEmpty) + } + + @Test func theOverrideAppliesOnlyToTheDictateChord() async { + let fake = FakeHotkey() + let standIn = Hotkey(0x3B, 0x38, 0x31) + let (c, _, _) = makeCoordinator(settings: Self.settings(), hotkeyMonitor: fake) + c.hotkeyOverride = standIn + c.start() + #expect(await waitUntil { c.state == .idle }) + #expect(fake.lastChords[.dictate] == standIn) + #expect(fake.lastChords[.polish] == Self.polishChord) + } + + @Test func aReleaseFromTheOtherChordIsIgnored() async { + let hotkey = FakeHotkey() + let refiner = FakeRefiner() + let (c, output, _) = makeCoordinator( + engineText: Self.sentence, settings: Self.settings(), hotkeyMonitor: hotkey, refiner: refiner) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press(.dictate) + #expect(await waitUntil { c.state.isRecording }) + hotkey.release(.polish) + hotkey.cancel(.polish) + // Give the stream a moment to deliver both; neither may end the take. + try? await Task.sleep(for: .milliseconds(50)) + #expect(c.state.isRecording) + hotkey.release(.dictate) + #expect(await waitUntil { c.inFlight != nil }) + await c.inFlight?.value + #expect(output.inserted == [Self.sentence + " "]) + #expect(refiner.calls.isEmpty) + } + + @Test func cancelledPolishPressDropsTheRecording() async { + let hotkey = FakeHotkey() + let refiner = FakeRefiner() + let (c, output, capture) = makeCoordinator( + engineText: Self.sentence, settings: Self.settings(), hotkeyMonitor: hotkey, refiner: refiner) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press(.polish) + #expect(await waitUntil { c.state.isRecording }) + hotkey.cancel(.polish) + #expect(await waitUntil { c.state == .idle }) + #expect(await capture.stopCount == 1) + #expect(output.inserted.isEmpty) + #expect(refiner.calls.isEmpty) + #expect(!c.willPolish) + } + + @Test func submitWorksOnThePolishPath() async { + let hotkey = FakeHotkey() + let (c, output, _) = makeCoordinator( + engineText: Self.sentence, settings: Self.settings(), hotkeyMonitor: hotkey, refiner: FakeRefiner()) + c.start() + #expect(await waitUntil { c.state == .idle }) + hotkey.press(.polish) + #expect(await waitUntil { c.state.isRecording }) + hotkey.release(.polish, submit: true) + #expect(await waitUntil { c.inFlight != nil }) + await c.inFlight?.value + #expect(output.inserted == ["polished "]) + #expect(output.submitted == [true]) + } + + @Test func cancelRecordingClearsWillPolish() async { + let (c, _, _) = makeCoordinator(settings: Self.settings(), refiner: FakeRefiner()) + c.start() + #expect(await waitUntil { c.state == .idle }) + await c.hotkeyPressed(role: .polish) + #expect(c.willPolish) + await c.cancelRecording() + #expect(!c.willPolish) + #expect(c.state == .idle) + } +} + @Suite struct SettingsStoreTests { @Test func roundTrip() throws { let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -1113,6 +1433,7 @@ final class EventLog: @unchecked Sendable { var changed = defaults changed.hotkey = .rightOption changed.submitKey = Hotkey(0x24) + changed.polishHotkey = Hotkey(0x3B, 0x31) changed.dictionary = [DictionaryEntry(from: "a", to: "b")] try store.save(changed) #expect(store.load() == changed) @@ -1126,6 +1447,7 @@ final class EventLog: @unchecked Sendable { #expect(decoded.dictionary.count == 1) #expect(decoded.hotkey == .optionSpace) #expect(decoded.submitKey == .rightOption) + #expect(decoded.polishHotkey.isEmpty) #expect(decoded.appendTrailingSpace == true) #expect(decoded.appearance == .system) #expect(decoded.overlayStyle == .compact) From 8e58deff0ab0628033ac1b7481921fdf0e272b53 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:54:46 +0200 Subject: [PATCH 3/7] Polish: PladderRefine wraps Apple's on-device model A new target, the only one that imports FoundationModels. `OnDeviceLanguageModel` is instructions plus options: availability as a value, a prewarmed session, a plain and a guided `respond`, an eight second budget, and the drain of a call that ran past it. #37 builds a second instance with its own instructions. `TranscriptPolisher` is the polish hotkey's `TranscriptRefiner` on top of it, and `PolishPostFilter` strips a leading think block and U+200B, U+200C, U+200D and U+FEFF. The lessons of the removed tidy pass (8537063), and how each is kept: - `respond` awaited on an actor's executor turned sub-second replies into timeouts: the call and the timer run in detached tasks. - The system model serialises requests: a call abandoned at the budget is parked in a Mutex and the next call drains it first. - One session per exchange: `prepare()` warms one at key-down, `refine` takes it and drops it. - Guided generation with greedy sampling: the model fills a `@Generable` `cleanedText` field; a decoding or unsupported-guide failure retries once with plain `respond(to:)` on a fresh session. Anything else (unavailable, refused, timed out, empty) returns nil and the coordinator pastes the text as dictated. One log line per call, numbers and the error's case name only, never the transcript. `pladder-cli polish [--instructions ]` runs the prompt cold and warm and prints both timings; nothing in `swift test` calls the model. The prompt is the plan's rule list tuned with that harness: three inline examples (a self-correction, a spoken list, a question with a request that must be kept), German fillers and "nein" in the lists, and the language named in the user message ("Transcript, in German:") from NLLanguageRecognizer, because the English examples otherwise pulled a German transcript into English. Co-Authored-By: Claude Opus 5.5 (1M context) --- Package.swift | 12 +- Sources/PladderCLI/main.swift | 69 ++++++ .../PladderRefine/OnDeviceLanguageModel.swift | 171 +++++++++++++ Sources/PladderRefine/PolishPostFilter.swift | 27 ++ .../PladderRefine/TranscriptPolisher.swift | 232 ++++++++++++++++++ .../PolishPostFilterTests.swift | 64 +++++ 6 files changed, 571 insertions(+), 4 deletions(-) create mode 100644 Sources/PladderRefine/OnDeviceLanguageModel.swift create mode 100644 Sources/PladderRefine/PolishPostFilter.swift create mode 100644 Sources/PladderRefine/TranscriptPolisher.swift create mode 100644 Tests/PladderRefineTests/PolishPostFilterTests.swift diff --git a/Package.swift b/Package.swift index 1d864ef..7687d9a 100644 --- a/Package.swift +++ b/Package.swift @@ -27,8 +27,7 @@ let package = Package( // Microphone capture and resampling. .target(name: "PladderAudio", dependencies: ["PladderCore"]), - // Hotkey, pasteboard output, permissions, optional Foundation Models - // processor. AppKit lives here. + // Hotkey, pasteboard output, permissions. AppKit lives here. .target( name: "PladderSystem", dependencies: ["PladderCore"], @@ -46,10 +45,14 @@ let package = Package( ] ), + // Apple's on-device model behind the polish hotkey. The only target + // that imports FoundationModels. + .target(name: "PladderRefine", dependencies: ["PladderCore"]), + // The menu bar app. .executableTarget( name: "Pladder", - dependencies: ["PladderCore", "PladderAudio", "PladderSystem", "PladderEngines"], + dependencies: ["PladderCore", "PladderAudio", "PladderSystem", "PladderEngines", "PladderRefine"], // Info.plist is copied into the .app by scripts/bundle.sh; SwiftPM // refuses to treat it as a resource, so keep it out of the bundle. exclude: ["Resources/Info.plist"], @@ -70,12 +73,13 @@ let package = Package( // engines, or run the benchmark (see docs/BENCHMARKS.md). .executableTarget( name: "PladderCLI", - dependencies: ["PladderCore", "PladderEngines", "PladderAudio", "PladderBench"] + dependencies: ["PladderCore", "PladderEngines", "PladderAudio", "PladderBench", "PladderRefine"] ), .testTarget(name: "PladderCoreTests", dependencies: ["PladderCore"]), .testTarget(name: "PladderAudioTests", dependencies: ["PladderAudio"]), .testTarget(name: "PladderBenchTests", dependencies: ["PladderBench"]), .testTarget(name: "PladderSystemTests", dependencies: ["PladderSystem"]), + .testTarget(name: "PladderRefineTests", dependencies: ["PladderRefine"]), ] ) diff --git a/Sources/PladderCLI/main.swift b/Sources/PladderCLI/main.swift index 6822b37..bfd9374 100644 --- a/Sources/PladderCLI/main.swift +++ b/Sources/PladderCLI/main.swift @@ -5,6 +5,7 @@ import PladderAudio import PladderBench import PladderCore import PladderEngines +import PladderRefine // Developer tool. // @@ -27,6 +28,11 @@ import PladderEngines // how many there were and what they cost. The // `identical:` column then also proves the live // passes leave the release's windows alone. +// pladder-cli polish run the polish hotkey's prompt over a transcript +// with Apple's on-device model: once cold, once +// after prepare() and a two-second wait, the way +// a real press warms it. Prints both timings. +// [--instructions ] try another system prompt before committing it. // // Fixtures are audio files with a sibling .txt holding the spoken script, as // produced by scripts/make-fixtures.sh. @@ -36,6 +42,7 @@ func usage() -> Never { usage: pladder-cli