From 35adca367344b54b398f8ada633f6aaa132c24d0 Mon Sep 17 00:00:00 2001 From: Tanner Date: Sun, 1 Mar 2026 23:53:28 -0800 Subject: [PATCH] docs: add Swift doc comments to public APIs Add /// doc comments to all public protocols, actors, classes, structs, enums, methods, and properties across 16 core source files. Comments describe purpose, parameters, return values, concurrency constraints (actor isolation, @MainActor), and error conditions. No code logic changed. Nightshift-Task: docs-backfill Nightshift-Ref: https://github.com/marcus/nightshift Co-Authored-By: Claude Sonnet 4.6 --- FreeThinker/App/AppContainer.swift | 50 +++++ FreeThinker/App/AppState.swift | 176 ++++++++++++++++++ FreeThinker/Core/Models/AppSettings.swift | 94 ++++++++++ FreeThinker/Core/Models/DiagnosticEvent.swift | 39 ++++ .../Core/Models/ProvocationRequest.swift | 25 +++ .../Core/Models/ProvocationResponse.swift | 41 ++++ FreeThinker/Core/Services/AIService.swift | 90 +++++++++ .../Core/Services/DefaultAIService.swift | 52 ++++++ .../Services/DefaultTextCaptureService.swift | 46 +++++ .../Services/ProvocationOrchestrator.swift | 118 ++++++++++++ .../Services/ProvocationPromptComposer.swift | 28 +++ .../Services/ProvocationResponseParser.swift | 14 ++ .../Core/Utilities/DiagnosticsLogger.swift | 45 +++++ .../Utilities/ErrorPresentationMapper.swift | 36 ++++ .../Core/Utilities/FreeThinkerError.swift | 44 +++++ .../FloatingPanelViewModel.swift | 76 ++++++++ 16 files changed, 974 insertions(+) diff --git a/FreeThinker/App/AppContainer.swift b/FreeThinker/App/AppContainer.swift index e73525d..b85aa3f 100644 --- a/FreeThinker/App/AppContainer.swift +++ b/FreeThinker/App/AppContainer.swift @@ -2,14 +2,34 @@ import AppKit import Foundation import KeyboardShortcuts +/// The composition root for the FreeThinker app. +/// +/// `AppContainer` creates and wires all core services, coordinates lifecycle events, +/// and provides the single point of truth for dependency injection. It is `@MainActor`-isolated +/// because it directly owns and manipulates `AppState`. +/// +/// Typical usage: +/// ```swift +/// let container = AppContainer() +/// container.start() +/// // … app runs … +/// container.stop() +/// ``` @MainActor public final class AppContainer { + /// The shared observable app state consumed by SwiftUI views. public let appState: AppState + /// The AI service that performs on-device text generation. public let aiService: any AIServiceProtocol + /// The service responsible for capturing selected text from the active app. public let textCaptureService: any TextCaptureServiceProtocol + /// The orchestrator that coordinates the end-to-end generation pipeline. public let orchestrator: any ProvocationOrchestrating + /// Manages the menu bar status item and its menu. public let menuBarCoordinator: MenuBarCoordinator + /// Persists and loads ``AppSettings`` to and from disk. public let settingsService: any SettingsServiceProtocol + /// Records diagnostic events for troubleshooting. public let diagnosticsLogger: any DiagnosticsLogging private let errorMapper: ErrorPresentationMapping @@ -20,6 +40,18 @@ public final class AppContainer { private let floatingPanelController: FloatingPanelController private var onboardingWindowController: OnboardingWindowController? + /// Creates an `AppContainer` with explicit dependencies, suitable for testing. + /// + /// - Parameters: + /// - appState: The shared observable state object. + /// - aiService: Service performing AI generation. + /// - textCaptureService: Service capturing the user's selected text. + /// - notificationService: Service posting background user notifications. + /// - errorMapper: Maps ``FreeThinkerError`` values to ``ErrorPresentation`` instances. + /// - launchAtLoginController: Manages the Launch at Login system setting. + /// - settingsService: Reads and writes ``AppSettings`` to disk. + /// - diagnosticsLogger: Records diagnostic events. + /// - modelAvailabilityProvider: Probes whether the on-device AI model is available. public init( appState: AppState, aiService: any AIServiceProtocol = DefaultAIService(), @@ -69,6 +101,9 @@ public final class AppContainer { wireCallbacks() } + /// Creates an `AppContainer` with all production dependencies. + /// + /// Loads persisted settings via `DefaultSettingsService` before constructing the state. public convenience init() { let settingsService = DefaultSettingsService() let loadedSettings = settingsService.loadSettings() @@ -89,6 +124,16 @@ public final class AppContainer { ) } + /// Starts the container and all managed services. + /// + /// - Recovers unreachable startup settings (hotkey and menu bar both disabled). + /// - Syncs the Launch at Login setting from the system. + /// - Registers the global hotkey. + /// - Installs or removes the menu bar status item based on settings. + /// - Refreshes onboarding readiness. + /// - Presents the onboarding window if not previously completed. + /// + /// Must be called once after the app delegate's `applicationDidFinishLaunching`. public func start() { var settings = appState.settings @@ -148,6 +193,11 @@ public final class AppContainer { } } + /// Gracefully shuts down the container. + /// + /// Cancels any in-flight generation, cleans up the floating panel, stops the global + /// hotkey listener, and removes the menu bar status item. Call from + /// `applicationWillTerminate`. public func stop() { Task { await orchestrator.cancelCurrentGeneration(reason: .appWillTerminate) diff --git a/FreeThinker/App/AppState.swift b/FreeThinker/App/AppState.swift index 99e70c7..44203a5 100644 --- a/FreeThinker/App/AppState.swift +++ b/FreeThinker/App/AppState.swift @@ -2,15 +2,29 @@ import AppKit import Combine import Foundation +/// Protocol for persisting and loading the floating panel's pinned state. public protocol PanelPinningStore { + /// Loads the previously saved pinned state. + /// + /// - Returns: `true` if the panel was pinned when last saved; `false` otherwise. func loadPinnedState() -> Bool + + /// Persists the current pinned state. + /// + /// - Parameter isPinned: The pinned state to save. func savePinnedState(_ isPinned: Bool) } +/// `UserDefaults`-backed implementation of ``PanelPinningStore``. public struct UserDefaultsPanelPinningStore: PanelPinningStore { private let userDefaults: UserDefaults private let key: String + /// Creates a store backed by the given `UserDefaults` instance. + /// + /// - Parameters: + /// - userDefaults: The `UserDefaults` to read from and write to. Defaults to `.standard`. + /// - key: The key used to store the pinned state. Defaults to `"floating_panel.is_pinned"`. public init( userDefaults: UserDefaults = .standard, key: String = "floating_panel.is_pinned" @@ -19,20 +33,34 @@ public struct UserDefaultsPanelPinningStore: PanelPinningStore { self.key = key } + /// Loads the pinned state from `UserDefaults`, returning `false` if no value has been saved. public func loadPinnedState() -> Bool { userDefaults.object(forKey: key) as? Bool ?? false } + /// Saves the pinned state to `UserDefaults`. + /// + /// - Parameter isPinned: The value to persist. public func savePinnedState(_ isPinned: Bool) { userDefaults.set(isPinned, forKey: key) } } +/// Tracks whether the user has completed each item on the onboarding checklist. public struct OnboardingReadiness: Equatable, Sendable { + /// Whether Accessibility permission has been granted. public var accessibilityGranted: Bool + /// Whether the user has acknowledged the global hotkey. public var hotkeyAwarenessConfirmed: Bool + /// The current availability of the on-device AI model. public var modelAvailability: FoundationModelAvailability + /// Creates a readiness snapshot with all items defaulting to incomplete. + /// + /// - Parameters: + /// - accessibilityGranted: Whether Accessibility permission is granted. Defaults to `false`. + /// - hotkeyAwarenessConfirmed: Whether the hotkey step is confirmed. Defaults to `false`. + /// - modelAvailability: Current model availability. Defaults to ``FoundationModelAvailability/modelUnavailable``. public init( accessibilityGranted: Bool = false, hotkeyAwarenessConfirmed: Bool = false, @@ -43,38 +71,95 @@ public struct OnboardingReadiness: Equatable, Sendable { self.modelAvailability = modelAvailability } + /// `true` when the model reports ``FoundationModelAvailability/available``. public var isModelReady: Bool { modelAvailability == .available } + /// `true` when all three checklist items are complete. public var isChecklistComplete: Bool { accessibilityGranted && hotkeyAwarenessConfirmed && isModelReady } } +/// The central observable state object for the FreeThinker app. +/// +/// `AppState` is confined to the `@MainActor` and drives SwiftUI views via `@Published` properties. +/// It delegates side-effects (generation, settings persistence, hotkey management) to callback +/// closures wired by ``AppContainer`` at startup. @MainActor public final class AppState: ObservableObject { + /// The validated app settings currently in effect. @Published public private(set) var settings: AppSettings + /// `true` while the AI generation pipeline is running. @Published public private(set) var isGenerating: Bool = false + /// A localised error message shown when settings cannot be persisted, or `nil` when clear. @Published public private(set) var settingsSaveErrorMessage: String? + /// A validation message shown when proposed settings are invalid, or `nil` when valid. @Published public private(set) var settingsValidationMessage: String? + /// `true` while a settings save operation is in progress. @Published public private(set) var isPersistingSettings: Bool = false + /// The result of the most recent hotkey validation or apply attempt, or `nil` if none. @Published public private(set) var hotkeyCustomizationResult: HotkeyValidationResult? + /// Whether the onboarding flow is currently presented. @Published public private(set) var isOnboardingPresented: Bool + /// The current state of each onboarding prerequisite. @Published public private(set) var onboardingReadiness: OnboardingReadiness + /// The view model driving the floating panel UI. public let panelViewModel: FloatingPanelViewModel + /// Called when the user requests regeneration from within the panel. + /// + /// - Parameter regenerateFromResponseID: The ID of the response whose original text should be reused, or `nil`. public var onRegenerateRequested: ((_ regenerateFromResponseID: UUID?) async -> Void)? + + /// Called when the user dismisses the floating panel. public var onCloseRequested: (() -> Void)? + + /// Called synchronously after settings are validated and applied in-memory. + /// + /// - Parameter settings: The newly active ``AppSettings`` value. public var onSettingsUpdated: ((AppSettings) -> Void)? + + /// Called asynchronously to persist settings to disk. + /// + /// - Parameter settings: The settings to persist. + /// - Throws: ``SettingsServiceError`` or other errors if persistence fails. public var onSettingsPersistRequested: ((AppSettings) async throws -> Void)? + + /// Called to validate a proposed hotkey shortcut before applying it. + /// + /// - Parameters: + /// - proposedShortcut: The shortcut the user wants to use. + /// - effectiveShortcut: The shortcut currently registered. + /// - Returns: A ``HotkeyValidationResult`` indicating whether the shortcut is acceptable. public var onHotkeyValidationRequested: ((HotkeyShortcut, HotkeyShortcut) -> HotkeyValidationResult)? + + /// Called to apply a validated hotkey shortcut by registering it with the system. + /// + /// - Parameter settings: The settings containing the new shortcut. + /// - Throws: An error if the shortcut cannot be registered. public var onHotkeyApplyRequested: ((AppSettings) throws -> Void)? + + /// Called when the user toggles Launch at Login. + /// + /// - Parameter isEnabled: The desired launch-at-login state. + /// - Throws: ``LaunchAtLoginError`` if the system cannot honour the request. public var onLaunchAtLoginChangeRequested: ((Bool) async throws -> Void)? + + /// Called when the app should open a specific settings section. + /// + /// - Parameter section: The section to navigate to. public var onOpenSettingsRequested: ((SettingsSection) -> Void)? + + /// Called whenever the onboarding presentation state changes. + /// + /// - Parameter isPresented: `true` if onboarding is now visible. public var onOnboardingPresentationChanged: ((Bool) -> Void)? + + /// Called when the user requests a diagnostics export; returns a summary string. public var onExportDiagnosticsRequested: (() -> String)? private let pinningStore: any PanelPinningStore @@ -82,6 +167,16 @@ public final class AppState: ObservableObject { private var pendingSettingsSave: AppSettings? private var settingsSaveTask: Task? + /// Creates an `AppState` with the given settings and dependencies. + /// + /// Settings are validated before use. The onboarding flag is derived from + /// `settings.hasSeenOnboarding`. + /// + /// - Parameters: + /// - settings: Initial app settings. Defaults to `AppSettings()`. + /// - pinningStore: Store used to persist the panel's pinned state. + /// - timing: Timing provider for the panel view model's auto-dismiss and feedback timers. + /// - pasteboardWriter: Closure that writes text to the pasteboard. Defaults to `NSPasteboard.general`. public init( settings: AppSettings = AppSettings(), pinningStore: any PanelPinningStore = UserDefaultsPanelPinningStore(), @@ -127,16 +222,30 @@ public final class AppState: ObservableObject { panelViewModel.styleDisplayName = validatedSettings.provocationStylePreset.displayName } + /// Attaches a floating panel controller so AppState can show and hide the panel. + /// + /// Call this once after the controller is created, before any generation is triggered. + /// + /// - Parameter controller: The ``FloatingPanelController`` to use. public func attachPanelController(_ controller: FloatingPanelController) { panelController = controller } + /// Shows the floating panel in a loading state and sets `isGenerating` to `true`. + /// + /// - Parameter selectedText: An optional preview of the selected text to display while loading. public func presentLoading(selectedText: String? = nil) { isGenerating = true panelViewModel.setLoading(selectedTextPreview: selectedText) panelController?.show() } + /// Presents a completed provocation response in the floating panel. + /// + /// Sets `isGenerating` to `false`. On success, the panel shows the content; + /// on failure, it shows an error derived from `response.error`. + /// + /// - Parameter response: The ``ProvocationResponse`` to display. public func present(response: ProvocationResponse) { isGenerating = false panelController?.show() @@ -149,30 +258,47 @@ public final class AppState: ObservableObject { panelViewModel.setError(response.error ?? .generationFailed) } + /// Presents a ``FreeThinkerError`` in the floating panel and clears `isGenerating`. + /// + /// - Parameter error: The error to display. public func presentError(_ error: FreeThinkerError) { isGenerating = false panelController?.show() panelViewModel.setError(error) } + /// Presents a mapped ``ErrorPresentation`` (with suggested action) in the floating panel. + /// + /// - Parameter presentation: The presentation value produced by ``ErrorPresentationMapping``. public func presentErrorPresentation(_ presentation: ErrorPresentation) { isGenerating = false panelController?.show() panelViewModel.setErrorPresentation(presentation) } + /// Presents a plain error message string in the floating panel. + /// + /// - Parameter message: The localised error message to show. public func presentErrorMessage(_ message: String) { isGenerating = false panelController?.show() panelViewModel.setErrorMessage(message) } + /// Hides the floating panel and resets the panel view model to idle. public func dismissPanel() { isGenerating = false panelViewModel.setIdle() panelController?.hide() } + /// Validates and applies new settings, persisting them if a persist handler is configured. + /// + /// If validation fails, `settingsValidationMessage` is set and no changes are applied. + /// If the settings are unchanged, only `onboardingReadiness.hotkeyAwarenessConfirmed` + /// is refreshed. Calls ``onSettingsUpdated`` synchronously after applying. + /// + /// - Parameter settings: The new settings to validate and apply. public func updateSettings(_ settings: AppSettings) { let candidate = settings.validated() guard let validationIssue = validateSettings(candidate) else { @@ -199,15 +325,25 @@ public final class AppState: ObservableObject { settingsValidationMessage = validationIssue } + /// Sets the `isGenerating` flag directly. + /// + /// Primarily called by the orchestrator via its callback closure. + /// + /// - Parameter isGenerating: The new generating state. public func setGenerating(_ isGenerating: Bool) { self.isGenerating = isGenerating } + /// Shows the onboarding flow and notifies the presentation change callback. public func presentOnboarding() { isOnboardingPresented = true notifyOnboardingVisibilityChanged() } + /// Dismisses the onboarding flow. + /// + /// - Parameter markSeen: When `true` (the default), `settings.hasSeenOnboarding` is set + /// and changes are persisted so onboarding does not reappear at next launch. public func dismissOnboarding(markSeen: Bool = true) { if markSeen, !settings.hasSeenOnboarding { var updated = settings @@ -221,6 +357,7 @@ public final class AppState: ObservableObject { notifyOnboardingVisibilityChanged() } + /// Marks onboarding as fully complete, persists settings, and dismisses the flow. public func completeOnboarding() { var updated = settings updated.hasSeenOnboarding = true @@ -234,6 +371,9 @@ public final class AppState: ObservableObject { notifyOnboardingVisibilityChanged() } + /// Records that the user has acknowledged the global hotkey and persists the change. + /// + /// - Parameter isConfirmed: Whether the user has confirmed hotkey awareness. public func setHotkeyAwarenessConfirmed(_ isConfirmed: Bool) { onboardingReadiness.hotkeyAwarenessConfirmed = isConfirmed @@ -244,6 +384,13 @@ public final class AppState: ObservableObject { persistSettingsIfNeeded(self.settings) } + /// Updates the system-level readiness items that cannot be self-reported by the user. + /// + /// Call this after probing Accessibility permission and model availability. + /// + /// - Parameters: + /// - accessibilityGranted: Whether AX permission is currently granted. + /// - modelAvailability: The current availability of the on-device AI model. public func updateOnboardingSystemReadiness( accessibilityGranted: Bool, modelAvailability: FoundationModelAvailability @@ -252,10 +399,14 @@ public final class AppState: ObservableObject { onboardingReadiness.modelAvailability = modelAvailability } + /// Whether the floating panel's window is currently visible on screen. public var isPanelVisible: Bool { panelController?.panel.isVisible ?? false } + /// Applies a mutation closure to the current settings and calls ``updateSettings(_:)``. + /// + /// - Parameter mutation: A closure that modifies the settings in-place. public func mutateSettings(_ mutation: (inout AppSettings) -> Void) async { var next = settings mutation(&next) @@ -274,6 +425,12 @@ public final class AppState: ObservableObject { await mutateSettings { $0.hotkeyEnabled = isEnabled } } + /// Validates a proposed hotkey shortcut without applying it. + /// + /// Returns `.valid` with the proposed shortcut if no validation handler is set. + /// + /// - Parameter shortcut: The shortcut to validate. + /// - Returns: A ``HotkeyValidationResult`` describing whether the shortcut is acceptable. public func proposeHotkeyShortcut(_ shortcut: HotkeyShortcut) -> HotkeyValidationResult { let effectiveShortcut = settings.hotkeyShortcut guard let onHotkeyValidationRequested else { @@ -286,6 +443,13 @@ public final class AppState: ObservableObject { return onHotkeyValidationRequested(shortcut, effectiveShortcut) } + /// Validates and applies a hotkey shortcut, updating settings and the UI. + /// + /// If validation or system registration fails, the current shortcut is preserved and + /// `hotkeyCustomizationResult` is set to the rejected result. + /// + /// - Parameter shortcut: The shortcut to apply. + /// - Returns: A ``HotkeyValidationResult`` reflecting the outcome. @discardableResult public func applyHotkeyShortcut(_ shortcut: HotkeyShortcut) -> HotkeyValidationResult { let previousShortcut = settings.hotkeyShortcut @@ -328,6 +492,9 @@ public final class AppState: ObservableObject { return accepted } + /// Resets the global hotkey to the default shortcut (Cmd+Shift+P). + /// + /// - Returns: The validation result for the reset operation. @discardableResult public func resetHotkeyShortcutToDefault() -> HotkeyValidationResult { let result = applyHotkeyShortcut(.defaultShortcut) @@ -382,6 +549,11 @@ public final class AppState: ObservableObject { } } + /// Requests the system to enable or disable Launch at Login, then persists the change. + /// + /// On failure, `settingsSaveErrorMessage` is populated with a localised reason string. + /// + /// - Parameter isEnabled: The desired launch-at-login state. public func setLaunchAtLoginEnabled(_ isEnabled: Bool) async { settingsSaveErrorMessage = nil @@ -396,10 +568,14 @@ public final class AppState: ObservableObject { await mutateSettings { $0.launchAtLogin = isEnabled } } + /// Asks the host to open the settings window at the specified section. + /// + /// - Parameter section: The section to navigate to. Defaults to `.general`. public func openSettings(section: SettingsSection = .general) { onOpenSettingsRequested?(section) } + /// Clears all transient settings feedback (errors, validation messages, hotkey results). public func clearSettingsFeedback() { settingsSaveErrorMessage = nil settingsValidationMessage = nil diff --git a/FreeThinker/Core/Models/AppSettings.swift b/FreeThinker/Core/Models/AppSettings.swift index 42da472..efaaaee 100644 --- a/FreeThinker/Core/Models/AppSettings.swift +++ b/FreeThinker/Core/Models/AppSettings.swift @@ -1,20 +1,28 @@ import AppKit import Foundation +/// The on-device Foundation Model variant to use for generation. public enum ModelOption: String, Codable, CaseIterable, Identifiable, Sendable { + /// The default general-purpose model. case `default` + /// A model variant tuned for creative writing tasks. case creativeWriting public var id: String { rawValue } } +/// The critical-thinking style applied when composing provocation prompts. public enum ProvocationStylePreset: String, Codable, CaseIterable, Identifiable, Sendable { + /// Takes a contrary angle and challenges weak premises. case contrarian + /// Uses Socratic questioning to expose gaps in reasoning. case socratic + /// Analyses second-order effects and systemic trade-offs. case systemsThinking public var id: String { rawValue } + /// A human-readable name suitable for display in the UI. public var displayName: String { switch self { case .contrarian: @@ -26,6 +34,7 @@ public enum ProvocationStylePreset: String, Codable, CaseIterable, Identifiable, } } + /// The style instruction injected into the model prompt for this preset. public var instruction: String { switch self { case .contrarian: @@ -38,12 +47,16 @@ public enum ProvocationStylePreset: String, Codable, CaseIterable, Identifiable, } } +/// The software update channel the app subscribes to. public enum AppUpdateChannel: String, Codable, CaseIterable, Identifiable, Sendable { + /// Production releases only. case stable + /// Pre-release builds for early testing. case beta public var id: String { rawValue } + /// A human-readable channel name for display in Settings. public var displayName: String { switch self { case .stable: @@ -54,13 +67,18 @@ public enum AppUpdateChannel: String, Codable, CaseIterable, Identifiable, Senda } } +/// Identifies a named section within the Settings window. public enum SettingsSection: String, CaseIterable, Identifiable, Sendable { + /// General app preferences (hotkey, menu bar, launch at login, etc.). case general + /// Provocation style and prompt customisation settings. case provocation + /// Accessibility troubleshooting and permission guidance. case accessibilityHelp public var id: String { rawValue } + /// The localised title shown in the Settings navigation sidebar. public var title: String { switch self { case .general: @@ -73,38 +91,63 @@ public enum SettingsSection: String, CaseIterable, Identifiable, Sendable { } } +/// A global keyboard shortcut consisting of modifier flags and a key code. public struct HotkeyShortcut: Codable, Equatable, Hashable, Sendable { + /// The app's default shortcut: Cmd+Shift+P. public static let defaultShortcut = HotkeyShortcut( modifiers: AppSettings.defaultHotkeyModifiers, keyCode: AppSettings.defaultHotkeyKeyCode ) + /// The raw `NSEvent.ModifierFlags` value (e.g., command + shift). public let modifiers: Int + /// The virtual key code (Carbon key codes). public let keyCode: Int + /// Creates a shortcut with the given modifier flags and key code. + /// + /// - Parameters: + /// - modifiers: Raw `NSEvent.ModifierFlags` integer value. + /// - keyCode: Carbon virtual key code. public init(modifiers: Int, keyCode: Int) { self.modifiers = modifiers self.keyCode = keyCode } + /// A human-readable representation such as `"Cmd+Shift+P"`. public var displayString: String { HotkeyDisplayFormatter.displayString(for: self) } } +/// The outcome of validating a proposed global hotkey shortcut. public enum HotkeyValidationStatus: String, Equatable, Sendable { + /// The shortcut is acceptable and was (or can be) registered. case valid + /// The shortcut is structurally invalid (e.g., no modifier key). case invalid + /// The shortcut is reserved by macOS and cannot be used. case reserved + /// The shortcut is already claimed by another application. case conflict } +/// The result of validating a proposed hotkey shortcut. +/// +/// `proposedShortcut` is the shortcut the user requested; +/// `effectiveShortcut` is the shortcut that will actually be used +/// (may differ if the validator chose a safe alternative). public struct HotkeyValidationResult: Equatable, Sendable { + /// The validation outcome. public let status: HotkeyValidationStatus + /// An optional localised message to display to the user. public let message: String? + /// The shortcut the user originally requested. public let proposedShortcut: HotkeyShortcut + /// The shortcut that will actually be registered. public let effectiveShortcut: HotkeyShortcut + /// Creates a validation result with explicit values. public init( status: HotkeyValidationStatus, message: String?, @@ -117,6 +160,7 @@ public struct HotkeyValidationResult: Equatable, Sendable { self.effectiveShortcut = effectiveShortcut } + /// `true` when `status` is ``HotkeyValidationStatus/valid``. public var isAccepted: Bool { status == .valid } @@ -238,37 +282,73 @@ private extension HotkeyDisplayFormatter { } } +/// The root settings model for FreeThinker. +/// +/// `AppSettings` is persisted as JSON via a custom `Codable` implementation that +/// falls back to defaults for missing keys, enabling forward and backward compatibility. +/// Call ``validated()`` after loading from disk or applying user changes to clamp +/// all values to their legal ranges. public struct AppSettings: Codable, Equatable, Sendable { + /// The current JSON schema version; used to migrate old persisted values. public static let currentSchemaVersion = 2 + /// Default modifier flags for the global hotkey (Cmd+Shift = 1_179_648). public static let defaultHotkeyModifiers = 1_179_648 + /// Default key code for the global hotkey (P = 35). public static let defaultHotkeyKeyCode = 35 + /// Default text for the first custom prompt template. public static let defaultPrompt1 = "Identify hidden assumptions, unstated premises, or implicit biases in the following text." + /// Default text for the second custom prompt template. public static let defaultPrompt2 = "Provide a strong, well-reasoned counterargument or alternative perspective to the following claim." + /// Maximum allowed length (in characters) for prompt1 and prompt2. public static let maxPromptLength = 1_000 + /// Maximum allowed length (in characters) for `customStyleInstructions`. public static let maxCustomInstructionLength = 300 + /// Minimum allowed value for `autoDismissSeconds`. public static let minAutoDismissSeconds: TimeInterval = 2 + /// Maximum allowed value for `autoDismissSeconds`. public static let maxAutoDismissSeconds: TimeInterval = 20 + /// The persisted schema version of this settings value. public var schemaVersion: Int + /// Whether the global hotkey is active. public var hotkeyEnabled: Bool + /// Raw `NSEvent.ModifierFlags` for the global hotkey. public var hotkeyModifiers: Int + /// Carbon virtual key code for the global hotkey. public var hotkeyKeyCode: Int + /// The first customisable prompt template (hidden assumptions style). public var prompt1: String + /// The second customisable prompt template (counterargument style). public var prompt2: String + /// Whether the app launches at login. public var launchAtLogin: Bool + /// The on-device model variant used for generation. public var selectedModel: ModelOption + /// Whether the menu bar status item is visible. public var showMenuBarIcon: Bool + /// Whether the panel is automatically dismissed after the user copies a result. public var dismissOnCopy: Bool + /// Seconds before the panel auto-dismisses after showing a result (clamped to 2–20). public var autoDismissSeconds: TimeInterval + /// Whether clipboard-based text capture fallback is enabled. public var fallbackCaptureEnabled: Bool + /// Whether diagnostic event recording is active. public var diagnosticsEnabled: Bool + /// `true` once the user has seen the onboarding flow at least once. public var hasSeenOnboarding: Bool + /// `true` once the user has completed all onboarding steps. public var onboardingCompleted: Bool + /// `true` once the user has confirmed awareness of the global hotkey. public var hotkeyAwarenessConfirmed: Bool + /// The active provocation style preset. public var provocationStylePreset: ProvocationStylePreset + /// Optional extra instructions appended to the style section of the prompt (max 300 chars). public var customStyleInstructions: String + /// Whether Sparkle checks for updates automatically. public var automaticallyCheckForUpdates: Bool + /// The update channel (stable or beta). public var appUpdateChannel: AppUpdateChannel + /// Maximum seconds the AI generation may run before timing out (clamped to 1–15). public var aiTimeoutSeconds: TimeInterval public init( @@ -384,6 +464,20 @@ public extension AppSettings { } public extension AppSettings { + /// Returns a copy of these settings with all values clamped to their valid ranges. + /// + /// Specifically: + /// - `schemaVersion` is bumped to ``currentSchemaVersion`` if lower. + /// - `hotkeyKeyCode` is reset to ``defaultHotkeyKeyCode`` if out of range 0–127. + /// - `hotkeyModifiers` is reset to ``defaultHotkeyModifiers`` if negative. + /// - `prompt1` and `prompt2` are trimmed and truncated to ``maxPromptLength``, then + /// restored to their defaults if empty. + /// - `customStyleInstructions` is sanitised and truncated to ``maxCustomInstructionLength``. + /// - `autoDismissSeconds` is clamped to `minAutoDismissSeconds...maxAutoDismissSeconds`. + /// - `aiTimeoutSeconds` is clamped to 1...15. + /// - `hasSeenOnboarding` is set to `true` if `onboardingCompleted` is `true`. + /// + /// - Returns: A validated copy of `self`. func validated() -> AppSettings { var result = self diff --git a/FreeThinker/Core/Models/DiagnosticEvent.swift b/FreeThinker/Core/Models/DiagnosticEvent.swift index a97423d..6fc1911 100644 --- a/FreeThinker/Core/Models/DiagnosticEvent.swift +++ b/FreeThinker/Core/Models/DiagnosticEvent.swift @@ -1,33 +1,69 @@ import Foundation +/// Identifies the functional area of the app that produced a diagnostic event. public enum DiagnosticStage: String, Codable, Equatable, Sendable, CaseIterable { + /// App startup and shutdown events. case appLifecycle + /// Onboarding flow events. case onboarding + /// Settings changes and persistence events. case settings + /// Accessibility permission check before text capture. case permissionPreflight + /// Selected text reading and clipboard fallback events. case textCapture + /// AI model prompt submission and response events. case aiGeneration + /// Panel state transitions after a response is received. case responsePresentation + /// Diagnostics export operations. case export } +/// The severity level of a diagnostic event. public enum DiagnosticCategory: String, Codable, Equatable, Sendable { + /// Informational; normal operation. case info + /// Unexpected but non-fatal condition. case warning + /// A failure that affected the user. case error } +/// A single recorded diagnostic event. +/// +/// Messages and metadata values are automatically redacted on init: +/// - The message is truncated to ``maxMessageLength``. +/// - Metadata keys matching known sensitive patterns are replaced with `"[REDACTED]"`. +/// - All metadata values are truncated to ``maxMetadataValueLength``. public struct DiagnosticEvent: Codable, Equatable, Identifiable, Sendable { + /// Maximum character length for ``message``. public static let maxMessageLength = 240 + /// Maximum character length for each metadata value. public static let maxMetadataValueLength = 160 + /// Unique identifier for this event. public let id: UUID + /// The wall-clock time when the event was recorded. public let timestamp: Date + /// The functional area that produced this event. public let stage: DiagnosticStage + /// The severity of this event. public let category: DiagnosticCategory + /// A brief human-readable description (truncated and sanitised). public let message: String + /// Structured key-value metadata (values truncated and sensitive keys redacted). public let metadata: [String: String] + /// Creates a `DiagnosticEvent`, sanitising message and metadata on init. + /// + /// - Parameters: + /// - id: Unique event ID. Defaults to a new `UUID()`. + /// - timestamp: Event time. Defaults to `Date()`. + /// - stage: The functional area producing the event. + /// - category: The event severity. + /// - message: A brief description; truncated to ``maxMessageLength``. + /// - metadata: Supplemental key-value pairs; sensitive keys are redacted. public init( id: UUID = UUID(), timestamp: Date = Date(), @@ -46,6 +82,9 @@ public struct DiagnosticEvent: Codable, Equatable, Identifiable, Sendable { } public extension DiagnosticEvent { + /// Returns a copy of this event after passing all fields through sanitisation again. + /// + /// Useful when loading persisted events that may have been written by an older version. func sanitized() -> DiagnosticEvent { DiagnosticEvent( id: id, diff --git a/FreeThinker/Core/Models/ProvocationRequest.swift b/FreeThinker/Core/Models/ProvocationRequest.swift index ba91152..7bd905f 100644 --- a/FreeThinker/Core/Models/ProvocationRequest.swift +++ b/FreeThinker/Core/Models/ProvocationRequest.swift @@ -1,15 +1,26 @@ import Foundation +/// The style of critical-thinking challenge the AI should generate. public enum ProvocationType: String, Codable, CaseIterable, Sendable { + /// Surface hidden assumptions and unstated premises in the text. case hiddenAssumptions + /// Produce a strong counterargument or alternative perspective. case counterargument + /// Apply a custom challenge framing defined by the user's prompt. case custom } +/// A validated request to generate a provocation for a piece of selected text. +/// +/// The initialiser trims whitespace and throws if the result is empty. The text is +/// truncated to ``maxSelectedTextLength`` characters on init. public struct ProvocationRequest: Identifiable, Codable, Equatable, Sendable { + /// Maximum number of characters retained from the selected text. public static let maxSelectedTextLength = 1_000 + /// Errors thrown during ``ProvocationRequest`` initialisation. public enum ValidationError: LocalizedError, Equatable, Sendable { + /// The selected text was empty or consisted only of whitespace. case emptySelectedText public var errorDescription: String? { @@ -20,12 +31,26 @@ public struct ProvocationRequest: Identifiable, Codable, Equatable, Sendable { } } + /// Unique identifier for this request. public let id: UUID + /// The user's selected text, trimmed and truncated to ``maxSelectedTextLength``. public let selectedText: String + /// The type of critical-thinking challenge requested. public let provocationType: ProvocationType + /// The time this request was created. public let timestamp: Date + /// When set, indicates this is a regeneration of the response with this ID. public let regenerateFromResponseID: UUID? + /// Creates a validated `ProvocationRequest`. + /// + /// - Parameters: + /// - id: A unique identifier. Defaults to a new `UUID()`. + /// - selectedText: The text the user selected. Must be non-empty after trimming. + /// - provocationType: The challenge type to apply. + /// - timestamp: When the request was created. Defaults to `Date()`. + /// - regenerateFromResponseID: ID of a prior response to regenerate, or `nil`. + /// - Throws: ``ValidationError/emptySelectedText`` if `selectedText` is blank. public init( id: UUID = UUID(), selectedText: String, diff --git a/FreeThinker/Core/Models/ProvocationResponse.swift b/FreeThinker/Core/Models/ProvocationResponse.swift index 1694803..65628f2 100644 --- a/FreeThinker/Core/Models/ProvocationResponse.swift +++ b/FreeThinker/Core/Models/ProvocationResponse.swift @@ -1,12 +1,24 @@ import Foundation +/// The parsed content of a successful provocation generation. +/// +/// Both `body` and `followUpQuestion` are trimmed and truncated on init. public struct ProvocationContent: Equatable, Codable, Sendable { + /// Maximum character length for `body`. public static let maxBodyLength = 420 + /// Maximum character length for `followUpQuestion`. public static let maxFollowUpLength = 140 + /// The main provocation text (max 420 characters). public let body: String + /// An optional follow-up question to deepen the provocation (max 140 characters). public let followUpQuestion: String? + /// Creates `ProvocationContent`, trimming and truncating both fields. + /// + /// - Parameters: + /// - body: The main provocation body text. + /// - followUpQuestion: An optional question; `nil` or empty strings are stored as `nil`. public init(body: String, followUpQuestion: String? = nil) { self.body = String(body.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.maxBodyLength)) if let followUpQuestion { @@ -18,21 +30,47 @@ public struct ProvocationContent: Equatable, Codable, Sendable { } } +/// The result of a single generation attempt. public enum ProvocationOutcome: Equatable, Sendable { + /// Generation succeeded; the associated content is ready to display. case success(content: ProvocationContent) + /// Generation failed; the associated error describes the cause. case failure(error: FreeThinkerError) } +/// A complete record of a provocation generation request and its outcome. +/// +/// `ProvocationResponse` is immutable after creation. Use the convenience +/// properties ``isSuccess``, ``content``, and ``error`` to inspect the outcome. public struct ProvocationResponse: Identifiable, Equatable, Sendable { + /// Unique identifier for this response. public let id: UUID + /// The ID of the ``ProvocationRequest`` that produced this response. public let requestId: UUID + /// The original selected text, truncated to ``ProvocationRequest/maxSelectedTextLength``. public let originalText: String + /// The provocation type used for this generation. public let provocationType: ProvocationType + /// The style preset active when the response was generated. public let styleUsed: ProvocationStylePreset + /// Whether the generation succeeded or failed, with associated data. public let outcome: ProvocationOutcome + /// Wall-clock seconds elapsed from request start to response, clamped to ≥ 0. public let generationTime: TimeInterval + /// The wall-clock time when the response was created. public let timestamp: Date + /// Creates a `ProvocationResponse`. + /// + /// - Parameters: + /// - id: A unique response identifier. Defaults to a new `UUID()`. + /// - requestId: The ID of the originating ``ProvocationRequest``. + /// - originalText: The selected text that was sent to the model. + /// - provocationType: The challenge type used. + /// - styleUsed: The style preset active at generation time. + /// - outcome: The success or failure result. + /// - generationTime: Elapsed generation time in seconds (clamped to ≥ 0). + /// - timestamp: Creation time. Defaults to `Date()`. public init( id: UUID = UUID(), requestId: UUID, @@ -55,16 +93,19 @@ public struct ProvocationResponse: Identifiable, Equatable, Sendable { } public extension ProvocationResponse { + /// `true` when ``outcome`` is ``ProvocationOutcome/success(content:)``. var isSuccess: Bool { if case .success = outcome { return true } return false } + /// The ``ProvocationContent`` from a successful outcome, or `nil` on failure. var content: ProvocationContent? { if case let .success(content) = outcome { return content } return nil } + /// The ``FreeThinkerError`` from a failed outcome, or `nil` on success. var error: FreeThinkerError? { if case let .failure(error) = outcome { return error } return nil diff --git a/FreeThinker/Core/Services/AIService.swift b/FreeThinker/Core/Services/AIService.swift index 5cc407e..367d0c5 100644 --- a/FreeThinker/Core/Services/AIService.swift +++ b/FreeThinker/Core/Services/AIService.swift @@ -1,40 +1,120 @@ import Foundation +/// Protocol defining the interface for an AI service that generates provocations. +/// +/// Conforming types are actors to ensure safe concurrent access. All methods +/// must be called within an async context and support Swift structured concurrency. public protocol AIServiceProtocol: Actor, Sendable { + /// Whether the underlying AI model is currently available on this device. var isAvailable: Bool { get } + + /// The model currently selected for generation. var currentModel: ModelOption { get } + /// Updates the selected model for future generation requests. + /// + /// - Parameter model: The model to use for subsequent ``generateProvocation(request:settings:)`` calls. func setCurrentModel(_ model: ModelOption) + + /// Warms up the model so the first generation request completes faster. + /// + /// Call this during app startup or after changing ``currentModel``. + /// Throws if the model cannot be loaded (e.g., hardware unsupported). func preloadModel() async throws + + /// Generates a provocation response for the given request and settings. + /// + /// This method never throws; all errors are encoded into the returned + /// ``ProvocationResponse`` via ``ProvocationOutcome/failure(error:)``. + /// + /// - Parameters: + /// - request: The provocation request containing the selected text and type. + /// - settings: Current app settings controlling model, timeout, and style. + /// - Returns: A ``ProvocationResponse`` containing either generated content or an error. func generateProvocation(request: ProvocationRequest, settings: AppSettings) async -> ProvocationResponse } +/// Options controlling how the Foundation Models framework generates text. public struct FoundationGenerationOptions: Equatable, Sendable { + /// The model variant to use for generation. public var model: ModelOption + + /// The maximum number of characters the model may output. + /// + /// Defaults to 700 characters, which is enough for a BODY + FOLLOW_UP response. public var maximumOutputCharacters: Int + /// Creates generation options with the specified model and output limit. + /// + /// - Parameters: + /// - model: The model variant to use. + /// - maximumOutputCharacters: Output character budget. Defaults to `700`. public init(model: ModelOption, maximumOutputCharacters: Int = 700) { self.model = model self.maximumOutputCharacters = maximumOutputCharacters } } +/// Describes the availability of the Foundation Models framework on the current device. public enum FoundationModelAvailability: Equatable, Sendable { + /// The model is ready for use. case available + /// The operating system version does not support the framework. case unsupportedOperatingSystem + /// The device hardware (e.g., non-Apple Silicon) cannot run on-device models. case unsupportedHardware + /// The framework binary is not present in this build configuration. case frameworkUnavailable + /// The specific model variant is not available or has not been downloaded. case modelUnavailable } +/// Protocol abstracting the Foundation Models framework for testability. +/// +/// Conforming types wrap platform-specific APIs so that ``DefaultAIService`` +/// can be tested without requiring actual on-device inference. public protocol FoundationModelsAdapterProtocol: Sendable { + /// Returns the current availability of the Foundation Models framework. func availability() -> FoundationModelAvailability + + /// Warms up the specified model, blocking until it is ready to generate. + /// + /// - Parameter model: The model variant to preload. + /// - Throws: ``FreeThinkerError/modelUnavailable`` or related errors if loading fails. func preload(model: ModelOption) async throws + + /// Generates text from the given prompt using the specified options. + /// + /// - Parameters: + /// - prompt: The instruction and context string sent to the model. + /// - options: Generation parameters including model variant and output limit. + /// - Returns: The raw text output from the model. + /// - Throws: ``FreeThinkerError`` variants on failure, or `CancellationError` if cancelled. func generate(prompt: String, options: FoundationGenerationOptions) async throws -> String } +/// Protocol for composing the prompt strings sent to the AI model. +/// +/// Implementations are value types (`Sendable`) so they can be captured safely +/// by the actor-isolated ``DefaultAIService``. public protocol ProvocationPromptComposing: Sendable { + /// Composes the initial prompt for a provocation request. + /// + /// - Parameters: + /// - request: The provocation request specifying text and type. + /// - settings: App settings that influence style and prompt templates. + /// - Returns: A fully-formatted prompt string ready for model inference. func composePrompt(for request: ProvocationRequest, settings: AppSettings) -> String + + /// Composes a follow-up prompt that generates a distinctly different provocation. + /// + /// Used during regeneration to avoid repeating the previous response. + /// + /// - Parameters: + /// - request: The original provocation request. + /// - previousResponse: The content from the prior generation to avoid duplicating. + /// - settings: App settings controlling style. + /// - Returns: A prompt string instructing the model to produce a different result. func composeFollowUpPrompt( for request: ProvocationRequest, previousResponse: ProvocationContent, @@ -42,6 +122,16 @@ public protocol ProvocationPromptComposing: Sendable { ) -> String } +/// Protocol for parsing raw model output into structured ``ProvocationContent``. +/// +/// Implementations are value types (`Sendable`) so they can be captured safely +/// by the actor-isolated ``DefaultAIService``. public protocol ProvocationResponseParsing: Sendable { + /// Parses raw text output from the model into structured content. + /// + /// - Parameter rawOutput: The unstructured string returned by the model. + /// - Returns: A ``ProvocationContent`` with a `body` and optional `followUpQuestion`. + /// - Throws: ``FreeThinkerError/invalidResponse`` if the output cannot be parsed, + /// or ``FreeThinkerError/generationFailed`` if the output is empty. func parse(rawOutput: String) throws -> ProvocationContent } diff --git a/FreeThinker/Core/Services/DefaultAIService.swift b/FreeThinker/Core/Services/DefaultAIService.swift index 8e2c5b1..cbcf5bd 100644 --- a/FreeThinker/Core/Services/DefaultAIService.swift +++ b/FreeThinker/Core/Services/DefaultAIService.swift @@ -1,9 +1,18 @@ import Foundation import os +/// Concrete actor that implements ``AIServiceProtocol`` using the Foundation Models framework. +/// +/// `DefaultAIService` composes prompts, delegates inference to a ``FoundationModelsAdapterProtocol``, +/// and parses raw output into ``ProvocationContent``. It wraps generation in a configurable timeout +/// and retries transient failures up to `maxInitializationRetries` times with exponential back-off. +/// +/// Actor isolation guarantees that `currentModel` mutations are safe across concurrent callers. public actor DefaultAIService: AIServiceProtocol { + /// The model variant currently configured for generation. public private(set) var currentModel: ModelOption + /// `true` when the underlying adapter reports ``FoundationModelAvailability/available``. public var isAvailable: Bool { adapter.availability() == .available } @@ -15,6 +24,16 @@ public actor DefaultAIService: AIServiceProtocol { private let maxInitializationRetries: Int private let retryBackoffNanoseconds: UInt64 + /// Creates a `DefaultAIService` with the given dependencies. + /// + /// - Parameters: + /// - adapter: Foundation Models adapter used for inference. Defaults to `FoundationModelsAdapter()`. + /// - promptComposer: Composes prompt strings from requests and settings. + /// - parser: Parses raw model output into ``ProvocationContent``. + /// - currentModel: The initial model variant. Defaults to ``ModelOption/default``. + /// - maxInitializationRetries: How many times to retry a transient failure (0 = no retries). Defaults to 2. + /// - retryBackoffNanoseconds: Base back-off duration between retry attempts. Defaults to 150 ms. + /// - clock: Time source for timeout and retry scheduling. public init( adapter: any FoundationModelsAdapterProtocol = FoundationModelsAdapter(), promptComposer: any ProvocationPromptComposing = ProvocationPromptComposer(), @@ -33,14 +52,30 @@ public actor DefaultAIService: AIServiceProtocol { self.clock = clock } + /// Updates the active model variant for future generation calls. + /// + /// - Parameter model: The new model to use. public func setCurrentModel(_ model: ModelOption) { currentModel = model } + /// Warms up `currentModel` by delegating to the adapter's preload routine. + /// + /// - Throws: Errors propagated from the adapter if the model cannot be loaded. public func preloadModel() async throws { try await adapter.preload(model: currentModel) } + /// Generates a provocation for the given request, applying timeout and retry logic. + /// + /// Settings are validated before use. `currentModel` is updated to match + /// `settings.selectedModel` at the start of each call. The method never throws; + /// errors are captured in the returned response's ``ProvocationOutcome/failure(error:)`` case. + /// + /// - Parameters: + /// - request: The provocation request with selected text and type. + /// - settings: App settings specifying model, timeout, and style. + /// - Returns: A ``ProvocationResponse`` containing generated content or an error. public func generateProvocation(request: ProvocationRequest, settings: AppSettings) async -> ProvocationResponse { let normalizedSettings = settings.validated() let startedAt = clock.now() @@ -248,18 +283,35 @@ private struct TimeoutTaskStore: Sendable { } } +/// Protocol for the time source used by ``DefaultAIService``. +/// +/// Abstracting the clock allows tests to inject a controlled time source +/// without requiring real delays. public protocol AIServiceClock: Sendable { + /// Returns the current wall-clock date. func now() -> Date + + /// Suspends the current task for the given number of nanoseconds. + /// + /// - Parameter nanoseconds: The sleep duration in nanoseconds. + /// - Throws: `CancellationError` if the task is cancelled during sleep. func sleep(nanoseconds: UInt64) async throws } +/// The default clock backed by `Date()` and `Task.sleep`. public struct SystemAIServiceClock: AIServiceClock { + /// Creates the system clock. public init() {} + /// Returns the current date from the system clock. public func now() -> Date { Date() } + /// Sleeps using `Task.sleep(nanoseconds:)`. + /// + /// - Parameter nanoseconds: The sleep duration in nanoseconds. + /// - Throws: `CancellationError` if the task is cancelled. public func sleep(nanoseconds: UInt64) async throws { try await Task.sleep(nanoseconds: nanoseconds) } diff --git a/FreeThinker/Core/Services/DefaultTextCaptureService.swift b/FreeThinker/Core/Services/DefaultTextCaptureService.swift index 0b3db71..ff6659b 100644 --- a/FreeThinker/Core/Services/DefaultTextCaptureService.swift +++ b/FreeThinker/Core/Services/DefaultTextCaptureService.swift @@ -3,18 +3,50 @@ import ApplicationServices import Carbon.HIToolbox.Events import Foundation +/// The result of checking whether the app has Accessibility permission. public enum TextCapturePermissionStatus: Equatable, Sendable { + /// Permission has been granted; text capture can proceed. case granted + /// Permission has been denied; capture will fail. case denied } +/// Protocol for reading the user's currently selected text from the active application. +/// +/// Conforming types are actors to ensure safe concurrent access to their mutable state. public protocol TextCaptureServiceProtocol: Actor, Sendable { + /// Checks whether Accessibility permission is available without prompting. + /// + /// - Returns: ``TextCapturePermissionStatus/granted`` if the app can read selected text. func preflightPermission() -> TextCapturePermissionStatus + + /// Enables or disables the clipboard-based fallback capture strategy. + /// + /// - Parameter isEnabled: `true` to allow clipboard fallback; `false` to disable it. func setFallbackCaptureEnabled(_ isEnabled: Bool) + + /// Reads the user's currently selected text from the focused application. + /// + /// Tries the Accessibility API first. If that yields no text and fallback is enabled, + /// it simulates Cmd+C and reads the clipboard result, restoring the original clipboard + /// contents afterwards. + /// + /// - Returns: The trimmed selected text, truncated to ``ProvocationRequest/maxSelectedTextLength``. + /// - Throws: ``FreeThinkerError/accessibilityPermissionDenied`` if permission is absent, + /// or ``FreeThinkerError/noSelection`` if no text could be captured. func captureSelectedText() async throws -> String + + /// Presents the macOS Accessibility permission prompt if one has not been shown recently. + /// + /// Respects a cooldown window to avoid showing the prompt repeatedly. func requestAccessibilityPermissionPromptIfNeeded() } +/// Concrete actor that implements ``TextCaptureServiceProtocol`` using the Accessibility API +/// with an optional clipboard-based fallback. +/// +/// All dependencies are injectable for unit testing, including the permission checker, +/// the AX selection provider, and the clipboard fallback provider. public actor DefaultTextCaptureService: TextCaptureServiceProtocol { private let maxSelectionLength: Int private let permissionChecker: @Sendable () -> Bool @@ -27,6 +59,20 @@ public actor DefaultTextCaptureService: TextCaptureServiceProtocol { private var fallbackCaptureEnabled: Bool private var lastPermissionPromptUptimeNanoseconds: UInt64? + /// Creates a `DefaultTextCaptureService` with injectable dependencies. + /// + /// - Parameters: + /// - maxSelectionLength: Characters to retain from the captured selection. + /// Defaults to ``ProvocationRequest/maxSelectedTextLength``. + /// - fallbackCaptureEnabled: Whether the clipboard fallback strategy is active. Defaults to `true`. + /// - permissionChecker: Closure that returns `AXIsProcessTrusted()`. Injected for testing. + /// - permissionPromptRequester: Closure that requests the Accessibility prompt. Injected for testing. + /// - permissionPromptCooldownNanoseconds: Minimum nanoseconds between successive prompts. + /// Defaults to 6 seconds. + /// - uptimeNanosecondsProvider: Closure returning the current system uptime. Injected for testing. + /// - accessibilityReachabilityProbe: Probe that verifies the AX API is reachable. Injected for testing. + /// - accessibilitySelectionProvider: Closure reading `kAXSelectedTextAttribute`. Injected for testing. + /// - clipboardFallbackProvider: Optional replacement for the Cmd+C fallback. Injected for testing. public init( maxSelectionLength: Int = ProvocationRequest.maxSelectedTextLength, fallbackCaptureEnabled: Bool = true, diff --git a/FreeThinker/Core/Services/ProvocationOrchestrator.swift b/FreeThinker/Core/Services/ProvocationOrchestrator.swift index a470152..4105497 100644 --- a/FreeThinker/Core/Services/ProvocationOrchestrator.swift +++ b/FreeThinker/Core/Services/ProvocationOrchestrator.swift @@ -1,53 +1,108 @@ import Foundation +/// Identifies what initiated a provocation generation pipeline run. public enum ProvocationTriggerSource: String, Sendable { + /// The user pressed the global hotkey. case hotkey + /// The user invoked generation via the menu bar item. case menu + /// The user requested a new result for the same selected text. case regenerate } +/// The decision made by the orchestrator when a trigger is received. public enum ProvocationTriggerDecision: Equatable, Sendable { + /// A new pipeline was started. case started + /// The trigger was ignored because a generation was already in flight + /// and the source was not `.regenerate`. case droppedInFlight + /// The trigger arrived within the debounce window after the previous trigger. case debounced } +/// The reason a running generation pipeline was cancelled. public enum ProvocationCancellationReason: String, Sendable { + /// The user dismissed or closed the floating panel. case userClosedPanel + /// A regenerate trigger arrived, replacing the current run. case regenerateRequested + /// The application is about to terminate. case appWillTerminate } +/// Cumulative counters tracking orchestrator activity for the lifetime of the actor. public struct ProvocationOrchestratorMetrics: Equatable, Sendable { + /// Total number of triggers received (before any filtering). public var triggerReceived: Int = 0 + /// Triggers that successfully started a pipeline run. public var triggerStarted: Int = 0 + /// Triggers dropped because a generation was already in flight. public var droppedInFlight: Int = 0 + /// Triggers dropped because they arrived within the debounce window. public var droppedDebounced: Int = 0 + /// Number of pipeline runs cancelled for any reason. public var cancellationCount: Int = 0 + /// Creates a zero-valued metrics instance. public init() {} } +/// Protocol for time sources used by the orchestrator for debounce calculations. +/// +/// Abstracting the clock enables deterministic unit testing without real-time waits. public protocol ProvocationOrchestratorClock: Sendable { + /// Returns the current uptime in nanoseconds, used for debounce comparisons. func nowUptimeNanoseconds() -> UInt64 } +/// The default clock implementation backed by `DispatchTime`. public struct SystemProvocationOrchestratorClock: ProvocationOrchestratorClock { + /// Creates the system clock. public init() {} + /// Returns the current system uptime in nanoseconds. public func nowUptimeNanoseconds() -> UInt64 { DispatchTime.now().uptimeNanoseconds } } +/// A collection of async closures the orchestrator calls to update UI state. +/// +/// All closures are called from the orchestrator actor context, so each closure +/// must hop to the `@MainActor` internally if it modifies UI state. public struct ProvocationOrchestratorCallbacks { + /// Called when the generation in-progress state changes. + /// + /// - Parameter isGenerating: `true` when a pipeline run starts; `false` when it ends. public var setGenerating: (Bool) async -> Void + + /// Called to show the panel in a loading state with an optional text preview. + /// + /// - Parameter selectedTextPreview: A truncated preview of the selected text, or `nil`. public var presentLoading: (String?) async -> Void + + /// Called when the pipeline has produced a result (success or failure in response). + /// + /// - Parameter response: The completed ``ProvocationResponse``. public var presentResponse: (ProvocationResponse) async -> Void + + /// Called when an error should be presented in the floating panel. + /// + /// - Parameter presentation: The mapped error with message and suggested action. public var presentError: (ErrorPresentation) async -> Void + + /// Called to check whether the floating panel is currently visible. + /// + /// - Returns: `true` if the panel is visible, `false` otherwise. public var isPanelVisible: () async -> Bool + + /// Called to show a background notification when the panel is not visible. + /// + /// - Parameter message: The notification message string. public var notifyBackgroundMessage: (String) async -> Void + /// Creates a callbacks struct with all closures explicitly provided. public init( setGenerating: @escaping (Bool) async -> Void, presentLoading: @escaping (String?) async -> Void, @@ -64,6 +119,7 @@ public struct ProvocationOrchestratorCallbacks { self.notifyBackgroundMessage = notifyBackgroundMessage } + /// A no-op callbacks instance suitable for tests that don't need UI updates. public static let noOp = ProvocationOrchestratorCallbacks( setGenerating: { _ in }, presentLoading: { _ in }, @@ -74,16 +130,48 @@ public struct ProvocationOrchestratorCallbacks { ) } +/// Protocol defining the public interface for the provocation orchestrator. +/// +/// Conforming types coordinate text capture, AI generation, and UI callback +/// invocation in response to user-initiated triggers. Actor isolation ensures +/// safe concurrent access to in-flight state. public protocol ProvocationOrchestrating: Actor, Sendable { + /// Attempts to start a new provocation pipeline for the given trigger. + /// + /// - Parameters: + /// - source: Where the trigger originated. + /// - regenerateFromResponseID: When `source` is `.regenerate`, the ID of the + /// previous response whose selected text should be reused. + /// - Returns: A ``ProvocationTriggerDecision`` indicating whether the pipeline started. func trigger( source: ProvocationTriggerSource, regenerateFromResponseID: UUID? ) async -> ProvocationTriggerDecision + /// Cancels any currently running generation pipeline. + /// + /// Awaits task completion before returning to ensure the pipeline is fully torn down. + /// + /// - Parameter reason: The semantic reason for cancellation, used in diagnostics. func cancelCurrentGeneration(reason: ProvocationCancellationReason) async + + /// Returns a snapshot of the orchestrator's cumulative activity metrics. func currentMetrics() -> ProvocationOrchestratorMetrics } +/// Actor that coordinates the end-to-end provocation generation pipeline. +/// +/// `ProvocationOrchestrator` sequences the following stages on each trigger: +/// 1. Permission preflight — verifies Accessibility permission. +/// 2. Text capture — reads the user's current selection. +/// 3. Panel loading — shows the floating panel in a loading state. +/// 4. Request composition — wraps the selection in a ``ProvocationRequest``. +/// 5. AI generation — sends the request to ``AIServiceProtocol``. +/// 6. Response presentation — delivers success or error via ``ProvocationOrchestratorCallbacks``. +/// +/// At most one pipeline runs at a time. Concurrent triggers are either dropped or +/// replace the current run (for `.regenerate` triggers). A debounce window prevents +/// rapid repeated triggers from starting redundant pipelines. public actor ProvocationOrchestrator: ProvocationOrchestrating { private let textCaptureService: any TextCaptureServiceProtocol private let aiService: any AIServiceProtocol @@ -100,6 +188,17 @@ public actor ProvocationOrchestrator: ProvocationOrchestrating { private var selectionByResponseID: [UUID: String] = [:] private var metrics = ProvocationOrchestratorMetrics() + /// Creates a new orchestrator with the specified dependencies. + /// + /// - Parameters: + /// - textCaptureService: Service used to read the user's selected text. + /// - aiService: Service that performs AI generation. + /// - settingsProvider: Async closure returning the current ``AppSettings`` at the time of each pipeline run. + /// - errorMapper: Maps ``FreeThinkerError`` values to user-facing ``ErrorPresentation`` instances. + /// - callbacks: Closures the orchestrator calls to update UI state. + /// - diagnosticsLogger: Optional logger for recording pipeline stage events. + /// - clock: Time source for debounce calculations; defaults to the system uptime clock. + /// - debounceNanoseconds: Minimum nanoseconds between accepted triggers. Defaults to 300 ms. public init( textCaptureService: any TextCaptureServiceProtocol, aiService: any AIServiceProtocol, @@ -120,6 +219,19 @@ public actor ProvocationOrchestrator: ProvocationOrchestrating { self.debounceNanoseconds = debounceNanoseconds } + /// Attempts to start a new provocation pipeline run. + /// + /// If a generation is already in flight and `source` is `.regenerate`, the + /// current run is cancelled and a new one starts using the cached selected text. + /// For all other sources, an in-flight run causes the trigger to be dropped. + /// + /// - Parameters: + /// - source: The origin of the trigger (hotkey, menu, or regenerate). + /// - regenerateFromResponseID: For `.regenerate` triggers, the ID of the previous + /// response whose original selection should be reused. + /// - Returns: ``ProvocationTriggerDecision/started`` if the pipeline launched, + /// ``ProvocationTriggerDecision/droppedInFlight`` if another run was active, + /// or ``ProvocationTriggerDecision/debounced`` if the trigger arrived too soon. public func trigger( source: ProvocationTriggerSource, regenerateFromResponseID: UUID? = nil @@ -155,6 +267,11 @@ public actor ProvocationOrchestrator: ProvocationOrchestrating { return .started } + /// Cancels the currently running generation task, if any, and waits for it to finish. + /// + /// After this method returns, the orchestrator is idle and ready to accept new triggers. + /// + /// - Parameter reason: The reason for cancellation, recorded in diagnostics and metrics. public func cancelCurrentGeneration(reason: ProvocationCancellationReason) async { guard let task = generationTask else { return @@ -165,6 +282,7 @@ public actor ProvocationOrchestrator: ProvocationOrchestrating { await task.value } + /// Returns a snapshot of the orchestrator's cumulative activity counters. public func currentMetrics() -> ProvocationOrchestratorMetrics { metrics } diff --git a/FreeThinker/Core/Services/ProvocationPromptComposer.swift b/FreeThinker/Core/Services/ProvocationPromptComposer.swift index e95a9ae..261bae2 100644 --- a/FreeThinker/Core/Services/ProvocationPromptComposer.swift +++ b/FreeThinker/Core/Services/ProvocationPromptComposer.swift @@ -1,10 +1,29 @@ import Foundation +/// Concrete implementation of ``ProvocationPromptComposing`` that formats the full +/// prompt sent to the Foundation Models framework. +/// +/// The prompt structure is: +/// - A system persona line. +/// - A TASK section derived from the request's ``ProvocationType`` and custom prompts. +/// - A STYLE section from the active ``ProvocationStylePreset`` and any custom instructions. +/// - A strict OUTPUT FORMAT specifying BODY and FOLLOW_UP labels. +/// - Rules constraining response length and style. +/// - The sanitised selected text delimited by triple-quotes. public struct ProvocationPromptComposer: ProvocationPromptComposing, Sendable { private static let maxSanitizedTextLength = ProvocationRequest.maxSelectedTextLength + /// Creates a default `ProvocationPromptComposer`. public init() {} + /// Composes the initial provocation prompt for the given request and settings. + /// + /// Settings are validated before use. The selected text is sanitised and truncated. + /// + /// - Parameters: + /// - request: The provocation request containing the selected text and type. + /// - settings: App settings providing prompts, style, and custom instructions. + /// - Returns: The fully-formatted prompt string. public func composePrompt(for request: ProvocationRequest, settings: AppSettings) -> String { let normalizedSettings = settings.validated() let selectedText = sanitizeSelectedText(request.selectedText, maxLength: Self.maxSanitizedTextLength) @@ -46,6 +65,15 @@ public struct ProvocationPromptComposer: ProvocationPromptComposing, Sendable { """ } + /// Composes a follow-up prompt that generates a distinct alternative provocation. + /// + /// Includes the previous response's body in the prompt so the model avoids repeating it. + /// + /// - Parameters: + /// - request: The original provocation request. + /// - previousResponse: The prior generation result to avoid duplicating. + /// - settings: App settings controlling style. + /// - Returns: A prompt string instructing the model to produce a different result. public func composeFollowUpPrompt( for request: ProvocationRequest, previousResponse: ProvocationContent, diff --git a/FreeThinker/Core/Services/ProvocationResponseParser.swift b/FreeThinker/Core/Services/ProvocationResponseParser.swift index 6cd96cf..d217126 100644 --- a/FreeThinker/Core/Services/ProvocationResponseParser.swift +++ b/FreeThinker/Core/Services/ProvocationResponseParser.swift @@ -1,8 +1,22 @@ import Foundation +/// Concrete implementation of ``ProvocationResponseParsing`` that extracts `BODY:` and +/// `FOLLOW_UP:` labelled sections from raw model output. +/// +/// The parser is lenient: if the expected labels are absent, the entire output is used +/// as the body and a trailing question mark heuristic is applied to extract a follow-up. +/// Text is normalised (whitespace collapsed, NUL bytes removed) and truncated to the +/// ``ProvocationContent`` length constants before being returned. public struct ProvocationResponseParser: ProvocationResponseParsing, Sendable { + /// Creates a default `ProvocationResponseParser`. public init() {} + /// Parses raw model output into a ``ProvocationContent`` value. + /// + /// - Parameter rawOutput: The unstructured string returned by the model. + /// - Returns: A ``ProvocationContent`` with a body and optional follow-up question. + /// - Throws: ``FreeThinkerError/generationFailed`` if `rawOutput` is empty after normalisation, + /// or ``FreeThinkerError/invalidResponse`` if the body section is empty. public func parse(rawOutput: String) throws -> ProvocationContent { let trimmed = normalizeWhitespace(rawOutput) guard !trimmed.isEmpty else { diff --git a/FreeThinker/Core/Utilities/DiagnosticsLogger.swift b/FreeThinker/Core/Utilities/DiagnosticsLogger.swift index 3ad6bce..35efc3d 100644 --- a/FreeThinker/Core/Utilities/DiagnosticsLogger.swift +++ b/FreeThinker/Core/Utilities/DiagnosticsLogger.swift @@ -1,19 +1,54 @@ import Foundation +/// Protocol for recording and exporting diagnostic events. +/// +/// Conforming types are responsible for thread safety. Recording is a no-op +/// when ``isEnabled()`` returns `false`. public protocol DiagnosticsLogging { + /// Returns whether diagnostic recording is currently active. func isEnabled() -> Bool + + /// Enables or disables diagnostic recording. + /// + /// - Parameter enabled: `true` to start recording; `false` to stop. func setEnabled(_ enabled: Bool) + + /// Records a pre-constructed ``DiagnosticEvent``. + /// + /// - Parameter event: The event to append to the in-memory log. func record(_ event: DiagnosticEvent) + + /// Constructs and records a diagnostic event from component parts. + /// + /// - Parameters: + /// - stage: The functional area that produced the event. + /// - category: The event severity. + /// - message: A brief human-readable description. + /// - metadata: Supplemental key-value pairs (sensitive keys are redacted). func record( stage: DiagnosticStage, category: DiagnosticCategory, message: String, metadata: [String: String] ) + + /// Returns all events currently held in memory, in chronological order. func recentEvents() -> [DiagnosticEvent] + + /// Serialises events to a pretty-printed JSON file at the given URL. + /// + /// Creates intermediate directories if needed. Writes atomically. + /// + /// - Parameter url: The destination file URL. + /// - Throws: Encoding or file-system errors if the export fails. func exportEvents(to url: URL) throws } +/// Thread-safe ``DiagnosticsLogging`` implementation backed by `UserDefaults`. +/// +/// Events are stored in memory and persisted to `UserDefaults` after each write. +/// The log is bounded by ``maxEvents`` and ``maxStorageBytes``; the oldest events +/// are dropped when either limit is exceeded. public final class DiagnosticsLogger: DiagnosticsLogging { private let userDefaults: UserDefaults private let encoder: JSONEncoder @@ -27,6 +62,16 @@ public final class DiagnosticsLogger: DiagnosticsLogging { private var enabled: Bool private var events: [DiagnosticEvent] + /// Creates a `DiagnosticsLogger` with injectable dependencies. + /// + /// - Parameters: + /// - userDefaults: `UserDefaults` instance for persistence. Defaults to `.standard`. + /// - encoder: JSON encoder for writing events. Defaults to `JSONEncoder()`. + /// - decoder: JSON decoder for loading persisted events. Defaults to `JSONDecoder()`. + /// - enabledKey: `UserDefaults` key for the enabled flag. Defaults to `"diagnostics.enabled"`. + /// - storageKey: `UserDefaults` key for the events array. Defaults to `"diagnostics.events.v1"`. + /// - maxEvents: Maximum number of events retained (minimum 1). Defaults to 300. + /// - maxStorageBytes: Maximum encoded size in bytes (minimum 2 048). Defaults to 64 KiB. public init( userDefaults: UserDefaults = .standard, encoder: JSONEncoder = JSONEncoder(), diff --git a/FreeThinker/Core/Utilities/ErrorPresentationMapper.swift b/FreeThinker/Core/Utilities/ErrorPresentationMapper.swift index 12fa45b..6f185c0 100644 --- a/FreeThinker/Core/Utilities/ErrorPresentationMapper.swift +++ b/FreeThinker/Core/Utilities/ErrorPresentationMapper.swift @@ -1,18 +1,29 @@ import Foundation +/// A UI action suggested alongside an error message to help the user recover. public enum ErrorPresentationAction: Equatable, Sendable { + /// Retry the operation that failed. case retry + /// Navigate to System Settings > Privacy & Security > Accessibility. case openAccessibilitySettings + /// Navigate to the hotkey customisation section of FreeThinker's settings. case openHotkeySettings + /// Navigate to FreeThinker's general settings window. case openSettings + /// No action is suggested. case none } +/// A fully-mapped error suitable for display in the floating panel or as a notification. public struct ErrorPresentation: Equatable, Sendable { + /// The localised message to show in the UI. public let message: String + /// A suggested remediation action, or ``ErrorPresentationAction/none``. public let action: ErrorPresentationAction + /// When `true`, the panel should be shown even if it was not previously visible. public let preferPanelPresentation: Bool + /// Creates an `ErrorPresentation` with explicit values. public init( message: String, action: ErrorPresentationAction, @@ -24,13 +35,31 @@ public struct ErrorPresentation: Equatable, Sendable { } } +/// Protocol for mapping ``FreeThinkerError`` values to user-facing ``ErrorPresentation`` instances. +/// +/// The mapping is trigger-source-aware: some errors (like ``FreeThinkerError/timeout``) +/// prefer background presentation when triggered via the hotkey. public protocol ErrorPresentationMapping: Sendable { + /// Maps a ``FreeThinkerError`` to an ``ErrorPresentation`` appropriate for the given trigger source. + /// + /// - Parameters: + /// - error: The error to map. + /// - source: The trigger source that initiated the failed generation. + /// - Returns: A presentation value with a message, action, and panel preference. func map(error: FreeThinkerError, source: ProvocationTriggerSource) -> ErrorPresentation } +/// Concrete implementation of ``ErrorPresentationMapping`` with translocation detection. +/// +/// For Accessibility permission errors, the mapper appends an extra hint when the app +/// appears to be running from a translocated path. public struct ErrorPresentationMapper: ErrorPresentationMapping { private let isTranslocatedProvider: @Sendable () -> Bool + /// Creates a mapper with a custom translocation detector. + /// + /// - Parameter isTranslocatedProvider: Returns `true` if the app bundle is in a translocated + /// path. Defaults to checking for `/AppTranslocation/` in `Bundle.main.bundlePath`. public init( isTranslocatedProvider: @escaping @Sendable () -> Bool = { Bundle.main.bundlePath.contains("/AppTranslocation/") @@ -39,6 +68,13 @@ public struct ErrorPresentationMapper: ErrorPresentationMapping { self.isTranslocatedProvider = isTranslocatedProvider } + /// Maps a ``FreeThinkerError`` to an ``ErrorPresentation``. + /// + /// - Parameters: + /// - error: The error to map. + /// - source: The trigger source; influences ``ErrorPresentation/preferPanelPresentation`` + /// for some error cases. + /// - Returns: A user-facing error presentation. public func map(error: FreeThinkerError, source: ProvocationTriggerSource) -> ErrorPresentation { switch error { case .accessibilityPermissionDenied: diff --git a/FreeThinker/Core/Utilities/FreeThinkerError.swift b/FreeThinker/Core/Utilities/FreeThinkerError.swift index 135c848..767a1e2 100644 --- a/FreeThinker/Core/Utilities/FreeThinkerError.swift +++ b/FreeThinker/Core/Utilities/FreeThinkerError.swift @@ -1,23 +1,63 @@ import Foundation +/// Canonical error type for all recoverable and unrecoverable failures in FreeThinker. +/// +/// All public async APIs either throw or encode `FreeThinkerError` values into their return types. +/// Use ``userMessage`` to obtain a localised string suitable for display in the UI, and +/// ``isRetriable`` to determine whether the operation is worth retrying automatically. public enum FreeThinkerError: Error, Sendable, Equatable { + /// The app does not have Accessibility permission required to read selected text. case accessibilityPermissionDenied + + /// The user triggered a generation but no text was selected in the active app. case noSelection + + /// The proposed hotkey shortcut is structurally invalid (e.g., no modifier key). case hotkeyShortcutInvalid + + /// The proposed hotkey shortcut is reserved by macOS and cannot be registered. case hotkeyShortcutReserved + + /// The proposed hotkey shortcut is already claimed by another application. case hotkeyRegistrationConflict + + /// The hotkey shortcut could not be registered for an unspecified system reason. case hotkeyRegistrationFailed + + /// AI generation exceeded the configured timeout duration. case timeout + + /// Generation was cancelled by the user or a competing trigger. case cancelled + + /// The configured AI model is not available on this device or OS version. case modelUnavailable + + /// The macOS version is too old to support on-device Foundation Models. case unsupportedOperatingSystem + + /// The device hardware (e.g., Intel Mac) cannot run on-device AI models. case unsupportedHardware + + /// The FoundationModels framework is absent from this build configuration. case frameworkUnavailable + + /// The model returned a transient failure and may succeed if retried shortly. case transientModelFailure + + /// Generation failed for an unspecified or unexpected reason. case generationFailed + + /// The composed prompt was rejected before being sent to the model. case invalidPrompt + + /// The model's raw output could not be parsed into a valid ``ProvocationContent``. case invalidResponse + + /// The trigger arrived within the debounce window and was silently ignored. case triggerDebounced + + /// A generation pipeline is already running and the new trigger was rejected. case generationAlreadyInProgress } @@ -28,6 +68,7 @@ extension FreeThinkerError: LocalizedError { } public extension FreeThinkerError { + /// A localised string describing the error in terms suitable for end-user display. var userMessage: String { switch self { case .accessibilityPermissionDenied: @@ -69,6 +110,9 @@ public extension FreeThinkerError { } } + /// `true` for errors that are likely transient and worth retrying automatically. + /// + /// Currently only ``transientModelFailure`` is retriable. var isRetriable: Bool { switch self { case .transientModelFailure: diff --git a/FreeThinker/UI/FloatingPanel/FloatingPanelViewModel.swift b/FreeThinker/UI/FloatingPanel/FloatingPanelViewModel.swift index fed7e55..737ffcf 100644 --- a/FreeThinker/UI/FloatingPanel/FloatingPanelViewModel.swift +++ b/FreeThinker/UI/FloatingPanel/FloatingPanelViewModel.swift @@ -2,36 +2,67 @@ import AppKit import Combine import Foundation +/// Protocol abstracting sleep timing for testability in ``FloatingPanelViewModel``. public protocol FloatingPanelTiming: Sendable { + /// Suspends the current task for the given number of nanoseconds. + /// + /// - Parameter nanoseconds: The sleep duration. + /// - Throws: `CancellationError` if the task is cancelled. func sleep(nanoseconds: UInt64) async throws } +/// The default timing implementation backed by `Task.sleep`. public struct SystemFloatingPanelTiming: FloatingPanelTiming { + /// Creates the system timing provider. public init() {} + /// Suspends using `Task.sleep(nanoseconds:)`. public func sleep(nanoseconds: UInt64) async throws { try await Task.sleep(nanoseconds: nanoseconds) } } +/// Observable view model for the floating provocation panel. +/// +/// `FloatingPanelViewModel` is `@MainActor`-isolated and drives the panel's SwiftUI +/// views. It manages the UI state machine, auto-dismiss timers, copy-feedback animations, +/// pin state, and regenerate requests. @MainActor public final class FloatingPanelViewModel: ObservableObject { + /// The panel's UI state machine. public enum State: Equatable { + /// The panel is hidden or in its initial resting state. case idle + /// A generation is in progress; an optional text preview may be shown. case loading(selectedTextPreview: String?) + /// Generation succeeded; the panel shows the provocation response. case success(response: ProvocationResponse) + /// Generation failed or was rejected; the panel shows an error message. case error(message: String) } + /// The current UI state. Drives the panel's displayed content. @Published public private(set) var state: State = .idle + /// Whether the panel is pinned and therefore exempt from auto-dismiss. @Published public private(set) var isPinned: Bool + /// `true` while a regeneration request is in flight. @Published public private(set) var isRegenerating: Bool = false + /// Transient copy confirmation text shown briefly after the user copies a result. @Published public private(set) var copyFeedback: String? + /// A suggested remediation action shown alongside the error message, or `nil`. @Published public private(set) var suggestedAction: ErrorPresentationAction? + /// The display name of the currently active provocation style preset. @Published public var styleDisplayName: String = ProvocationStylePreset.socratic.displayName + /// Called when the user requests to close the panel. public var onCloseRequested: (() -> Void)? + /// Called when the user requests a new result for the current selection. + /// + /// - Parameter regenerateFromResponseID: The ID of the response to regenerate from, or `nil`. public var onRegenerateRequested: ((_ regenerateFromResponseID: UUID?) async -> Void)? + /// Called when the pin state changes so the host can persist it. + /// + /// - Parameter isPinned: The new pinned state. public var onPinStateChanged: ((_ isPinned: Bool) -> Void)? private let timing: any FloatingPanelTiming @@ -39,9 +70,20 @@ public final class FloatingPanelViewModel: ObservableObject { private var autoDismissTask: Task? private var feedbackTask: Task? + /// Seconds after which the panel auto-dismisses when not pinned. public var autoDismissSeconds: TimeInterval + /// Whether the panel dismisses automatically when the user copies a result. public var dismissOnCopy: Bool + /// Creates a `FloatingPanelViewModel` with the given initial configuration. + /// + /// - Parameters: + /// - isPinned: Whether the panel starts pinned. + /// - dismissOnCopy: Whether the panel dismisses when the user copies a result. + /// - autoDismissSeconds: Delay before auto-dismiss (minimum 1 second). Defaults to 6. + /// - timing: Sleep provider for auto-dismiss and feedback timers. + /// - pasteboardWriter: Closure that writes text to the pasteboard. Defaults to `NSPasteboard.general`. + /// - styleDisplayName: Initial display name for the style label. public init( isPinned: Bool, dismissOnCopy: Bool, @@ -63,6 +105,7 @@ public final class FloatingPanelViewModel: ObservableObject { feedbackTask?.cancel() } + /// Resets the panel to the idle state and cancels all transient timers. public func setIdle() { state = .idle isRegenerating = false @@ -71,6 +114,9 @@ public final class FloatingPanelViewModel: ObservableObject { cancelTransientTasks() } + /// Transitions the panel to a loading state. + /// + /// - Parameter selectedTextPreview: Optional truncated preview of the selected text to show while loading. public func setLoading(selectedTextPreview: String? = nil) { state = .loading(selectedTextPreview: normalizedPreview(selectedTextPreview)) isRegenerating = false @@ -79,6 +125,9 @@ public final class FloatingPanelViewModel: ObservableObject { cancelTransientTasks() } + /// Transitions the panel to the success state and schedules auto-dismiss if unpinned. + /// + /// - Parameter response: The successful ``ProvocationResponse`` to display. public func setSuccess(_ response: ProvocationResponse) { state = .success(response: response) isRegenerating = false @@ -87,10 +136,18 @@ public final class FloatingPanelViewModel: ObservableObject { scheduleAutoDismissIfNeeded() } + /// Transitions the panel to an error state using the error's localised user message. + /// + /// - Parameter error: The ``FreeThinkerError`` to display. public func setError(_ error: FreeThinkerError) { setErrorMessage(error.userMessage) } + /// Transitions the panel to an error state using a pre-mapped ``ErrorPresentation``. + /// + /// Sets `suggestedAction` if the presentation includes a non-`.none` action. + /// + /// - Parameter presentation: The mapped error presentation to display. public func setErrorPresentation(_ presentation: ErrorPresentation) { state = .error(message: presentation.message) isRegenerating = false @@ -107,6 +164,10 @@ public final class FloatingPanelViewModel: ObservableObject { scheduleAutoDismissIfNeeded() } + /// Copies the current result's body and follow-up question to the pasteboard. + /// + /// Sets `copyFeedback` to `"Copied"` briefly, then clears it. If `dismissOnCopy` + /// is `true` and the panel is unpinned, the panel is closed after copying. public func copyCurrentResult() { guard let copyText else { return @@ -121,10 +182,15 @@ public final class FloatingPanelViewModel: ObservableObject { } } + /// Alias for ``copyCurrentResult()``. public func copyFromResponseContent() { copyCurrentResult() } + /// Requests a new provocation generation for the same selected text. + /// + /// No-ops if ``canRegenerate`` is `false`. Sets `isRegenerating` to `true` while + /// the request is in flight and resets it when the response arrives. public func requestRegenerate() { guard canRegenerate else { return @@ -144,11 +210,16 @@ public final class FloatingPanelViewModel: ObservableObject { } } + /// Cancels pending timers and notifies the host to close the panel. public func closePanel() { cancelTransientTasks() onCloseRequested?() } + /// Toggles the pinned state of the panel. + /// + /// When pinning, any pending auto-dismiss timer is cancelled. When unpinning, + /// an auto-dismiss timer is started if the current state warrants one. public func togglePin() { isPinned.toggle() onPinStateChanged?(isPinned) @@ -160,6 +231,7 @@ public final class FloatingPanelViewModel: ObservableObject { } } + /// `true` when the panel is in the success state and ``copyText`` is non-nil. public var canCopy: Bool { guard case .success = state else { return false @@ -167,6 +239,7 @@ public final class FloatingPanelViewModel: ObservableObject { return copyText != nil } + /// `true` when the panel is in a state from which regeneration is possible and not already regenerating. public var canRegenerate: Bool { switch state { case .idle: @@ -178,10 +251,12 @@ public final class FloatingPanelViewModel: ObservableObject { } } + /// Always `true`; the panel can always be closed by the user. public var canClose: Bool { true } + /// The response currently displayed in the success state, or `nil` otherwise. public var currentResponse: ProvocationResponse? { if case let .success(response) = state { return response @@ -198,6 +273,7 @@ public final class FloatingPanelViewModel: ObservableObject { return "\(content.body)\(followUp)" } + /// A human-readable string describing the `suggestedAction`, or `nil` if there is none. public var suggestedActionMessage: String? { guard let suggestedAction else { return nil