How FluidVoice Rewrite Mode Works with Text Selection in Any macOS Application

FluidVoice's rewrite mode captures the currently selected text from any foreground macOS application using the Accessibility framework, processes it through an LLM, and types the AI-generated replacement back into the original app.

The FluidVoice open-source project (altic-dev/FluidVoice) enables users to rewrite text across any macOS application without requiring specialized integrations or plugins. By leveraging system-level Accessibility APIs, the app reads selected text from the foreground application's UI hierarchy, generates rewritten content via an LLM, and automatically replaces the original selection using simulated keyboard input.

How Rewrite Mode Activates and Captures Selected Text

When a user invokes the global hotkey, FluidVoice initiates a capture sequence that bridges the UI layer with low-level system services. The ContentView.handleModeTransition() method signals RewriteModeService to begin the capture process, which immediately queries the TextSelectionService to extract the current selection.

The entry point in RewriteModeService.swift handles the coordination between the view layer and the text extraction logic:

// In RewriteModeService.swift
func captureSelectedText() -> Bool {
    if let text = textSelectionService.getSelectedText(), !text.isEmpty {
        self.originalText = text                // UI will show this
        self.selectedContextText = text
        self.rewrittenText = ""
        self.conversationHistory = []
        self.isWriteMode = false                // we are in rewrite mode
        return true
    }
    return false
}

If captureSelectedText() returns true, the RewriteModeView displays the captured string in the Original Text panel, preparing the user to enter rewrite instructions.

Accessibility Framework Implementation for Cross-App Text Selection

FluidVoice operates across arbitrary applications by treating the macOS Accessibility framework as a universal interface to the UI hierarchy. The TextSelectionService.getSelectedText() method performs a two-tier lookup: first targeting the system-wide focused element, then falling back to the frontmost application if necessary.

Primary Text Extraction via kAXSelectedTextAttribute

The service attempts to read the kAXSelectedTextAttribute directly from the focused accessibility element. This attribute provides immediate access to highlighted text without requiring manual substring calculations.

Fallback Extraction Using Range and Value Attributes

When an application does not expose kAXSelectedTextAttribute, FluidVoice implements a robust fallback strategy using kAXSelectedTextRangeAttribute and kAXValueAttribute. This approach reads the full text content of the element, retrieves the selected range as a CFRange, and extracts the corresponding substring.

The implementation in TextSelectionService.swift demonstrates this dual-path extraction:

// Inside TextSelectionService.swift
private func getSelectedText(from element: AXUIElement) -> String? {
    var value: CFTypeRef?
    let result = AXUIElementCopyAttributeValue(element,
                                               kAXSelectedTextAttribute as CFString,
                                               &value)
    if result == .success, let text = value as? String {
        return text
    }

    // --- Fallback: use range + full value ---
    var rangeRef: CFTypeRef?
    let rangeResult = AXUIElementCopyAttributeValue(element,
                                                    kAXSelectedTextRangeAttribute as CFString,
                                                    &rangeRef)
    guard rangeResult == .success, let axRange = rangeRef else { return nil }

    var range = CFRange()
    guard AXValueGetValue(axRange as! AXValue, .cfRange, &range) else { return nil }
    guard range.length > 0 else { return nil }

    var fullValueRef: CFTypeRef?
    let fullResult = AXUIElementCopyAttributeValue(element,
                                                   kAXValueAttribute as CFString,
                                                   &fullValueRef)
    guard fullResult == .success, let fullText = fullValueRef as? String else { return nil }

    let nsText = fullText as NSString
    return nsText.substring(with: NSRange(location: range.location, length: range.length))
}

This fallback mechanism ensures compatibility with applications that implement accessibility differently, including browsers, code editors, and native macOS text fields.

Processing Rewrites with LLM Integration

Once the original text is captured and stored in RewriteModeService.originalText and selectedContextText, the service enters Rewrite Mode (as opposed to Write Mode, which generates content from scratch). The processRewriteRequest(_:) method constructs a prompt that combines the user's instruction with the selected context, then dispatches it to the LLM via LLMClient.

