Architecture & Internals

Text Selection & Strategy Chain

Multi-strategy selection extraction using Accessibility APIs, WebKit markers, and AppleScript.

On this page (8 sections)

The text selection subsystem detects user text selection events across all macOS applications and extracts selected text non-destructively, then hands it to the popup.


Selection Detection Architecture#

The subsystem consists of three primary components:

+------------------------+   +-----------------------+
| MacSelectionMonitor |-->| RuleEngine |
| (Global Mouse/AX Event)|   | (Per-App Policy) |
+------------------------+   +-----------------------+
 |                |
 | builds         v
 |          +-----------------------+
 |          | SelectionContext |
 |          +-----------------------+
 v                |
+------------------------+      |
| SelectionRetrievalCoordinator |      |
| (Gate + Mode Routing) |      |
+------------------------+      |
                        v
             +-----------------------+
             | onSelection callback |
             | (PopupWindowController) |
             +-----------------------+
  1. MacSelectionMonitor: Listens for mouse release events (leftMouseUp) and keyboard selection gestures (⌘A select-all, ⇧+arrow) and dispatches retrieval.
  2. SelectionRetrievalCoordinator (from OpenSelection package via OpenSelectionBridge): Applies the gate, resolves the app's retrieval mode from AppPolicyContext, and routes to the matching strategy.
  3. Context assembly: MacSelectionMonitor resolves app rules via RuleEngine, builds a SelectionContext, and notifies subscriber callbacks (such as PopupWindowController).

The monitor snapshots the window under a mouse press and rejects the release if that window moved, resized or disappeared. This avoids retrieving an old selection from the editor that remains focused while its title bar is dragged. New presses and switches away from the source process cancel pending automatic reads and clear the shortcut cache. Queued notifications for apps no longer frontmost, the source app's own activation, and OpenClip's popup activation are ignored. The monitor rechecks the source PID before caching or presenting results after async work. At the copy boundary, OpenSelection's AutomaticCopyCapture requires an enabled Command-C menu shortcut, then rechecks cancellation, the frontmost process and the overlay guard before invoking the copy trigger. The bounded menu probe runs off the main actor. Native AX retrieval and the explicit hotkey use their existing behavior. See current limitations.

The shared capture and window-geometry implementations live in OpenSelection. OpenClip retains only its app-specific monitor lifecycle (including hold-to-trigger and cached selections).


Retrieval: Resolver, Gate, and Strategies#

Every retrieval request (mouse-up drag, ⌘A/⇧+arrow gesture, or the ⌥⌘C hotkey) runs through SelectionRetrievalCoordinator.retrieve(for:policy:cursor:):

  1. Fresh AX snapshot — AXElementInspector.inspect() resolves the focused application, then the focused UI element from that application, never from the system-wide element (the classic source of stale reads). It collects the role, parent/container roles, selection attributes, and selection bounds. The blocking snapshot runs on the dedicated com.openclip.ax-inspect queue, raced against Constants.axReadTimeout (0.5 s) via a once-resume gate; a hung or unresponsive target yields nil instead of stalling the popup.
  2. Gate — SelectionGatePolicy decides whether to attempt retrieval at all:
    • skipRoles — AX roles that can never hold a text selection (buttons, menus, scrollbars, …) are rejected up front.
    • allowedCursors — the cursor class (from CursorClassifier in OpenSelection) must suggest a text context; .unknown is never a reason to block.
  3. Strategy chain — a single canonical fallback order selects the first working strategy. The app's SelectionRetrievalMode picks the entry point into that chain; retrieval then runs that strategy and every strategy below it. An app with no rule starts at ax-text-control (the top), which is the "auto" behavior.

The canonical chain:

ax-text-control → ax-web-area → menu-copy → keyboard-copy
Rendering diagram…

Chain suffix per preferred mode:

Preferred modeStrategies run (in order)
ax-text-control (default / no rule)AX text → (embedded web-area detected? AX web area → keyboard copy; otherwise AX text → keyboard copy)
ax-web-area (browsers)AX web area → keyboard copy
browser-script (legacy alias)AX web area → keyboard copy
menu-copymenu copy
keyboard-copykeyboard copy

Web selections run directly through native Accessibility (AXWebArea), reading kAXSelectedTextMarkerRange in <1 ms without spawning subprocesses or requiring Apple Events permissions. If Accessibility yields no text (e.g. Google Docs canvas or custom editors), the coordinator falls back to keyboard-copy.

Retrieval modes#

