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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# wisp Development Guidelines

Auto-generated from all feature plans. Last updated: 2026-03-28
Auto-generated from all feature plans. Last updated: 2026-03-29

## Active Technologies
- Swift 6.2 with strict concurrency checking + AppKit (NSPanel, Core Animation), WhisperKit (existing) (002-status-indicator-gui)
- Swift 6.2 with strict concurrency checking enabled + WhisperKit (existing), KeyboardShortcuts 2.x (Sindre Sorhus — re-add to Package.swift), AVFoundation (existing), CoreAudio (system framework), Apple FoundationModels (existing), AppKit + SwiftUI (003-config-screen)
- UserDefaults — two explicit keys (`selectedMicrophoneUID`, `cleanupPrompt`); hotkey managed automatically by KeyboardShortcuts library (003-config-screen)
- Swift 6.1+ with strict concurrency checking enabled + AppKit (NSWindow, NSMenu), SwiftUI (List, Button), Foundation (Codable, JSONEncoder/Decoder, FileManager) (004-transcription-log)
- JSON file — `~/Library/Application Support/Wisp/transcription-log.json` (004-transcription-log)
- Swift 6.1+ with strict concurrency checking enabled + AppKit (NSPanel, Core Animation), WhisperKit (existing), AVFoundation (existing) (005-escape-cancel-countdown)
- JSON file at `~/Library/Application Support/Wisp/transcription-log.json` (existing) (005-escape-cancel-countdown)

- Swift 5.9+ with strict concurrency checking + WhisperKit (Argmax), KeyboardShortcuts (Sindre Sorhus), AppKi (001-core-dictation-flow)

Expand All @@ -27,9 +29,9 @@ tests/
Swift 5.9+ with strict concurrency checking: Follow standard conventions

## Recent Changes
- 005-escape-cancel-countdown: Added Swift 6.1+ with strict concurrency checking enabled + AppKit (NSPanel, Core Animation), WhisperKit (existing), AVFoundation (existing)
- 004-transcription-log: Added Swift 6.1+ with strict concurrency checking enabled + AppKit (NSWindow, NSMenu), SwiftUI (List, Button), Foundation (Codable, JSONEncoder/Decoder, FileManager)
- 003-config-screen: Added Swift 6.2 with strict concurrency checking enabled + WhisperKit (existing), KeyboardShortcuts 2.x (Sindre Sorhus — re-add to Package.swift), AVFoundation (existing), CoreAudio (system framework), Apple FoundationModels (existing), AppKit + SwiftUI
- 002-status-indicator-gui: Added Swift 6.2 with strict concurrency checking + AppKit (NSPanel, Core Animation), WhisperKit (existing)


<!-- MANUAL ADDITIONS START -->
Expand Down
148 changes: 147 additions & 1 deletion Wisp/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var overlayWindow: StatusOverlayWindow?
private var logStore = TranscriptionLogStore()
private var logWindow: LogWindow?
private var escapeMonitor: Any?

// Cancel-countdown state (set when the first Escape is pressed during recording)
private var pendingAudioBuffer: Data?
private var shouldPasteAfterProcessing = false
private var cancelCountdownTask: Task<Void, Never>?

func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
Expand Down Expand Up @@ -126,6 +132,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
hotkeyService?.register()

escapeMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard event.keyCode == 53 else { return } // 53 = Escape
Task { @MainActor [weak self] in
self?.handleEscapeKey()
}
}

// Preload Whisper model and warm up Core ML compilation
Task {
do {
Expand Down Expand Up @@ -155,6 +168,102 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

// MARK: - Dictation Flow

private func handleEscapeKey() {
if state == .recording {
beginCancelCountdown()
} else if state == .cancelling {
restoreFromCancelling()
}
}

private func beginCancelCountdown() {
currentSession?.stop()
guard let session = currentSession else {
print("[Wisp] No active session to cancel")
return
}

menuBarController?.playStopSound()

if session.audioDuration < 0.5 {
print("[Wisp] Recording too short, discarding without countdown")
handleResult(.discarded(reason: .tooShort))
return
}

guard let audioBuffer = audioCaptureService?.stopRecording() else {
print("[Wisp] No audio buffer returned on cancel")
handleResult(.failed(error: .microphoneUnavailable))
return
}

guard case .success(let newState) = state.transition(to: .cancelling) else {
print("[Wisp] State transition to cancelling failed")
return
}

print("[Wisp] Starting cancel countdown")
state = newState
menuBarController?.updateState(state)
overlayWindow?.show(state: .cancelling)

pendingAudioBuffer = audioBuffer
shouldPasteAfterProcessing = false

cancelCountdownTask = Task { [weak self] in
do {
try await Task.sleep(for: .seconds(3))
} catch {
return // Cancelled by second Escape press
}
await MainActor.run { [weak self] in
self?.commitCancelledTranscription()
}
}
}

private func commitCancelledTranscription() {
guard state == .cancelling else { return }
guard case .success(let newState) = state.transition(to: .processing) else { return }

print("[Wisp] Cancel countdown expired — transcribing silently without paste")
overlayWindow?.hide()
state = newState
// Menu bar is intentionally not updated here: silent background processing
// should not surface a visual indicator. handleResult restores .idle on completion.

cancelCountdownTask = nil
guard let buffer = pendingAudioBuffer else { return }
pendingAudioBuffer = nil

Task {
await transcribeAndSave(audioBuffer: buffer)
}
}

private func restoreFromCancelling() {
cancelCountdownTask?.cancel()
cancelCountdownTask = nil

guard case .success(let newState) = state.transition(to: .processing) else {
print("[Wisp] State transition from cancelling to processing failed")
return
}

print("[Wisp] Cancel reversed via second Escape — will transcribe and paste")
shouldPasteAfterProcessing = true
state = newState
menuBarController?.updateState(state)
overlayWindow?.show(state: .transcribing)

guard let buffer = pendingAudioBuffer else { return }
pendingAudioBuffer = nil

Task {
await transcribeAndPaste(audioBuffer: buffer)
}
}

private func handleHotkeyToggle() {
print("[Wisp] handleHotkeyToggle, state: \(state)")
switch state {
Expand All @@ -164,6 +273,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
startRecording()
case .recording:
stopRecordingAndTranscribe()
case .cancelling:
print("[Wisp] Ignoring hotkey during cancel countdown")
case .processing:
print("[Wisp] Ignoring hotkey during processing")
}
Expand Down Expand Up @@ -286,6 +397,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
print("[Wisp] Cleaned text: \(cleanedText)")

await MainActor.run {
shouldPasteAfterProcessing = true
pasteService?.paste(text: cleanedText) { [weak self] fallbackToClipboard in
if fallbackToClipboard {
self?.notificationService?.show(
Expand All @@ -305,13 +417,47 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}

private func transcribeAndSave(audioBuffer: Data) async {
do {
let rawText = try await transcriptionService?.transcribe(audioBuffer: audioBuffer)
guard let rawText, !rawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
// No speech detected — silently reset to idle with no notification
await MainActor.run { silentlyResetToIdle() }
return
}
let cleanedText: String
if let service = textCleanupService {
cleanedText = (try? await service.cleanup(rawText)) ?? rawText
} else {
cleanedText = rawText
}
await MainActor.run {
handleResult(.completed(text: cleanedText))
}
} catch {
// Transcription failed during cancelled recording — silently discard per spec
await MainActor.run { silentlyResetToIdle() }
}
}

private func silentlyResetToIdle() {
currentSession = nil
if case .success(let newState) = state.transition(to: .idle) {
state = newState
} else {
state = .idle
}
menuBarController?.updateState(.idle)
}

private func handleResult(_ result: TranscriptionResult) {
currentSession?.complete(with: result)
currentSession = nil

switch result {
case .completed(let text):
logStore.append(text: text)
logStore.append(text: text, wasPasted: shouldPasteAfterProcessing)
shouldPasteAfterProcessing = false
case .discarded(let reason):
switch reason {
case .tooShort:
Expand Down
7 changes: 7 additions & 0 deletions Wisp/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ enum AppState: Equatable, Sendable {
case loading
case idle
case recording
case cancelling
case processing

enum TransitionError: Error, Equatable {
Expand All @@ -16,8 +17,14 @@ enum AppState: Equatable, Sendable {
return .success(.idle)
case (.idle, .recording):
return .success(.recording)
case (.recording, .cancelling):
return .success(.cancelling)
case (.recording, .processing):
return .success(.processing)
case (.cancelling, .processing):
return .success(.processing)
case (.cancelling, .idle):
return .success(.idle)
case (.processing, .idle):
return .success(.idle)
default:
Expand Down
3 changes: 3 additions & 0 deletions Wisp/Models/IndicatorState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Foundation
enum IndicatorState: Equatable, Sendable {
case modelLoading
case recording
case cancelling
case transcribing
case error(String)
case hidden
Expand All @@ -15,6 +16,8 @@ enum IndicatorState: Equatable, Sendable {
return .hidden
case .recording:
return .recording
case .cancelling:
return .cancelling
case .processing:
return .transcribing
}
Expand Down
15 changes: 14 additions & 1 deletion Wisp/Models/TranscriptionLogEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,23 @@ struct TranscriptionLogEntry: Codable, Identifiable, Sendable {
let id: UUID
let text: String
let timestamp: Date
let wasPasted: Bool

init(id: UUID = UUID(), text: String, timestamp: Date = Date()) {
init(id: UUID = UUID(), text: String, timestamp: Date = Date(), wasPasted: Bool = true) {
self.id = id
self.text = text
self.timestamp = timestamp
self.wasPasted = wasPasted
}

// Custom decoder for backward compatibility: existing log entries that have no
// "wasPasted" key are treated as pasted (the only outcome that existed before
// this feature was added).
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
text = try container.decode(String.self, forKey: .text)
timestamp = try container.decode(Date.self, forKey: .timestamp)
wasPasted = try container.decodeIfPresent(Bool.self, forKey: .wasPasted) ?? true
}
}
4 changes: 2 additions & 2 deletions Wisp/Models/TranscriptionLogStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ final class TranscriptionLogStore {
self.entries = Self.load(from: url)
}

func append(text: String) {
let entry = TranscriptionLogEntry(text: text)
func append(text: String, wasPasted: Bool = true) {
let entry = TranscriptionLogEntry(text: text, wasPasted: wasPasted)
entries.insert(entry, at: 0)
if entries.count > 500 {
entries.removeLast()
Expand Down
5 changes: 5 additions & 0 deletions Wisp/UI/LogView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ private struct LogEntryRow: View {
Text(entry.text)
.font(.body)
.textSelection(.enabled)
if !entry.wasPasted {
Text("not pasted")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
Spacer()
Button {
Expand Down
5 changes: 5 additions & 0 deletions Wisp/UI/MenuBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ final class MenuBarController {
systemSymbolName: "mic.fill",
accessibilityDescription: "Wisp — Recording"
)
case .cancelling:
button.image = NSImage(
systemSymbolName: "waveform",
accessibilityDescription: "Wisp — Cancelling"
)
case .processing:
button.image = NSImage(
systemSymbolName: "ellipsis.circle",
Expand Down
Loading
Loading