Summary
Reduce the main-thread cost of synchronous Accessibility API polling in the prediction path, particularly for Chromium/Electron apps where AX queries can take 10–50ms+ each.
Problem
FocusTracker.refreshNow() makes synchronous AX API calls on @MainActor. Each call to FocusSnapshotResolver.resolveSnapshot() can walk up to 200 AX nodes (depth limit 10), reading role, subrole, value, selection, frame, and attribute lists per candidate element.
This is called three times per keystroke cycle:
SuggestionCoordinator+Input.swift:119 — at keystroke time
SuggestionCoordinator+Prediction.swift:46 — before generation
SuggestionCoordinator+Prediction.swift:130 — after generation completes
For most native macOS apps, AX queries return in <1ms and this is fine. But Chromium-based browsers, Electron apps (VS Code, Slack, Discord), and apps with deep view hierarchies can take 10–50ms+ per query. Three calls at 50ms = 150ms of main-thread blocking per keystroke — enough to visibly delay suggestion appearance.
The synchronous polling design was chosen intentionally because AXObserver delivery is inconsistent across apps (documented in FocusTracker.swift:4–5). This is a valid trade-off, but there are mitigations that preserve polling while reducing cost.
Proposed direction
Quick wins (low risk, high impact):
-
Reduce 3 calls to 1–2 per cycle. The post-generation refresh (line 130) can reuse a recent snapshot if the focused app/element hasn't changed — no need to re-walk 200 nodes.
-
Cache unchanged snapshots. If frontmostApplication and the focused AXUIElement ref haven't changed since the last poll, skip the deep resolveSnapshot() tree walk and return the cached result. Invalidate on app switch or focus change.
-
Skip deep geometry search for known-fast paths. If the focused element already has a valid caret rect from the initial attribute read, don't scan 200 child nodes looking for a better one.
Longer-term (higher effort):
-
Per-app AX response time tracker. Measure how long AX calls take per bundle ID. For known-slow apps, reduce polling depth or skip optional attribute reads.
-
Async AX wrapper with timeout. Dispatch AX calls to a background serial queue with a timeout (e.g., 50ms). If the call doesn't return in time, use the last known snapshot. This is a significant refactor since AX APIs have main-thread requirements in some contexts.
Additional context
- There are currently zero timeouts, async wrappers, or caching for AX calls in the codebase.
- The
FocusSnapshotResolver deep geometry search (lines 281–339) with its 200-node limit is the most expensive part — it exists because some apps don't expose caret geometry on the focused element directly.
- Browser-specific code paths (
AXHelper.textMarkerCaretRect) are particularly expensive.
- The quick wins (items 1–3) can be shipped independently and should meaningfully improve the experience for Electron/Chromium users.
Summary
Reduce the main-thread cost of synchronous Accessibility API polling in the prediction path, particularly for Chromium/Electron apps where AX queries can take 10–50ms+ each.
Problem
FocusTracker.refreshNow()makes synchronous AX API calls on@MainActor. Each call toFocusSnapshotResolver.resolveSnapshot()can walk up to 200 AX nodes (depth limit 10), reading role, subrole, value, selection, frame, and attribute lists per candidate element.This is called three times per keystroke cycle:
SuggestionCoordinator+Input.swift:119— at keystroke timeSuggestionCoordinator+Prediction.swift:46— before generationSuggestionCoordinator+Prediction.swift:130— after generation completesFor most native macOS apps, AX queries return in <1ms and this is fine. But Chromium-based browsers, Electron apps (VS Code, Slack, Discord), and apps with deep view hierarchies can take 10–50ms+ per query. Three calls at 50ms = 150ms of main-thread blocking per keystroke — enough to visibly delay suggestion appearance.
The synchronous polling design was chosen intentionally because
AXObserverdelivery is inconsistent across apps (documented in FocusTracker.swift:4–5). This is a valid trade-off, but there are mitigations that preserve polling while reducing cost.Proposed direction
Quick wins (low risk, high impact):
Reduce 3 calls to 1–2 per cycle. The post-generation refresh (line 130) can reuse a recent snapshot if the focused app/element hasn't changed — no need to re-walk 200 nodes.
Cache unchanged snapshots. If
frontmostApplicationand the focusedAXUIElementref haven't changed since the last poll, skip the deepresolveSnapshot()tree walk and return the cached result. Invalidate on app switch or focus change.Skip deep geometry search for known-fast paths. If the focused element already has a valid caret rect from the initial attribute read, don't scan 200 child nodes looking for a better one.
Longer-term (higher effort):
Per-app AX response time tracker. Measure how long AX calls take per bundle ID. For known-slow apps, reduce polling depth or skip optional attribute reads.
Async AX wrapper with timeout. Dispatch AX calls to a background serial queue with a timeout (e.g., 50ms). If the call doesn't return in time, use the last known snapshot. This is a significant refactor since AX APIs have main-thread requirements in some contexts.
Additional context
FocusSnapshotResolverdeep geometry search (lines 281–339) with its 200-node limit is the most expensive part — it exists because some apps don't expose caret geometry on the focused element directly.AXHelper.textMarkerCaretRect) are particularly expensive.