Modekebab-case keyStrategy
AX native text controlax-text-controlAXTextControlStrategy reads kAXSelectedTextAttribute (falling back to value + selectedTextRange substring) and the selection bounds. Zero pasteboard side-effects. Default.
AX web areaax-web-areaAXWebAreaStrategy reads kAXSelectedTextMarkerRange → AXStringForTextMarkerRange (fallback selectedText). Includes a settle-retry loop: the snapshot is re-inspected fresh on every retry (up to webAreaSettleMaxRetries = 6, webAreaSettleInterval = 50 ms apart) so text appearing after focus is observed instead of a frozen target. AXElementInspector walks up to 25 ancestor levels and searches the window on focus mismatch.
Browser script (legacy)browser-scriptFormerly used AppleScript execute javascript; now maps directly to ax-web-area with keyboard-copy fallback to eliminate subprocess latency and permission friction.
Menu copymenu-copyPasteboardCopyEngine archives the pasteboard, AXPresses the app's Edit ▸ Copy menu item (matched by action identifier copy:, ⌘C key equivalent, or localized title via AXMenuNavigator), polls for an advanced changeCount with non-empty text, then restores. The press shares inspectGate and uses axReadTimeout, same as inspect. Used for terminals.
Keyboard copykeyboard-copyThe same engine with a synthesized ⌘C key event (SessionEventTapPoster) as the trigger. Used for custom code editors and Electron apps whose AX selection reads are unreliable.

The copy engine and transient markers#

Both copy modes run through PasteboardCopyEngine: archive every type of every pasteboard item → run the trigger → poll every 2 ms up to a per-app timeout (pasteboardCopyTimeout 0.25 s, or safariPasteboardCopyTimeout 0.6 s for browsers) for an advanced changeCount yielding non-empty text (a changeCount advance with empty content keeps polling, covering the transient clipboard race; recopying identical text succeeds as long as changeCount advances) → read the new string → restore the archived items synchronously before returning, tagged with the nspasteboard markers org.nspasteboard.TransientType and org.nspasteboard.AutoGeneratedType (empty data). The markers tell clipboard managers to skip the restore as a user-visible copy. The clipboard is therefore clean by the time retrieval returns; there is no lingering visibility window.

Per-app routing (default catalog)#

DefaultAppRules.catalog assigns modes to app groups; RuleEngine.resolvePolicies matches the frontmost app's identity (bundle ID, or a process:/path: identifier for bundle-less apps) against default + user rules (with .* prefix / * wildcards). User rules in ~/.openclip/rules.json override per-key — see docs/user-guide/app-rules.md for the JSON keys.

GroupTarget Application CategoryMode
safariGroupSafari & WebKit-based browsersax-web-area
chromiumGroupChromium-based browsersax-web-area
firefoxGroupFirefox & Gecko-based browsersax-web-area
arcGroupArc browsersax-web-area
keyboardCopyAppsCode editors, IDEs, markdown & note appskeyboard-copy
menuCopyAppsTerminal emulatorsmenu-copy
denyPasteAppsApplications requiring paste suppressiondenyPaste: true
defaultAll other standard applicationsax-text-control

Shortcut Clipboard Fallback & Synchronous Resolution#

The retrieval path above applies to passive selection monitoring. The global toggle shortcut (HotkeyManager) runs a strictly synchronous resolution pipeline on @MainActor without incurring asynchronous AX query latency:

  1. Monitored Selection Reuse: The hotkey checks selectionMonitor.synchronousSelection(for: frontmostBundleID). If the user recently selected text in the active application and that selection has not expired (Constants.selectionMaxAge = 30 s) or been cleared by caret navigation / typing, the monitored selection is reused immediately.
  2. Clipboard Fallback: If no valid monitored selection exists, OpenClip falls back to the current contents of NSPasteboard.general so the search palette still has input to act on.
    • The context is flagged SelectionContext.isClipboardFallback; PopupWindowController.show filters available actions down to Paste (and AI Tools launcher).
  3. Empty Context Fallback: If the clipboard is also empty, an empty selection context is created with the frontmost app's identity, allowing standalone actions to run.

Passive selection monitoring continues even when "Appear Automatically" is disabled (isAppEnabled == false or hotkeyOnly: true), updating latestSelection and pre-warming the search index in the background so pressing the shortcut opens the palette with zero perceptual delay. Both passive mouse and keyboard paths withhold synthetic copy fallback under those settings; native AX reads still populate the cache. The explicit hold gesture delivers straight from handleMouseDown and stays unaffected by isAppEnabled.

The hold's editable-field lookup runs off the main actor through SelectionEditabilityProbe, bound to the original source PID and one axReadTimeout deadline. If the lookup times out, clipboard fallback is refused; the UI thread keeps processing events.


Privacy & Non-Destructive Guarantees#

  • No Clipboard Pollution (AX modes): ax-text-control, ax-web-area, and browser-script never write to NSPasteboard — they read the live accessibility tree or the browser's AppleScript bridge.
  • Copy modes are archive-and-restore: menu-copy/keyboard-copy temporarily place the selected text on the general pasteboard, then restore the archived items tagged with the nspasteboard transient markers so clipboard managers don't treat the restore as a user copy.
  • Ignored Fields: Secure text fields (such as password inputs or masked text areas) do not expose kAXSelectedTextAttribute through AX APIs, ensuring password security.

Inline Action Match Context#

Inline result evaluation receives the same per-action regex match context as click execution. The selection remains available as openclip.input.text, while openclip.input.matchedText and openclip.input.captures carry the regex match and capture groups. This lets inline actions such as Currency Converter evaluate a value embedded in a longer selected passage.