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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Auto-generated from all feature plans. Last updated: 2026-03-29
- JSON file at `~/Library/Application Support/Wisp/transcription-log.json` (existing) (005-escape-cancel-countdown)
- Swift 6.1+ with strict concurrency checking enabled + AppKit (NSSound, NSStatusItem, NSMenu), ServiceManagement (SMAppService), AVFoundation (existing) (006-polish-and-cleanup)
- UserDefaults (startup preference, keyed on existing PreferencesStore) (006-polish-and-cleanup)
- Swift 6.1+ with strict concurrency checking enabled + WhisperKit (existing), FoundationModels (existing), AppKit + SwiftUI (existing), KeyboardShortcuts (existing) (007-custom-word-dictionary)
- UserDefaults (`com.wisp.wordDictionary` → `[String]`) (007-custom-word-dictionary)

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

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

## Recent Changes
- 007-custom-word-dictionary: Added Swift 6.1+ with strict concurrency checking enabled + WhisperKit (existing), FoundationModels (existing), AppKit + SwiftUI (existing), KeyboardShortcuts (existing)
- 006-polish-and-cleanup: Added Swift 6.1+ with strict concurrency checking enabled + AppKit (NSSound, NSStatusItem, NSMenu), ServiceManagement (SMAppService), AVFoundation (existing)
- 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)


<!-- MANUAL ADDITIONS START -->
Expand Down
17 changes: 11 additions & 6 deletions Wisp/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var currentSession: DictationSession?

private var preferencesStore: PreferencesStore?
private var wordDictionary = WordDictionaryStore()
private var microphoneList: MicrophoneList?
private var hotkeyService: HotkeyService?
private var audioCaptureService: AudioCaptureService?
Expand Down Expand Up @@ -93,12 +94,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
if logWindow == nil {
logWindow = LogWindow()
}
logWindow?.show(entries: logStore.entries)
logWindow?.show(logStore: logStore, wordDictionary: wordDictionary)
}

@objc private func openPreferences() {
guard let store = preferencesStore, let mics = microphoneList else { return }
PreferencesWindow.show(preferences: store, microphoneList: mics)
PreferencesWindow.show(preferences: store, microphoneList: mics, wordDictionary: wordDictionary)
}

@objc private func toggleLaunchOnStartup() {
Expand Down Expand Up @@ -416,7 +417,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private func transcribeAndPaste(audioBuffer: Data) async {
do {
print("[Wisp] Loading model and transcribing...")
let rawText = try await transcriptionService?.transcribe(audioBuffer: audioBuffer)
let hints = await MainActor.run { wordDictionary.words }
let rawText = try await transcriptionService?.transcribe(
audioBuffer: audioBuffer, wordHints: hints)
print("[Wisp] Raw transcription: \(rawText ?? "<nil>")")
guard let rawText, !rawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
Expand All @@ -427,7 +430,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
print("[Wisp] Cleaning up text...")
let cleanedText: String
if let service = textCleanupService {
cleanedText = (try? await service.cleanup(rawText)) ?? rawText
cleanedText = (try? await service.cleanup(rawText, wordHints: hints)) ?? rawText
} else {
cleanedText = rawText
}
Expand Down Expand Up @@ -456,15 +459,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {

private func transcribeAndSave(audioBuffer: Data) async {
do {
let rawText = try await transcriptionService?.transcribe(audioBuffer: audioBuffer)
let hints = await MainActor.run { wordDictionary.words }
let rawText = try await transcriptionService?.transcribe(
audioBuffer: audioBuffer, wordHints: hints)
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
cleanedText = (try? await service.cleanup(rawText, wordHints: hints)) ?? rawText
} else {
cleanedText = rawText
}
Expand Down
2 changes: 1 addition & 1 deletion Wisp/Models/TranscriptionLogEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation

struct TranscriptionLogEntry: Codable, Identifiable, Sendable {
let id: UUID
let text: String
var text: String
let timestamp: Date
let wasPasted: Bool

Expand Down
7 changes: 7 additions & 0 deletions Wisp/Models/TranscriptionLogStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Foundation
// ~/Library/Application Support/Wisp/transcription-log.json

@MainActor
@Observable
final class TranscriptionLogStore {

private(set) var entries: [TranscriptionLogEntry] = []
Expand All @@ -24,6 +25,12 @@ final class TranscriptionLogStore {
self.entries = Self.load(from: url)
}

func update(id: UUID, text: String) {
guard let index = entries.firstIndex(where: { $0.id == id }) else { return }
entries[index].text = text
save()
}

func append(text: String, wasPasted: Bool = true) {
let entry = TranscriptionLogEntry(text: text, wasPasted: wasPasted)
entries.insert(entry, at: 0)
Expand Down
86 changes: 86 additions & 0 deletions Wisp/Models/WordDictionaryStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Foundation

@MainActor
@Observable
final class WordDictionaryStore {

private(set) var words: [String] = []

private let defaults: UserDefaults
private let key = "com.wisp.wordDictionary"

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.words = defaults.stringArray(forKey: key) ?? []
}

// MARK: - Mutation

func add(_ word: String) {
let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard !contains(trimmed) else { return }
words.append(trimmed)
persist()
}

func update(at index: Int, word: String) {
let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard words.indices.contains(index) else { return }
words[index] = trimmed
persist()
}

func remove(at offsets: IndexSet) {
words.remove(atOffsets: offsets)
persist()
}

func remove(_ word: String) {
guard let index = words.firstIndex(where: {
$0.caseInsensitiveCompare(word) == .orderedSame
}) else { return }
words.remove(at: index)
persist()
}

// MARK: - Query

func contains(_ word: String) -> Bool {
words.contains { $0.caseInsensitiveCompare(word) == .orderedSame }
}

// MARK: - Word Extraction

/// Returns words present in `newText` but absent (case-insensitive) from `oldText`.
/// Strips leading/trailing punctuation from each token before comparison.
nonisolated static func extractNewWords(from oldText: String, to newText: String) -> [String] {
let oldTokens = Set(
tokenise(oldText).map { stripped($0).lowercased() }
)
var seen = Set<String>()
var result: [String] = []
for token in tokenise(newText) {
let key = stripped(token).lowercased()
guard !key.isEmpty, !oldTokens.contains(key), !seen.contains(key) else { continue }
seen.insert(key)
result.append(stripped(token))
}
return result
}

// MARK: - Private

private func persist() {
defaults.set(words, forKey: key)
}

nonisolated private static func tokenise(_ text: String) -> [String] {
text.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
}

nonisolated private static func stripped(_ token: String) -> String {
token.trimmingCharacters(in: .punctuationCharacters)
}
}
8 changes: 6 additions & 2 deletions Wisp/Services/TextCleanupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ final class TextCleanupService: @unchecked Sendable {
session = LanguageModelSession()
}

func cleanup(_ text: String) async throws -> String {
func cleanup(_ text: String, wordHints: [String] = []) async throws -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return text }

// Read the prompt at call time so changes take effect without restarting the service.
let basePrompt = await MainActor.run { preferences.cleanupPrompt }
let prompt = basePrompt + "\n\nTranscribed text: \(text)"
var prompt = basePrompt
if !wordHints.isEmpty {
prompt += "\nUse these exact spellings when they appear: \(wordHints.joined(separator: ", "))"
}
prompt += "\n\nTranscribed text: \(text)"

let response = try await session.respond(to: prompt)
let cleaned = response.content.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
12 changes: 10 additions & 2 deletions Wisp/Services/TranscriptionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ final class TranscriptionService: @unchecked Sendable {
loadTask = nil
}

func transcribe(audioBuffer: Data) async throws -> String {
func transcribe(audioBuffer: Data, wordHints: [String] = []) async throws -> String {
try await loadModel()

guard let kit = whisperKit else {
Expand All @@ -61,7 +61,15 @@ final class TranscriptionService: @unchecked Sendable {
}

do {
let results = try await kit.transcribe(audioArray: floatArray)
var decodeOptions = DecodingOptions()
if !wordHints.isEmpty, let tokenizer = kit.tokenizer {
let hintText = wordHints.joined(separator: ", ")
decodeOptions.promptTokens = tokenizer.encode(text: hintText)
}
let results = try await kit.transcribe(
audioArray: floatArray,
decodeOptions: decodeOptions
)
let text = results.map(\.text).joined(separator: " ").trimmingCharacters(
in: .whitespacesAndNewlines)
return text
Expand Down
93 changes: 76 additions & 17 deletions Wisp/UI/LogView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import SwiftUI

struct LogView: View {

let entries: [TranscriptionLogEntry]
var logStore: TranscriptionLogStore
var wordDictionary: WordDictionaryStore

static let timestampFormat: Date.FormatStyle = .dateTime
.month(.abbreviated)
Expand All @@ -12,13 +13,17 @@ struct LogView: View {
.minute()

var body: some View {
if entries.isEmpty {
if logStore.entries.isEmpty {
Text("No transcriptions yet.")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(entries) { entry in
LogEntryRow(entry: entry)
List(logStore.entries) { entry in
LogEntryRow(
entry: entry,
logStore: logStore,
wordDictionary: wordDictionary
)
}
}
}
Expand All @@ -27,33 +32,61 @@ struct LogView: View {
private struct LogEntryRow: View {

let entry: TranscriptionLogEntry
let logStore: TranscriptionLogStore
let wordDictionary: WordDictionaryStore

@State private var isEditing: Bool = false
@State private var editDraft: String = ""

var body: some View {
HStack(alignment: .top, spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text(entry.timestamp, format: LogView.timestampFormat)
.font(.caption)
.foregroundStyle(.secondary)
Text(entry.text)
.font(.body)
.textSelection(.enabled)

if isEditing {
TextEditor(text: $editDraft)
.font(.body)
.frame(minHeight: 44)
.onSubmit { commitEdit() }
} else {
Text(entry.text)
.font(.body)
.textSelection(.enabled)
.onTapGesture(count: 2) { startEditing() }
}

if !entry.wasPasted {
Text("not pasted")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
Spacer()
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(entry.text, forType: .string)
} label: {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.help("Copy to clipboard")
.onHover { hovering in
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }

if isEditing {
VStack(spacing: 4) {
Button("Save") { commitEdit() }
.buttonStyle(.borderless)
.disabled(editDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button("Cancel") { cancelEdit() }
.buttonStyle(.borderless)
.foregroundStyle(.secondary)
}
.font(.caption)
} else {
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(entry.text, forType: .string)
} label: {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.help("Copy to clipboard")
.onHover { hovering in
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
}
}
}
.padding(10)
Expand All @@ -64,4 +97,30 @@ private struct LogEntryRow: View {
.listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
.listRowSeparator(.hidden)
}

// MARK: - Edit actions

private func startEditing() {
editDraft = entry.text
isEditing = true
}

private func commitEdit() {
let trimmed = editDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
cancelEdit()
return
}
let newWords = WordDictionaryStore.extractNewWords(from: entry.text, to: trimmed)
for word in newWords {
wordDictionary.add(word)
}
logStore.update(id: entry.id, text: trimmed)
isEditing = false
}

private func cancelEdit() {
editDraft = ""
isEditing = false
}
}
6 changes: 4 additions & 2 deletions Wisp/UI/LogWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ final class LogWindow: NSWindow {
center()
}

func show(entries: [TranscriptionLogEntry]) {
contentView = NSHostingView(rootView: LogView(entries: entries))
func show(logStore: TranscriptionLogStore, wordDictionary: WordDictionaryStore) {
contentView = NSHostingView(
rootView: LogView(logStore: logStore, wordDictionary: wordDictionary)
)
makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
Expand Down
Loading
Loading