Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a2265d8
Add Transcribe with Prompt hotkey and fix AI gate for custom prompts
yelloduxx Mar 25, 2026
2aeae94
refactor: extract prompt mode key handlers to reduce function complexity
yelloduxx Mar 25, 2026
0f16730
fix: address Codex review — prompt override correctness and leak prev…
yelloduxx Mar 25, 2026
22173a2
fix: remove redundant prompt override cleanup (handled by clearActive…
yelloduxx Mar 25, 2026
9b3f2bd
fix: address Codex P1/P2 — provider check and prompt override mode leak
yelloduxx Mar 25, 2026
b1c2397
fix: trim ContentView to satisfy type_body_length limit
yelloduxx Mar 25, 2026
9b75652
style: apply swiftformat to changed files
yelloduxx Mar 26, 2026
bfdc0a0
docs: add screenshot for PR #227
yelloduxx Mar 26, 2026
3785bb1
fix: use Right Shift as default for prompt mode hotkey to avoid confl…
yelloduxx Mar 27, 2026
5dbd802
fix: keep overlay handlers alive when settings window closes
yelloduxx Mar 28, 2026
ad02eb7
refactor: extract accessibility and lifecycle helpers from ContentVie…
yelloduxx Mar 28, 2026
d82009a
fix: show prompt mode override name in top (notch) overlay
yelloduxx Mar 28, 2026
9c8b89d
feat: add prompt switching and action chips to top notch overlay
yelloduxx Apr 1, 2026
ae1ef23
fix: use isPromptModeActive flag for prompt mode detection in notch o…
yelloduxx Apr 1, 2026
9573a6b
Refactor dictation prompt selection and trim ContentView type body
altic-dev Apr 4, 2026
ddb7014
Remove notch mode and action controls
altic-dev Apr 4, 2026
894bd0d
Fix prompt-mode selection in bottom overlay
altic-dev Apr 4, 2026
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion Sources/Fluid/Analytics/AnalyticsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ final class AnalyticsService {
"environment": environment,

// Low-cardinality settings snapshot
"ai_processing_enabled": settings.enableAIProcessing,
"ai_processing_enabled": !settings.isDictationPromptOff,
"streaming_preview_enabled": settings.enableStreamingPreview,
"press_and_hold_mode": settings.pressAndHoldMode,
"copy_to_clipboard_enabled": settings.copyTranscriptionToClipboard,
Expand Down
449 changes: 274 additions & 175 deletions Sources/Fluid/ContentView.swift

Large diffs are not rendered by default.

173 changes: 154 additions & 19 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class SettingsStore: ObservableObject {
self.migrateProviderAPIKeysIfNeeded()
self.scrubSavedProviderAPIKeys()
self.migrateDictationPromptProfilesIfNeeded()
self.migrateLegacyDictationAIPreferenceIfNeeded()
self.normalizePromptSelectionsIfNeeded()
self.migrateOverlayBottomOffsetTo50IfNeeded()
}
Expand All @@ -35,9 +36,13 @@ final class SettingsStore: ObservableObject {
case write // legacy persisted value (decoded as .edit)
case rewrite // legacy persisted value (decoded as .edit)

var id: String { self.rawValue }
var id: String {
self.rawValue
}

static var visiblePromptModes: [PromptMode] { [.dictate, .edit] }
static var visiblePromptModes: [PromptMode] {
[.dictate, .edit]
}

var normalized: PromptMode {
switch self {
Expand Down Expand Up @@ -78,6 +83,12 @@ final class SettingsStore: ObservableObject {
}
}

enum DictationPromptSelection: Equatable {
case off
case `default`
case profile(String)
}

struct DictationPromptProfile: Codable, Identifiable, Hashable {
let id: String
var name: String
Expand Down Expand Up @@ -262,6 +273,38 @@ final class SettingsStore: ObservableObject {
}
}

var isDictationPromptOff: Bool {
get { self.defaults.bool(forKey: Keys.dictationPromptOff) }
set {
objectWillChange.send()
self.defaults.set(newValue, forKey: Keys.dictationPromptOff)
}
}

var dictationPromptSelection: DictationPromptSelection {
if self.isDictationPromptOff {
return .off
}
if let promptID = self.selectedDictationPromptID {
return .profile(promptID)
}
return .default
}

func setDictationPromptSelection(_ selection: DictationPromptSelection) {
switch selection {
case .off:
self.isDictationPromptOff = true
self.selectedDictationPromptID = nil
case .default:
self.isDictationPromptOff = false
self.selectedDictationPromptID = nil
case let .profile(promptID):
self.isDictationPromptOff = false
self.selectedDictationPromptID = promptID
}
}

/// Convenience: currently selected profile, or nil if Default/invalid selection.
var selectedDictationPromptProfile: DictationPromptProfile? {
self.selectedPromptProfile(for: .dictate)
Expand Down Expand Up @@ -326,7 +369,11 @@ final class SettingsStore: ObservableObject {
func setSelectedPromptID(_ id: String?, for mode: PromptMode) {
switch mode.normalized {
case .dictate:
self.selectedDictationPromptID = id
if let id {
self.setDictationPromptSelection(.profile(id))
} else {
self.setDictationPromptSelection(.default)
}
case .edit:
self.selectedEditPromptID = id
case .write, .rewrite:
Expand Down Expand Up @@ -723,12 +770,11 @@ final class SettingsStore: ObservableObject {
}
}

let fallback = self.defaultPromptResolution(
return self.defaultPromptResolution(
for: normalizedMode,
source: .appBindingDefault,
appBinding: binding
)
return fallback
}

if let profile = self.selectedPromptProfile(for: normalizedMode) {
Expand Down Expand Up @@ -1234,7 +1280,9 @@ final class SettingsStore: ObservableObject {
case purple = "Purple"
case orange = "Orange"

var id: String { self.rawValue }
var id: String {
self.rawValue
}

var hex: String {
switch self {
Expand All @@ -1254,7 +1302,9 @@ final class SettingsStore: ObservableObject {
case fluidSfx3 = "fluid_sfx_3"
case fluidSfx4 = "fluid_sfx_4"

var id: String { self.rawValue }
var id: String {
self.rawValue
}

var displayName: String {
switch self {
Expand Down Expand Up @@ -1591,6 +1641,52 @@ final class SettingsStore: ObservableObject {
}
}

// MARK: - Prompt Mode Settings (Transcribe with Prompt)

var promptModeShortcutEnabled: Bool {
get {
let value = self.defaults.object(forKey: Keys.promptModeShortcutEnabled)
return value as? Bool ?? false
}
set {
objectWillChange.send()
self.defaults.set(newValue, forKey: Keys.promptModeShortcutEnabled)
}
}

var promptModeHotkeyShortcut: HotkeyShortcut {
get {
if let data = defaults.data(forKey: Keys.promptModeHotkeyShortcut),
let shortcut = try? JSONDecoder().decode(HotkeyShortcut.self, from: data)
{
return shortcut
}
// Default to Right Shift key (keyCode: 60, no modifiers) — avoids conflict with Command Mode (Right Command, keyCode 54)
return HotkeyShortcut(keyCode: 60, modifierFlags: [])
}
set {
objectWillChange.send()
if let data = try? JSONEncoder().encode(newValue) {
self.defaults.set(data, forKey: Keys.promptModeHotkeyShortcut)
}
}
}

var promptModeSelectedPromptID: String? {
get {
let value = self.defaults.string(forKey: Keys.promptModeSelectedPromptID)
return value?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true ? nil : value
}
set {
objectWillChange.send()
if let id = newValue?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty {
self.defaults.set(id, forKey: Keys.promptModeSelectedPromptID)
} else {
self.defaults.removeObject(forKey: Keys.promptModeSelectedPromptID)
}
}
}

var commandModeShortcutEnabled: Bool {
get {
let value = self.defaults.object(forKey: Keys.commandModeShortcutEnabled)
Expand Down Expand Up @@ -1942,6 +2038,25 @@ final class SettingsStore: ObservableObject {
DebugLogger.shared.info("Migrated legacy custom dictation prompt to a prompt profile", source: "SettingsStore")
}

private func migrateLegacyDictationAIPreferenceIfNeeded() {
guard self.defaults.object(forKey: Keys.dictationPromptOff) == nil else { return }

let hasSelectedCustomDictationPrompt = self.selectedDictationPromptID.flatMap { id in
self.dictationPromptProfiles.first(where: { $0.id == id && $0.mode == .dictate })
} != nil

let shouldStartOff: Bool
if hasSelectedCustomDictationPrompt {
shouldStartOff = false
} else if self.defaults.object(forKey: Keys.enableAIProcessing) != nil {
shouldStartOff = !self.defaults.bool(forKey: Keys.enableAIProcessing)
} else {
shouldStartOff = false
}

self.defaults.set(shouldStartOff, forKey: Keys.dictationPromptOff)
}

private func normalizePromptSelectionsIfNeeded() {
// One-time migration to unified edit keys.
if self.defaults.object(forKey: Keys.selectedEditPromptID) == nil,
Expand Down Expand Up @@ -1986,6 +2101,12 @@ final class SettingsStore: ObservableObject {
self.selectedEditPromptID = nil
}

if let id = self.promptModeSelectedPromptID,
self.dictationPromptProfiles.contains(where: { $0.id == id && $0.mode.normalized == .dictate }) == false
{
self.promptModeSelectedPromptID = nil
}

let validPromptIDsByMode: [PromptMode: Set<String>] = [
.dictate: Set(self.dictationPromptProfiles.filter { $0.mode.normalized == .dictate }.map(\.id)),
.edit: Set(self.dictationPromptProfiles.filter { $0.mode.normalized == .edit }.map(\.id)),
Expand Down Expand Up @@ -2323,8 +2444,8 @@ final class SettingsStore: ObservableObject {
/// Unified speech recognition model selection.
/// Replaces the old TranscriptionProviderOption + WhisperModelSize dual-setting.
enum SpeechModel: String, CaseIterable, Identifiable, Codable {
// Temporarily disabled in UI/runtime while Parakeet word boosting work is prioritized.
// Flip to `true` in a future round to re-enable Qwen without deleting implementation.
/// Temporarily disabled in UI/runtime while Parakeet word boosting work is prioritized.
/// Flip to `true` in a future round to re-enable Qwen without deleting implementation.
static let qwenPreviewEnabled = false

// MARK: - FluidAudio Models (Apple Silicon Only)
Expand All @@ -2349,7 +2470,9 @@ final class SettingsStore: ObservableObject {
case whisperLargeTurbo = "whisper-large-turbo" // temporarily disabled in UI
case whisperLarge = "whisper-large"

var id: String { rawValue }
var id: String {
rawValue
}

// MARK: - Display Properties

Expand Down Expand Up @@ -2861,7 +2984,9 @@ final class SettingsStore: ObservableObject {
case fluidAudio
case whisper

var id: String { rawValue }
var id: String {
rawValue
}

var displayName: String {
switch self {
Expand Down Expand Up @@ -2916,9 +3041,10 @@ final class SettingsStore: ObservableObject {
// swiftlint:enable type_body_length

private extension SettingsStore {
// Keys
/// Keys
enum Keys {
static let enableAIProcessing = "EnableAIProcessing"
static let dictationPromptOff = "DictationPromptOff"
static let enableDebugLogs = "EnableDebugLogs"
static let availableAIModels = "AvailableAIModels"
static let availableModelsByProvider = "AvailableModelsByProvider"
Expand Down Expand Up @@ -2967,6 +3093,11 @@ private extension SettingsStore {
static let commandModeLinkedToGlobal = "CommandModeLinkedToGlobal"
static let commandModeShortcutEnabled = "CommandModeShortcutEnabled"

// Prompt Mode Keys (Transcribe with Prompt)
static let promptModeHotkeyShortcut = "PromptModeHotkeyShortcut"
static let promptModeShortcutEnabled = "PromptModeShortcutEnabled"
static let promptModeSelectedPromptID = "PromptModeSelectedPromptID"

// Rewrite Mode Keys
static let rewriteModeHotkeyShortcut = "RewriteModeHotkeyShortcut"
static let rewriteModeSelectedModel = "RewriteModeSelectedModel"
Expand All @@ -2986,7 +3117,7 @@ private extension SettingsStore {
static let fillerWords = "FillerWords"
static let removeFillerWordsEnabled = "RemoveFillerWordsEnabled"

// GAAV Mode (removes capitalization and trailing punctuation)
/// GAAV Mode (removes capitalization and trailing punctuation)
static let gaavModeEnabled = "GAAVModeEnabled"

// Custom Dictionary
Expand All @@ -2997,7 +3128,7 @@ private extension SettingsStore {
static let selectedTranscriptionProvider = "SelectedTranscriptionProvider"
static let whisperModelSize = "WhisperModelSize"

// Unified Speech Model (replaces above two)
/// Unified Speech Model (replaces above two)
static let selectedSpeechModel = "SelectedSpeechModel"
static let selectedCohereLanguage = "SelectedCohereLanguage"
static let externalCoreMLArtifactsDirectories = "ExternalCoreMLArtifactsDirectories"
Expand All @@ -3009,10 +3140,10 @@ private extension SettingsStore {
static let overlaySize = "OverlaySize"
static let transcriptionPreviewCharLimit = "TranscriptionPreviewCharLimit"

// Media Playback Control
/// Media Playback Control
static let pauseMediaDuringTranscription = "PauseMediaDuringTranscription"

// Custom Dictation Prompt
/// Custom Dictation Prompt
static let customDictationPrompt = "CustomDictationPrompt"

// Dictation Prompt Profiles (multi-prompt system)
Expand All @@ -3032,7 +3163,7 @@ private extension SettingsStore {
static let defaultWritePromptOverride = "DefaultWritePromptOverride" // legacy fallback key
static let defaultRewritePromptOverride = "DefaultRewritePromptOverride" // legacy fallback key

// Streak Settings
/// Streak Settings
static let weekendsDontBreakStreak = "WeekendsDontBreakStreak"
}
}
Expand All @@ -3042,7 +3173,9 @@ extension SettingsStore {
case standard
case reliablePaste

var id: String { self.rawValue }
var id: String {
self.rawValue
}

var displayName: String {
switch self {
Expand Down Expand Up @@ -3099,7 +3232,9 @@ extension SettingsStore {
case medium = "ggml-medium.bin"
case large = "ggml-large-v3.bin"

var id: String { rawValue }
var id: String {
rawValue
}

var displayName: String {
switch self {
Expand Down
17 changes: 10 additions & 7 deletions Sources/Fluid/Services/DictationAIPostProcessingGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,26 @@ import Foundation
/// Shared gating logic for whether dictation AI post-processing is usable/configured.
enum DictationAIPostProcessingGate {
/// Returns true if dictation AI post-processing should be allowed, given current settings.
/// - Requires `SettingsStore.shared.enableAIProcessing == true`
/// - Requires dictation prompt selection to not be `Off`
/// - For Apple Intelligence: requires `AppleIntelligenceService.isAvailable`
/// - For other providers: requires a local endpoint OR a non-empty API key
static func isConfigured() -> Bool {
let settings = SettingsStore.shared
guard settings.enableAIProcessing else { return false }
guard !settings.isDictationPromptOff else { return false }

return self.isProviderConfigured()
}

/// Returns true if the selected AI provider is reachable/configured (API key or local endpoint),
/// regardless of the AI toggle or prompt selection. Used to gate prompt-mode hotkey AI processing.
static func isProviderConfigured() -> Bool {
let settings = SettingsStore.shared
let providerID = settings.selectedProviderID
if providerID == "apple-intelligence" {
return AppleIntelligenceService.isAvailable
}

let baseURL = self.baseURL(for: providerID, settings: settings)
if self.isLocalEndpoint(baseURL) {
return true
}

if self.isLocalEndpoint(baseURL) { return true }
let apiKey = (settings.getAPIKey(for: providerID) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return !apiKey.isEmpty
}
Expand Down
Loading
Loading