// In RewriteModeService.swift
func processRewriteRequest(_ prompt: String) async {
    // Build the prompt that includes the selected context
    if !self.originalText.isEmpty {                 // rewrite mode
        let rewritePrompt = """
        User's instruction: \(prompt)

        Apply the instruction to the selected context. Output ONLY the rewritten text, nothing else.
        """
        self.conversationHistory.append(Message(role: .user, content: rewritePrompt))
    } else {                                         // write mode
        self.originalText = prompt
        self.isWriteMode = true
        self.conversationHistory.append(Message(role: .user, content: prompt))
    }

    // Call the LLM (non‑streaming for edit mode)
    let response = try await callLLM(messages: conversationHistory,
                                     isWriteMode: isWriteMode)
    self.rewrittenText = response
}

The method distinguishes between rewrite and write modes by checking originalText.isEmpty. When text exists, it constructs a constrained prompt instructing the LLM to output only the rewritten content without additional commentary.

Injecting Rewritten Text Back into the Target Application

After the LLM returns the rewritten text and the user accepts the changes, RewriteModeService.acceptRewrite() handles the final injection. The method hides the FluidVoice window to return focus to the target application, then uses TypingService.typeTextInstantly(_:) to simulate keyboard input and replace the original selection.

func acceptRewrite() {
    guard !self.rewrittenText.isEmpty else { return }
    NSApp.hide(nil)                         // return focus to the target app
    typingService.typeTextInstantly(self.rewrittenText)
}

This approach operates entirely through standard input simulation, requiring no clipboard manipulation or application-specific APIs.

Required macOS Accessibility Permissions

FluidVoice requires the user to grant Accessibility permissions for the app to read and interact with other applications' UI elements. The codebase checks AXIsProcessTrusted() to verify permissions before attempting accessibility operations, logging diagnostics if access is denied.

All text extraction and injection operations depend on this single permission grant, making the architecture both powerful and simple to deploy across different macOS environments.

Summary

  • FluidVoice uses the macOS Accessibility framework to read selected text from any foreground application via TextSelectionService.getSelectedText().
  • The system attempts direct extraction via kAXSelectedTextAttribute, falling back to kAXSelectedTextRangeAttribute and kAXValueAttribute when necessary.
  • RewriteModeService coordinates the capture, LLM processing, and injection workflow, storing state in originalText and selectedContextText.
  • Rewritten content is injected back into the source application using TypingService.typeTextInstantly(_:), which simulates keyboard input after NSApp.hide(nil) returns focus.
  • The feature requires macOS Accessibility permissions (AXIsProcessTrusted()) but needs no application-specific integration or plugins.

Frequently Asked Questions

Does FluidVoice rewrite mode work with all macOS applications?

FluidVoice's rewrite mode works with any macOS application that implements standard accessibility attributes for text fields. The TextSelectionService handles variations in implementation by checking for kAXSelectedTextAttribute first, then falling back to range-based extraction using kAXSelectedTextRangeAttribute and kAXValueAttribute. Applications that completely disable accessibility access or use non-standard text rendering may block this functionality.

What happens if no text is selected when activating rewrite mode?

If captureSelectedText() returns false due to empty selection, RewriteModeService automatically switches to Write Mode by setting isWriteMode = true and treating the user's input as a generation request rather than a rewrite. The processRewriteRequest(_:) method detects an empty originalText property and constructs a generative prompt instead of a context-aware rewrite instruction.

How does FluidVoice handle privacy when reading text from other apps?

FluidVoice reads text directly from the foreground application's UI hierarchy using the Accessibility framework, processing it locally through the RewriteModeService before sending it to the configured LLM endpoint. The text never passes through the system clipboard during the capture or replacement phases. Users maintain control over when the hotkey activates the capture mechanism, and the app checks AXIsProcessTrusted() to ensure permissions are explicitly granted.

Can I customize the LLM prompt used for rewriting text?

While the provided code shows a fixed prompt template in processRewriteRequest(_:), the architecture supports customization through the conversationHistory array and the callLLM(messages:isWriteMode:) method. Developers can modify the prompt construction logic in RewriteModeService.swift to include additional constraints, formatting instructions, or system messages before dispatching to the LLMClient.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →