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 @@ -10,6 +10,8 @@ Auto-generated from all feature plans. Last updated: 2026-03-29
- 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 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 5.9+ with strict concurrency checking + WhisperKit (Argmax), KeyboardShortcuts (Sindre Sorhus), AppKi (001-core-dictation-flow)

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

## Recent Changes
- 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)
- 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


<!-- MANUAL ADDITIONS START -->
Expand Down
49 changes: 43 additions & 6 deletions Wisp/App/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import AppKit
@preconcurrency import AVFoundation
@preconcurrency import ApplicationServices
import ServiceManagement

@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {

private var statusItem: NSStatusItem?
private var state: AppState = .loading
Expand All @@ -22,6 +23,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var logStore = TranscriptionLogStore()
private var logWindow: LogWindow?
private var escapeMonitor: Any?
private var launchOnStartupItem: NSMenuItem?

// Cancel-countdown state (set when the first Escape is pressed during recording)
private var pendingAudioBuffer: Data?
Expand All @@ -31,6 +33,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
let store = PreferencesStore()
// Reconcile stored preference with actual system state (handles manual System Settings changes)
store.syncLaunchOnStartup(SMAppService.mainApp.status == .enabled)
preferencesStore = store
microphoneList = MicrophoneList()
setupMenuBar()
Expand All @@ -49,6 +53,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
menuBarController?.updateState(.loading)

let menu = NSMenu()
menu.delegate = self
menu.addItem(
NSMenuItem(
title: "Preferences\u{2026}",
Expand All @@ -63,6 +68,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
keyEquivalent: ""
)
)

let startupItem = NSMenuItem(
title: "Launch on Startup",
action: #selector(toggleLaunchOnStartup),
keyEquivalent: ""
)
startupItem.state = (preferencesStore?.launchOnStartup ?? false) ? .on : .off
launchOnStartupItem = startupItem
menu.addItem(startupItem)

menu.addItem(.separator())
menu.addItem(
NSMenuItem(
Expand All @@ -86,6 +101,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
PreferencesWindow.show(preferences: store, microphoneList: mics)
}

@objc private func toggleLaunchOnStartup() {
guard let store = preferencesStore else { return }
do {
try store.setLaunchOnStartup(!store.launchOnStartup)
launchOnStartupItem?.state = store.launchOnStartup ? .on : .off
} catch {
let alert = NSAlert()
alert.messageText = "Could Not Update Login Item"
alert.informativeText = error.localizedDescription
alert.alertStyle = .warning
alert.runModal()
}
}

// MARK: - NSMenuDelegate

func menuWillOpen(_ menu: NSMenu) {
let isEnabled = SMAppService.mainApp.status == .enabled
preferencesStore?.syncLaunchOnStartup(isEnabled)
launchOnStartupItem?.state = isEnabled ? .on : .off
}

private func setupOverlay() {
overlayWindow = StatusOverlayWindow()
overlayWindow?.show(state: .modelLoading)
Expand Down Expand Up @@ -303,14 +340,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
state = newState
print("[Wisp] Recording started")
menuBarController?.updateState(state)
menuBarController?.playStartSound()
overlayWindow?.show(state: .recording)

currentSession = DictationSession()

audioCaptureService?.startRecording { [weak self] result in
DispatchQueue.main.async {
self?.handleAutoStop(result: result)
menuBarController?.playStartSound { [weak self] in
self?.audioCaptureService?.startRecording { [weak self] result in
DispatchQueue.main.async {
self?.handleAutoStop(result: result)
}
}
}
}
Expand Down
30 changes: 30 additions & 0 deletions Wisp/Models/PreferencesStore.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import Observation
import ServiceManagement

enum PreferencesError: Error, Equatable {
case emptyPrompt
Expand All @@ -21,12 +22,14 @@ final class PreferencesStore {

private(set) var cleanupPrompt: String
private(set) var selectedMicrophoneUID: String?
private(set) var launchOnStartup: Bool

private let defaults: UserDefaults

private enum Keys {
static let cleanupPrompt = "com.wisp.cleanupPrompt"
static let selectedMicrophoneUID = "com.wisp.selectedMicrophoneUID"
static let launchOnStartup = "com.wisp.launchOnStartup"
}

init(defaults: UserDefaults = .standard) {
Expand All @@ -35,6 +38,7 @@ final class PreferencesStore {
let trimmed = stored.trimmingCharacters(in: .whitespacesAndNewlines)
self.cleanupPrompt = trimmed.isEmpty ? Self.defaultCleanupPrompt : trimmed
self.selectedMicrophoneUID = defaults.string(forKey: Keys.selectedMicrophoneUID)
self.launchOnStartup = defaults.bool(forKey: Keys.launchOnStartup)
}

// MARK: - Cleanup Prompt
Expand Down Expand Up @@ -65,4 +69,30 @@ final class PreferencesStore {
func resetMicrophone() {
setMicrophoneUID(nil)
}

// MARK: - Launch on Startup

/// Registers or unregisters the app as a login item and persists the preference.
/// Throws if the system call fails (e.g. permission denied, MDM restriction).
func setLaunchOnStartup(_ enabled: Bool) throws {
let currentStatus = SMAppService.mainApp.status
if enabled {
if currentStatus != .enabled {
try SMAppService.mainApp.register()
}
} else {
if currentStatus == .enabled || currentStatus == .requiresApproval {
try SMAppService.mainApp.unregister()
}
}
launchOnStartup = enabled
defaults.set(enabled, forKey: Keys.launchOnStartup)
}

/// Updates the stored preference to match the actual system state without calling SMAppService.
/// Called at app launch to reconcile with changes made outside the app (e.g. System Settings).
func syncLaunchOnStartup(_ enabled: Bool) {
launchOnStartup = enabled
defaults.set(enabled, forKey: Keys.launchOnStartup)
}
}
107 changes: 88 additions & 19 deletions Wisp/UI/MenuBarController.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,52 @@
import AppKit

final class MenuBarController {
@MainActor
final class MenuBarController: NSObject {

private let statusItem: NSStatusItem

// Beep-timing state
private var activeStartSound: NSSound?
private var startSoundCompletion: (() -> Void)?
private var startSoundFallbackTask: Task<Void, Never>?

// Ghost icon drawn programmatically as a template image (original design, not Nintendo's Wisp)
private static let ghostIcon: NSImage = {
let size = NSSize(width: 18, height: 18)
let image = NSImage(size: size, flipped: false) { _ in
NSColor.black.setFill()
let path = NSBezierPath()
path.windingRule = .evenOdd
// Ghost head (top semicircle) — center (9,11), radius 6
path.move(to: NSPoint(x: 3, y: 11))
path.appendArc(
withCenter: NSPoint(x: 9, y: 11), radius: 6, startAngle: 180, endAngle: 0,
clockwise: true)
// Straight sides down to scalloped skirt
path.line(to: NSPoint(x: 15, y: 2))
// Three downward scallops, right → left
path.appendArc(
withCenter: NSPoint(x: 13, y: 2), radius: 2, startAngle: 0, endAngle: 180,
clockwise: true)
path.appendArc(
withCenter: NSPoint(x: 9, y: 2), radius: 2, startAngle: 0, endAngle: 180,
clockwise: true)
path.appendArc(
withCenter: NSPoint(x: 5, y: 2), radius: 2, startAngle: 0, endAngle: 180,
clockwise: true)
path.close()
path.appendOval(in: NSRect(x: 5.3, y: 11.8, width: 2.4, height: 2.4))
path.appendOval(in: NSRect(x: 10.3, y: 11.8, width: 2.4, height: 2.4))
path.fill()
return true
}
image.isTemplate = true
return image
}()

init(statusItem: NSStatusItem) {
self.statusItem = statusItem
super.init()
updateState(.idle)
}

Expand All @@ -18,21 +59,13 @@ final class MenuBarController {
systemSymbolName: "hourglass",
accessibilityDescription: "Wisp — Loading"
)
case .idle:
button.image = NSImage(
systemSymbolName: "waveform",
accessibilityDescription: "Wisp — Idle"
)
case .idle, .cancelling:
button.image = Self.ghostIcon
case .recording:
button.image = NSImage(
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 All @@ -41,20 +74,56 @@ final class MenuBarController {
}
}

func playStartSound() {
playSound(named: "record-start")
}
/// Plays the start beep and calls `completion` only after the sound finishes.
/// Falls back to calling `completion` immediately if the sound asset is unavailable,
/// and after 1 second if the delegate callback never fires.
func playStartSound(completion: @escaping @MainActor () -> Void) {
guard let url = Bundle.module.url(forResource: "record-start", withExtension: "wav"),
let sound = NSSound(contentsOf: url, byReference: true)
else {
completion()
return
}

func playStopSound() {
playSound(named: "record-stop")
startSoundCompletion = completion
activeStartSound = sound

startSoundFallbackTask = Task { @MainActor [weak self] in
do {
try await Task.sleep(for: .seconds(1))
} catch {
return // Cancelled when delegate fired normally
}
self?.fireSoundCompletion()
}

sound.delegate = self
sound.play()
}

private func playSound(named name: String) {
guard let url = Bundle.module.url(forResource: name, withExtension: "wav") else {
print("[Wisp] Sound not found: \(name).wav")
func playStopSound() {
guard let url = Bundle.module.url(forResource: "record-stop", withExtension: "wav") else {
print("[Wisp] Sound not found: record-stop.wav")
return
}
let sound = NSSound(contentsOf: url, byReference: true)
sound?.play()
}

private func fireSoundCompletion() {
startSoundFallbackTask?.cancel()
startSoundFallbackTask = nil
activeStartSound = nil
let completion = startSoundCompletion
startSoundCompletion = nil
completion?()
}
}

extension MenuBarController: NSSoundDelegate {
nonisolated func sound(_ sound: NSSound, didFinishPlaying flag: Bool) {
Task { @MainActor [weak self] in
self?.fireSoundCompletion()
}
}
}
13 changes: 13 additions & 0 deletions Wisp/UI/PreferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct PreferencesView: View {
Form {
shortcutSection
microphoneSection
startupSection
promptSection
}
.formStyle(.grouped)
Expand Down Expand Up @@ -54,6 +55,18 @@ struct PreferencesView: View {
}
}

private var startupSection: some View {
Section("System") {
Toggle(
"Launch Wisp on Startup",
isOn: Binding(
get: { preferences.launchOnStartup },
set: { try? preferences.setLaunchOnStartup($0) }
)
)
}
}

private var promptSection: some View {
Section("Transcription Cleanup Prompt") {
TextEditor(text: $promptDraft)
Expand Down
Binary file modified dist/Wisp.app/Contents/MacOS/Wisp
Binary file not shown.
Binary file modified dist/Wisp.dmg
Binary file not shown.
Binary file modified dist/Wisp.pkg
Binary file not shown.
34 changes: 34 additions & 0 deletions specs/006-polish-and-cleanup/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Specification Quality Checklist: Polish and Cleanup

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-29
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

- All items pass. Spec is ready for `/speckit.plan`.
Loading
Loading