diff --git a/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8bad378e..9cf9148a 100644 --- a/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mattt/eventsource.git", "state" : { - "revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0", - "version" : "1.3.0" + "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", + "version" : "1.4.1" } }, { @@ -52,7 +52,7 @@ "location" : "https://github.com/ejbills/mediaremote-adapter", "state" : { "branch" : "master", - "revision" : "78aae86c03adab11a7b352211cc82381737cf854" + "revision" : "cf30c4f1af29b5829d859f088f8dbdf12611a046" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mxcl/Path.swift", "state" : { - "revision" : "8e355c28e9393c42e58b18c54cace2c42c98a616", - "version" : "1.4.1" + "revision" : "74ec90bbe50a3376e399286fed48b60db9b91bb1", + "version" : "1.6.0" } }, { @@ -78,8 +78,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "9f542610331815e29cc3821d3b6f488db8715517", - "version" : "1.6.0" + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" } }, { @@ -87,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", - "version" : "1.4.1" + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" } }, { @@ -96,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "fa308c07a6fa04a727212d793e761460e41049c3", - "version" : "4.3.0" + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" } }, { @@ -114,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/huggingface/swift-jinja.git", "state" : { - "revision" : "0aeefadec459ce8e11a333769950fb86183aca43", - "version" : "2.3.5" + "revision" : "0b67ecb79139f6addef8699eff3622808aa6c7dc", + "version" : "2.3.6" } }, { @@ -123,8 +132,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version" : "1.6.4" + "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version" : "1.14.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "cd3e1152083706d77b223fb29110e590efcc70c0", + "version" : "2.101.2" } }, { @@ -132,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/modelcontextprotocol/swift-sdk.git", "state" : { - "revision" : "c0407a0b52677cb395d824cac2879b963075ba8c", - "version" : "0.10.2" + "revision" : "a0ae212ebf6eab5f754c3129608bc5557637e605", + "version" : "0.12.1" } }, { @@ -141,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", - "version" : "1.6.3" + "revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1", + "version" : "1.7.2" } }, { @@ -150,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/huggingface/swift-transformers", "state" : { - "revision" : "b38443e44d93eca770f2eb68e2a4d0fa100f9aa2", - "version" : "1.3.0" + "revision" : "2fa33e1f5e7131a7fc64c28e6d161dcec0d24820", + "version" : "1.3.3" } }, { diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 92a6a3ef..6d237de4 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2551,9 +2551,10 @@ struct ContentView: View { if typingTarget.shouldRestoreOriginalFocus { await self.restoreFocusToRecordingTarget() } - self.asr.typeTextToActiveField( - self.rewriteModeService.rewrittenText, - preferredTargetPID: typingTarget.pid + self.rewriteModeService.acceptRewrite( + preferredTargetPID: typingTarget.pid, + hideApp: false, + recordAnalytics: false ) AnalyticsService.shared.capture( .outputDelivered, diff --git a/Sources/Fluid/Persistence/SettingsStore+CommandMode.swift b/Sources/Fluid/Persistence/SettingsStore+CommandMode.swift index 0db8994d..9786d704 100644 --- a/Sources/Fluid/Persistence/SettingsStore+CommandMode.swift +++ b/Sources/Fluid/Persistence/SettingsStore+CommandMode.swift @@ -50,6 +50,10 @@ extension SettingsStore { } var commandModeReadinessIssue: String? { + if self.commandModeRouteToCodex { + return nil + } + let sourceProviderID = self.commandModeLinkedToGlobal ? self.selectedProviderID : self.commandModeSelectedProviderID if sourceProviderID == "apple-intelligence" || sourceProviderID == "apple-intelligence-disabled" { return "Command Mode cannot use Apple Intelligence because terminal tools require a chat API. Choose a verified chat provider or turn Sync off." diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index e90be710..635ac7a0 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -15,6 +15,7 @@ final class SettingsStore: ObservableObject { static let transcriptionPreviewCharLimitRange: ClosedRange = 50...800 static let transcriptionPreviewCharLimitStep = 50 static let defaultTranscriptionPreviewCharLimit = 150 + static let defaultVisualizerNoiseThreshold = 0.12 private let defaults = UserDefaults.standard private let keychain = KeychainService.shared private(set) var launchAtStartupEnabled = false @@ -1553,7 +1554,7 @@ final class SettingsStore: ObservableObject { var visualizerNoiseThreshold: Double { get { let value = self.defaults.double(forKey: Keys.visualizerNoiseThreshold) - return value == 0.0 ? 0.4 : value // Default to 0.4 if not set + return value == 0.0 ? Self.defaultVisualizerNoiseThreshold : value } set { // Clamp between 0.0 and 0.95 to avoid division by zero issues in visualizers @@ -2342,6 +2343,28 @@ final class SettingsStore: ObservableObject { } } + var commandModeRouteToCodex: Bool { + get { + let value = self.defaults.object(forKey: Keys.commandModeRouteToCodex) + return value as? Bool ?? false + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.commandModeRouteToCodex) + } + } + + var commandModeCodexHandoffStyle: String { + get { + let value = self.defaults.string(forKey: Keys.commandModeCodexHandoffStyle) ?? "notch" + return ["notch", "app"].contains(value) ? value : "notch" + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.commandModeCodexHandoffStyle) + } + } + // MARK: - Rewrite Mode Settings var rewriteModeHotkeyShortcut: HotkeyShortcut { @@ -4235,6 +4258,8 @@ private extension SettingsStore { static let cancelRecordingHotkeyShortcut = "CancelRecordingHotkeyShortcut" static let commandModeLinkedToGlobal = "CommandModeLinkedToGlobal" static let commandModeShortcutEnabled = "CommandModeShortcutEnabled" + static let commandModeRouteToCodex = "CommandModeRouteToCodex" + static let commandModeCodexHandoffStyle = "CommandModeCodexHandoffStyle" // Prompt Mode Keys (Transcribe with Prompt) static let promptModeHotkeyShortcut = "PromptModeHotkeyShortcut" diff --git a/Sources/Fluid/Services/CodexHandoffService.swift b/Sources/Fluid/Services/CodexHandoffService.swift new file mode 100644 index 00000000..2483241e --- /dev/null +++ b/Sources/Fluid/Services/CodexHandoffService.swift @@ -0,0 +1,264 @@ +import AppKit +import Foundation + +@MainActor +final class CodexHandoffService { + static let shared = CodexHandoffService() + + private static let codexBundleID = "com.openai.codex" + private static let pasteboardSessionSemaphore = DispatchSemaphore(value: 1) + private static let bundledCodexCLIPath = "/Applications/Codex.app/Contents/Resources/codex" + + private init() {} + + struct HandoffResult { + let success: Bool + let message: String + } + + enum HandoffStyle: Equatable { + case notch + case app + + init(rawValue: String) { + self = rawValue == "app" ? .app : .notch + } + } + + private struct PasteboardItemSnapshot { + let dataByType: [NSPasteboard.PasteboardType: Data] + } + + private struct PasteboardSnapshot { + let items: [PasteboardItemSnapshot] + } + + func sendToCodex(_ text: String, style: HandoffStyle) async -> HandoffResult { + let prompt = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !prompt.isEmpty else { + return HandoffResult(success: false, message: "No command text to send to Codex.") + } + + switch style { + case .notch: + return await self.runCodexInNotch(prompt) + case .app: + return await self.sendToCodexApp(prompt) + } + } + + private func sendToCodexApp(_ prompt: String) async -> HandoffResult { + guard await self.activateCodex() else { + return HandoffResult(success: false, message: "Could not open Codex.") + } + + try? await Task.sleep(nanoseconds: 250_000_000) + + guard await self.pasteAndSubmit(prompt) else { + return HandoffResult(success: false, message: "Could not paste into Codex.") + } + + return HandoffResult(success: true, message: "Sent to Codex.") + } + + private func runCodexInNotch(_ prompt: String) async -> HandoffResult { + guard FileManager.default.isExecutableFile(atPath: Self.bundledCodexCLIPath) else { + return HandoffResult(success: false, message: "Codex CLI was not found.") + } + + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("fluidvoice-codex-\(UUID().uuidString).txt") + defer { try? FileManager.default.removeItem(at: outputURL) } + + let process = Process() + process.executableURL = URL(fileURLWithPath: Self.bundledCodexCLIPath) + process.currentDirectoryURL = URL(fileURLWithPath: NSHomeDirectory()) + process.arguments = [ + "-a", "never", + "exec", + "--skip-git-repo-check", + "--color", "never", + "-C", NSHomeDirectory(), + "-o", outputURL.path, + "-" + ] + + let inputPipe = Pipe() + process.standardInput = inputPipe + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + DebugLogger.shared.error("Failed to start Codex CLI: \(error.localizedDescription)", source: "CodexHandoffService") + return HandoffResult(success: false, message: "Could not start Codex.") + } + + if let data = prompt.data(using: .utf8) { + inputPipe.fileHandleForWriting.write(data) + } + inputPipe.fileHandleForWriting.closeFile() + + let completed = await self.waitForProcess(process, timeout: 120) + guard completed else { + process.terminate() + return HandoffResult(success: false, message: "Codex took too long and was stopped.") + } + + guard process.terminationStatus == 0 else { + return HandoffResult(success: false, message: "Codex finished with an error.") + } + + let output = (try? String(contentsOf: outputURL, encoding: .utf8))? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !output.isEmpty else { + return HandoffResult(success: true, message: "Codex finished.") + } + + return HandoffResult(success: true, message: output) + } + + private func waitForProcess(_ process: Process, timeout: TimeInterval) async -> Bool { + await withCheckedContinuation { continuation in + let lock = NSLock() + var didResume = false + + func resume(_ value: Bool) { + lock.lock() + defer { lock.unlock() } + guard !didResume else { return } + didResume = true + continuation.resume(returning: value) + } + + process.terminationHandler = { _ in + resume(true) + } + + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { + resume(false) + } + } + } + + private func activateCodex() async -> Bool { + if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == Self.codexBundleID }) { + return app.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } + + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: Self.codexBundleID) else { + return false + } + + return await withCheckedContinuation { continuation in + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + NSWorkspace.shared.openApplication(at: url, configuration: configuration) { app, error in + if let error { + DebugLogger.shared.error("Failed to launch Codex: \(error.localizedDescription)", source: "CodexHandoffService") + continuation.resume(returning: false) + return + } + _ = app?.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + continuation.resume(returning: app != nil) + } + } + } + + private func pasteAndSubmit(_ text: String) async -> Bool { + Self.pasteboardSessionSemaphore.wait() + + let pasteboard = NSPasteboard.general + let snapshot = self.capturePasteboardSnapshot(pasteboard) + + pasteboard.clearContents() + guard pasteboard.setString(text, forType: .string) else { + self.restorePasteboardSnapshot(snapshot, to: pasteboard) + Self.pasteboardSessionSemaphore.signal() + return false + } + + guard self.sendCommandKey("v") else { + self.restorePasteboardSnapshot(snapshot, to: pasteboard) + Self.pasteboardSessionSemaphore.signal() + return false + } + + try? await Task.sleep(nanoseconds: 100_000_000) + guard self.sendReturnKey() else { + self.restorePasteboardSnapshot(snapshot, to: pasteboard) + Self.pasteboardSessionSemaphore.signal() + return false + } + + try? await Task.sleep(nanoseconds: 1_000_000_000) + self.restorePasteboardSnapshot(snapshot, to: pasteboard) + Self.pasteboardSessionSemaphore.signal() + + return true + } + + private func sendCommandKey(_ character: Character) -> Bool { + guard let keyCode = Self.keyCode(for: character), + let keyDown = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false) + else { + return false + } + + keyDown.flags = .maskCommand + keyUp.flags = .maskCommand + keyDown.post(tap: .cghidEventTap) + usleep(10_000) + keyUp.post(tap: .cghidEventTap) + return true + } + + private func sendReturnKey() -> Bool { + guard let keyDown = CGEvent(keyboardEventSource: nil, virtualKey: 36, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: nil, virtualKey: 36, keyDown: false) + else { + return false + } + + keyDown.post(tap: .cghidEventTap) + usleep(10_000) + keyUp.post(tap: .cghidEventTap) + return true + } + + private static func keyCode(for character: Character) -> CGKeyCode? { + switch character.lowercased() { + case "v": return 9 + default: return nil + } + } + + private func capturePasteboardSnapshot(_ pasteboard: NSPasteboard) -> PasteboardSnapshot { + let items: [PasteboardItemSnapshot] = pasteboard.pasteboardItems?.map { item in + var dataByType: [NSPasteboard.PasteboardType: Data] = [:] + for type in item.types { + if let data = item.data(forType: type) { + dataByType[type] = data + } + } + return PasteboardItemSnapshot(dataByType: dataByType) + } ?? [] + return PasteboardSnapshot(items: items) + } + + private func restorePasteboardSnapshot(_ snapshot: PasteboardSnapshot, to pasteboard: NSPasteboard) { + pasteboard.clearContents() + guard !snapshot.items.isEmpty else { return } + + let restoredItems = snapshot.items.map { snap -> NSPasteboardItem in + let item = NSPasteboardItem() + for (type, data) in snap.dataByType { + item.setData(data, forType: type) + } + return item + } + _ = pasteboard.writeObjects(restoredItems) + } +} diff --git a/Sources/Fluid/Services/CommandModeService.swift b/Sources/Fluid/Services/CommandModeService.swift index b356788c..54611ad4 100644 --- a/Sources/Fluid/Services/CommandModeService.swift +++ b/Sources/Fluid/Services/CommandModeService.swift @@ -34,7 +34,13 @@ final class CommandModeService: ObservableObject { } private var shouldSyncCommandNotchState: Bool { - self.enableNotchOutput && NotchOverlayManager.shared.shouldSyncCommandConversationToNotch + (self.enableNotchOutput || self.shouldForceCodexNotchOutput) && + NotchOverlayManager.shared.shouldSyncCommandConversationToNotch + } + + private var shouldForceCodexNotchOutput: Bool { + SettingsStore.shared.commandModeRouteToCodex && + SettingsStore.shared.commandModeCodexHandoffStyle == "notch" } private func loadCurrentChatFromStore() { @@ -307,6 +313,11 @@ final class CommandModeService: ObservableObject { func processUserCommand(_ text: String, notifyInvalidRequest: Bool = false) async { guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + if SettingsStore.shared.commandModeRouteToCodex { + await self.processCodexHandoff(text) + return + } + self.isProcessing = true self.currentTurnCount = 0 self.didRequireConfirmationThisRun = false @@ -324,10 +335,57 @@ final class CommandModeService: ObservableObject { await self.processNextTurn(notifyInvalidRequest: notifyInvalidRequest) } + private func processCodexHandoff(_ text: String) async { + self.isProcessing = true + self.currentTurnCount = 0 + self.didRequireConfirmationThisRun = false + self.pendingCommand = nil + let handoffStyle = CodexHandoffService.HandoffStyle(rawValue: SettingsStore.shared.commandModeCodexHandoffStyle) + let statusMessage = handoffStyle == .notch ? "Running Codex in notch..." : "Sending to Codex..." + self.currentStep = .executing(handoffStyle == .notch ? "Running Codex" : "Sending to Codex") + + let cleanText = text.trimmingCharacters(in: .whitespacesAndNewlines) + self.conversationHistory.append(Message(role: .user, content: cleanText)) + self.saveCurrentChat() + + if self.shouldSyncCommandNotchState { + NotchContentState.shared.addCommandMessage(role: .user, content: cleanText) + NotchContentState.shared.addCommandMessage(role: .status, content: statusMessage) + NotchContentState.shared.setCommandProcessing(true) + if handoffStyle == .notch { + self.showExpandedNotchIfNeeded() + } + } + + let result = await CodexHandoffService.shared.sendToCodex(cleanText, style: handoffStyle) + self.conversationHistory.append(Message( + role: .assistant, + content: result.message, + stepType: result.success ? .success : .failure + )) + self.isProcessing = false + self.currentStep = .completed(result.success) + self.saveCurrentChat() + self.captureCommandRunCompleted(success: result.success) + + if self.shouldSyncCommandNotchState { + NotchContentState.shared.addCommandMessage(role: result.success ? .assistant : .status, content: result.message) + NotchContentState.shared.setCommandProcessing(false) + if handoffStyle == .notch { + self.showExpandedNotchIfNeeded() + } + } + } + /// Process follow-up command from notch input func processFollowUpCommand(_ text: String) async { guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + if SettingsStore.shared.commandModeRouteToCodex { + await self.processCodexHandoff(text) + return + } + // Add to both histories self.conversationHistory.append(Message(role: .user, content: text)) if self.shouldSyncCommandNotchState { diff --git a/Sources/Fluid/Services/NotchOverlayManager.swift b/Sources/Fluid/Services/NotchOverlayManager.swift index b6bd753f..9adad9a0 100644 --- a/Sources/Fluid/Services/NotchOverlayManager.swift +++ b/Sources/Fluid/Services/NotchOverlayManager.swift @@ -435,33 +435,40 @@ final class NotchOverlayManager { /// Show expanded command output notch func showExpandedCommandOutput() { guard self.canShowExpandedCommandOutput else { return } + guard self.commandOutputState == .idle else { return } + + self.commandOutputState = .showing + self.isCommandOutputExpanded = true + NotchContentState.shared.mode = .command + NotchContentState.shared.isExpandedForCommandOutput = true // Hide regular notch first if visible - if self.notch != nil { + let needsRegularNotchCleanup = self.notch != nil + if needsRegularNotchCleanup { self.hide() } - // Wait a bit for cleanup + let delay: UInt64 = needsRegularNotchCleanup ? 100_000_000 : 0 Task { [weak self] in - try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + if delay > 0 { + try? await Task.sleep(nanoseconds: delay) + } await self?.showExpandedCommandOutputInternal() } } private func showExpandedCommandOutputInternal() async { - guard self.canShowExpandedCommandOutput else { return } - guard self.commandOutputState == .idle else { return } + guard self.canShowExpandedCommandOutput else { + self.commandOutputState = .idle + self.isCommandOutputExpanded = false + NotchContentState.shared.collapseCommandOutput() + return + } + guard self.commandOutputState == .showing else { return } self.commandOutputGeneration &+= 1 let currentGeneration = self.commandOutputGeneration - self.commandOutputState = .showing - self.isCommandOutputExpanded = true - - // Update content state - NotchContentState.shared.mode = .command - NotchContentState.shared.isExpandedForCommandOutput = true - let publisher = self.lastAudioPublisher ?? Empty().eraseToAnyPublisher() let newNotch = DynamicNotch( diff --git a/Sources/Fluid/Services/RewriteModeService.swift b/Sources/Fluid/Services/RewriteModeService.swift index 0ac9ee25..b41307a1 100644 --- a/Sources/Fluid/Services/RewriteModeService.swift +++ b/Sources/Fluid/Services/RewriteModeService.swift @@ -13,6 +13,7 @@ final class RewriteModeService: ObservableObject { @Published var conversationHistory: [Message] = [] @Published var isWriteMode: Bool = false // true = no text selected (write/improve), false = text selected (rewrite) private var promptAppBundleID: String? + private var selectionTargetPID: pid_t? private let textSelectionService = TextSelectionService.shared private let typingService = TypingService() @@ -48,12 +49,15 @@ final class RewriteModeService: ObservableObject { } func captureSelectedText() -> Bool { + let targetPID = TypingService.captureSystemFocusedPID() + ?? NSWorkspace.shared.frontmostApplication?.processIdentifier if let text = textSelectionService.getSelectedText(), !text.isEmpty { self.originalText = text self.selectedContextText = text self.rewrittenText = "" self.conversationHistory = [] self.isWriteMode = false + self.selectionTargetPID = targetPID if self.shouldTracePromptProcessing { self.logPromptTrace("Captured selected context", value: text) } @@ -69,6 +73,8 @@ final class RewriteModeService: ObservableObject { self.rewrittenText = "" self.conversationHistory = [] self.isWriteMode = true + self.selectionTargetPID = TypingService.captureSystemFocusedPID() + ?? NSWorkspace.shared.frontmostApplication?.processIdentifier if self.shouldTracePromptProcessing { self.logPromptTrace("Starting edit with no selected context", value: "") } @@ -159,18 +165,29 @@ final class RewriteModeService: ObservableObject { } } - func acceptRewrite() { + func acceptRewrite( + preferredTargetPID: pid_t? = nil, + hideApp: Bool = true, + recordAnalytics: Bool = true + ) { guard !self.rewrittenText.isEmpty else { return } - NSApp.hide(nil) // Restore focus to the previous app - self.typingService.typeTextInstantly(self.rewrittenText) - - AnalyticsService.shared.capture( - .outputDelivered, - properties: [ - "mode": AnalyticsMode.rewrite.rawValue, - "method": AnalyticsOutputMethod.typed.rawValue, - ] + if hideApp { + NSApp.hide(nil) // Restore focus to the previous app + } + self.typingService.typeTextReliably( + self.rewrittenText, + preferredTargetPID: preferredTargetPID ?? self.selectionTargetPID ) + + if recordAnalytics { + AnalyticsService.shared.capture( + .outputDelivered, + properties: [ + "mode": AnalyticsMode.rewrite.rawValue, + "method": AnalyticsOutputMethod.typed.rawValue, + ] + ) + } } func clearState() { @@ -182,6 +199,7 @@ final class RewriteModeService: ObservableObject { self.isWriteMode = false self.thinkingBuffer = [] self.promptAppBundleID = nil + self.selectionTargetPID = nil } func setPromptAppBundleID(_ bundleID: String?) { diff --git a/Sources/Fluid/Services/TextSelectionService.swift b/Sources/Fluid/Services/TextSelectionService.swift index 601a06f9..75140b48 100644 --- a/Sources/Fluid/Services/TextSelectionService.swift +++ b/Sources/Fluid/Services/TextSelectionService.swift @@ -4,6 +4,7 @@ import Foundation final class TextSelectionService { static let shared = TextSelectionService() + private static let pasteboardSessionSemaphore = DispatchSemaphore(value: 1) private init() {} @@ -42,12 +43,26 @@ final class TextSelectionService { } } + if let copiedSelection = self.getSelectedTextByCopyFallback() { + self.diag("Selection capture success via clipboard fallback (chars=\(copiedSelection.count))") + return copiedSelection + } + self.diag("Selection capture failed: no selected text found") return nil } // MARK: - Private Helpers + private struct PasteboardItemSnapshot { + let dataByType: [NSPasteboard.PasteboardType: Data] + } + + private struct PasteboardSnapshot { + let items: [PasteboardItemSnapshot] + let changeCount: Int + } + private func getFocusedElement() -> AXUIElement? { let systemWideElement = AXUIElementCreateSystemWide() var focusedElement: CFTypeRef? @@ -134,6 +149,91 @@ final class TextSelectionService { return extracted } + private func getSelectedTextByCopyFallback() -> String? { + Self.pasteboardSessionSemaphore.wait() + defer { Self.pasteboardSessionSemaphore.signal() } + + let pasteboard = NSPasteboard.general + let snapshot = self.capturePasteboardSnapshot(pasteboard) + + pasteboard.clearContents() + let clearedChangeCount = pasteboard.changeCount + + guard self.sendCopyShortcut() else { + self.restorePasteboardSnapshot(snapshot, to: pasteboard) + self.diag("Clipboard fallback failed: could not dispatch Cmd+C") + return nil + } + + // Google Docs and other browser editors can update the system pasteboard + // noticeably later than native text fields after Cmd+C. + let deadline = Date().addingTimeInterval(1.0) + var copiedText: String? + repeat { + if pasteboard.changeCount != clearedChangeCount, + let text = pasteboard.string(forType: .string), + !text.isEmpty + { + copiedText = text + break + } + usleep(15_000) + } while Date() < deadline + + self.restorePasteboardSnapshot(snapshot, to: pasteboard) + + guard let copiedText else { + self.diag("Clipboard fallback failed: clipboard did not receive selected text") + return nil + } + + return copiedText + } + + private func sendCopyShortcut() -> Bool { + guard let copyDown = CGEvent(keyboardEventSource: nil, virtualKey: 8, keyDown: true), + let copyUp = CGEvent(keyboardEventSource: nil, virtualKey: 8, keyDown: false) + else { + return false + } + + copyDown.flags = .maskCommand + copyUp.flags = .maskCommand + copyDown.post(tap: .cghidEventTap) + usleep(10_000) + copyUp.post(tap: .cghidEventTap) + return true + } + + private func capturePasteboardSnapshot(_ pasteboard: NSPasteboard) -> PasteboardSnapshot { + let items: [PasteboardItemSnapshot] = pasteboard.pasteboardItems?.map { item in + var dataByType: [NSPasteboard.PasteboardType: Data] = [:] + for type in item.types { + if let data = item.data(forType: type) { + dataByType[type] = data + } + } + return PasteboardItemSnapshot(dataByType: dataByType) + } ?? [] + return PasteboardSnapshot(items: items, changeCount: pasteboard.changeCount) + } + + private func restorePasteboardSnapshot(_ snapshot: PasteboardSnapshot, to pasteboard: NSPasteboard) { + guard pasteboard.changeCount != snapshot.changeCount else { return } + + pasteboard.clearContents() + guard !snapshot.items.isEmpty else { return } + + let restoredItems = snapshot.items.map { snap -> NSPasteboardItem in + let item = NSPasteboardItem() + for (type, data) in snap.dataByType { + item.setData(data, forType: type) + } + return item + } + _ = pasteboard.writeObjects(restoredItems) + } + private func describe(_ error: AXError) -> String { switch error { case .success: return "success" diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index 259c9a05..29e9f708 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -256,17 +256,43 @@ final class TypingService { /// Types/inserts text, optionally preferring a specific target PID for CGEvent posting. /// This helps when our overlay temporarily has focus; we can still target the original app. func typeTextInstantly(_ text: String, preferredTargetPID: pid_t?, textReadyAt: TimeInterval?) { + self.typeTextInstantly( + text, + preferredTargetPID: preferredTargetPID, + textReadyAt: textReadyAt, + forceReliablePaste: false + ) + } + + /// Inserts text through the compatibility path regardless of the global typing mode. + /// This is useful for web editors that report success for key-event insertion but + /// only reliably replace rich editor selections through paste. + func typeTextReliably(_ text: String, preferredTargetPID: pid_t?, textReadyAt: TimeInterval? = nil) { + self.typeTextInstantly( + text, + preferredTargetPID: preferredTargetPID, + textReadyAt: textReadyAt, + forceReliablePaste: true + ) + } + + private func typeTextInstantly( + _ text: String, + preferredTargetPID: pid_t?, + textReadyAt: TimeInterval?, + forceReliablePaste: Bool + ) { let requestedAt = ProcessInfo.processInfo.systemUptime let mode = self.textInsertionMode let settleDelayMs: Int = { - if mode == .reliablePaste { + if forceReliablePaste || mode == .reliablePaste { return preferredTargetPID == nil ? 80 : 0 } return preferredTargetPID == nil ? 200 : 0 }() let textReadyAge = textReadyAt.map { Self.elapsedMs(from: $0, to: requestedAt) } self.bench( - "request chars=\(text.count) mode=\(mode.rawValue) preferredPID=\(preferredTargetPID.map { String($0) } ?? "nil") textReadyAgeMs=\(textReadyAge.map { String($0) } ?? "nil")" + "request chars=\(text.count) mode=\(mode.rawValue) forceReliablePaste=\(forceReliablePaste) preferredPID=\(preferredTargetPID.map { String($0) } ?? "nil") textReadyAgeMs=\(textReadyAge.map { String($0) } ?? "nil")" ) self.log("[TypingService] ENTRY: typeTextInstantly called with text length: \(text.count)") self.log("[TypingService] Text preview: \"\(String(text.prefix(100)))\"") @@ -316,7 +342,7 @@ final class TypingService { self.log("[TypingService] Delay completed, calling insertTextInstantly") let insertStartedAt = ProcessInfo.processInfo.systemUptime self.bench("insert_call") - self.insertTextInstantly(text, preferredTargetPID: preferredTargetPID) + self.insertTextInstantly(text, preferredTargetPID: preferredTargetPID, forceReliablePaste: forceReliablePaste) self.bench( "insert_return elapsedMs=\(Self.elapsedMs(since: insertStartedAt)) totalMs=\(Self.elapsedMs(since: requestedAt))" ) @@ -337,11 +363,11 @@ final class TypingService { // MARK: - Internal insertion pipeline - private func insertTextInstantly(_ text: String, preferredTargetPID: pid_t?) { + private func insertTextInstantly(_ text: String, preferredTargetPID: pid_t?, forceReliablePaste: Bool) { self.log("[TypingService] insertTextInstantly called with \(text.count) characters") self.log("[TypingService] Attempting to type text: \"\(text.prefix(50))\(text.count > 50 ? "..." : "")\"") - if self.textInsertionMode == .reliablePaste { + if forceReliablePaste || self.textInsertionMode == .reliablePaste { self.log("[TypingService] Reliable Paste mode enabled") if self.tryReliablePasteInsertion(text, preferredTargetPID: preferredTargetPID) { self.log("[TypingService] SUCCESS: Reliable Paste mode completed") diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 323a8a86..7f68f030 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -1188,7 +1188,7 @@ struct SettingsView: View { Spacer() Button("Reset") { - self.visualizerNoiseThreshold = 0.4 + self.visualizerNoiseThreshold = SettingsStore.defaultVisualizerNoiseThreshold SettingsStore.shared.visualizerNoiseThreshold = self.visualizerNoiseThreshold } .buttonStyle(.bordered) diff --git a/Sources/Fluid/Views/CommandModeView.swift b/Sources/Fluid/Views/CommandModeView.swift index 39b40c4e..4cf3b1d5 100644 --- a/Sources/Fluid/Views/CommandModeView.swift +++ b/Sources/Fluid/Views/CommandModeView.swift @@ -45,7 +45,7 @@ struct CommandModeView: View { .onAppear { self.updateAvailableModels() // Disable notch output when using in-app UI (conversation is shared but notch shouldn't show) - self.service.enableNotchOutput = false + self.updateNotchOutputPreference() } .onDisappear { // Re-enable notch output when leaving in-app UI @@ -62,6 +62,13 @@ struct CommandModeView: View { .onChange(of: self.settings.commandModeLinkedToGlobal) { _, _ in self.updateAvailableModels() } + .onChange(of: self.settings.commandModeRouteToCodex) { _, _ in + self.updateAvailableModels() + self.updateNotchOutputPreference() + } + .onChange(of: self.settings.commandModeCodexHandoffStyle) { _, _ in + self.updateNotchOutputPreference() + } .onChange(of: self.settings.selectedProviderID) { _, _ in self.updateAvailableModels() } @@ -157,6 +164,25 @@ struct CommandModeView: View { } .toggleStyle(.checkbox) .help("Ask for confirmation before running commands") + .disabled(self.settings.commandModeRouteToCodex) + + Toggle(isOn: self.$settings.commandModeRouteToCodex) { + Label("Codex", systemImage: "sparkles") + .font(.caption) + } + .toggleStyle(.checkbox) + .help("Send Command Mode input to Codex") + + if self.settings.commandModeRouteToCodex { + Picker("Codex style", selection: self.$settings.commandModeCodexHandoffStyle) { + Text("Notch").tag("notch") + Text("App").tag("app") + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 112) + .help("Choose whether Codex runs in the notch or receives input in the app") + } } .padding() .background(self.theme.palette.windowBackground) @@ -450,7 +476,7 @@ struct CommandModeView: View { private var inputArea: some View { VStack(alignment: .leading, spacing: 10) { - if let issue = self.settings.commandModeReadinessIssue { + if let issue = self.commandModeReadinessIssue { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .font(.caption) @@ -489,6 +515,8 @@ struct CommandModeView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: true, vertical: false) .help("Use the same provider and model selected in AI Enhancement.") + .disabled(self.settings.commandModeRouteToCodex) + .opacity(self.settings.commandModeRouteToCodex ? 0.55 : 1) SearchableProviderPicker( builtInProviders: self.verifiedBuiltInProvidersList, @@ -508,8 +536,8 @@ struct CommandModeView: View { controlWidth: 140, controlHeight: 30 ) - .disabled(self.settings.commandModeLinkedToGlobal) - .opacity(self.settings.commandModeLinkedToGlobal ? 0.55 : 1) + .disabled(self.settings.commandModeLinkedToGlobal || self.settings.commandModeRouteToCodex) + .opacity((self.settings.commandModeLinkedToGlobal || self.settings.commandModeRouteToCodex) ? 0.55 : 1) SearchableModelPicker( models: self.availableModels, @@ -526,7 +554,8 @@ struct CommandModeView: View { controlWidth: 180, controlHeight: 30 ) - .disabled(self.settings.commandModeLinkedToGlobal) + .disabled(self.settings.commandModeLinkedToGlobal || self.settings.commandModeRouteToCodex) + .opacity(self.settings.commandModeRouteToCodex ? 0.55 : 1) Spacer(minLength: 12) @@ -574,10 +603,14 @@ struct CommandModeView: View { // MARK: - Actions + private var commandModeReadinessIssue: String? { + self.settings.commandModeRouteToCodex ? nil : self.settings.commandModeReadinessIssue + } + private var canSubmitCommand: Bool { !self.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !self.service.isProcessing && - self.settings.commandModeReadinessIssue == nil + self.commandModeReadinessIssue == nil } private func toggleRecording() { @@ -589,7 +622,7 @@ struct CommandModeView: View { await MainActor.run { self.inputText = command } - guard self.settings.commandModeReadinessIssue == nil else { return } + guard self.commandModeReadinessIssue == nil else { return } await self.service.processUserCommand(command) await MainActor.run { self.inputText = "" @@ -603,7 +636,7 @@ struct CommandModeView: View { private func submitCommand() { let text = self.inputText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } - guard self.settings.commandModeReadinessIssue == nil else { return } + guard self.commandModeReadinessIssue == nil else { return } self.inputText = "" Task { await self.service.processUserCommand(text) @@ -611,6 +644,11 @@ struct CommandModeView: View { } private func updateAvailableModels() { + if self.settings.commandModeRouteToCodex { + self.availableModels = [] + return + } + let currentProviderID = self.settings.effectiveCommandModeProviderID let currentModel = self.settings.commandModeSelectedModel ?? "" guard !currentProviderID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -625,6 +663,11 @@ struct CommandModeView: View { } } + private func updateNotchOutputPreference() { + self.service.enableNotchOutput = self.settings.commandModeRouteToCodex && + self.settings.commandModeCodexHandoffStyle == "notch" + } + private var builtInProvidersList: [(id: String, name: String)] { // Apple Intelligence disabled for Command Mode (no tool support) ModelRepository.shared.builtInProvidersList( diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 9d441a7f..420ae0ef 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -22,6 +22,7 @@ final class DictationE2ETests: XCTestCase { private let commandModeLinkedToGlobalKey = "CommandModeLinkedToGlobal" private let commandModeSelectedProviderIDKey = "CommandModeSelectedProviderID" private let commandModeSelectedModelKey = "CommandModeSelectedModel" + private let visualizerNoiseThresholdKey = "VisualizerNoiseThreshold" private var privateAISelectedModelIDKey: String { PrivateAIProviderFeature.shared.selectedModelDefaultsKey } private var privateAILocalModelPathKey: String { PrivateAIProviderFeature.shared.localModelPathDefaultsKey } private var privateAIPrefixKVCacheEnabledKey: String { PrivateAIProviderFeature.shared.prefixCacheDefaultsKey } @@ -60,6 +61,22 @@ final class DictationE2ETests: XCTestCase { } } + func testVisualizerNoiseThreshold_defaultIsSensitiveEnoughForQuietSpeechFeedback() { + self.withRestoredDefaults(keys: [self.visualizerNoiseThresholdKey]) { + let defaults = UserDefaults.standard + defaults.removeObject(forKey: self.visualizerNoiseThresholdKey) + + let threshold = SettingsStore.shared.visualizerNoiseThreshold + + XCTAssertEqual(threshold, SettingsStore.defaultVisualizerNoiseThreshold, accuracy: 0.001) + XCTAssertLessThan( + threshold, + 0.2, + "Default visualizer sensitivity should show movement for quiet but valid mic input." + ) + } + } + func testDictationEndToEnd_whisperTiny_transcribesFixture() async throws { // Arrange SettingsStore.shared.shareAnonymousAnalytics = false