Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
6E49ADEB31D04DC77A47DEB0 /* FileLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */; };
744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; };
76FD91607794883F8E121450 /* CaretGeometrySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */; };
78A8713A0E5B4C89E2D715BC /* FocusCapabilityFlickerGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */; };
78FAE5DB691A1B71042B9D20 /* AboutPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */; };
7B6A63F5DCC2C163CDFD2A5C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BC4F887528AE74AC0DD30314 /* Assets.xcassets */; };
7C36DBA762E19C8C31676D44 /* MidWordContinuationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1274F897631B1B3A835D157F /* MidWordContinuationPolicyTests.swift */; };
Expand Down Expand Up @@ -181,6 +182,7 @@
BBE22CE4EF43247F8775B25D /* FocusPollBackoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09FADF683BE7B3558377FA76 /* FocusPollBackoff.swift */; };
BFCA7FAFDAEBF586AB615567 /* ClipboardRelevanceFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B0D133AB77A2503FB08827 /* ClipboardRelevanceFilterTests.swift */; };
C0B833234748E82D3382631A /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = C379D77029D6E88C8C1B9AF7 /* emoji.json */; };
C0FE11D76BDF01A5470C554D /* FocusCapabilityFlickerGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */; };
C2C958D6E5F5FE1CCC414BCE /* SuggestionSubsystemContracts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB16474A67CE1D210B944C9 /* SuggestionSubsystemContracts.swift */; };
C4C6734678797669055988E0 /* AppUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9573F3504CAE6891DF9B7D /* AppUpdateManager.swift */; };
C618C5595DA9C57C806A3E03 /* SettingsAttentionEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC293F6125E2B14DCF05AD9 /* SettingsAttentionEvaluatorTests.swift */; };
Expand Down Expand Up @@ -269,6 +271,7 @@
12DD19BCE610808F1E38702D /* PermissionOverlayTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionOverlayTrackerTests.swift; sourceTree = "<group>"; };
1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMatcher.swift; sourceTree = "<group>"; };
18D990E515E1AE4F312F4E95 /* BundledRuntimeLocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledRuntimeLocatorTests.swift; sourceTree = "<group>"; };
1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGateTests.swift; sourceTree = "<group>"; };
19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPaneScaffold.swift; sourceTree = "<group>"; };
19DB9558F4D3AFB108D71649 /* SuggestionStateHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionStateHelperTests.swift; sourceTree = "<group>"; };
1A8414BEB7E34F57607E37FE /* EmojiVariantResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiVariantResolver.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -337,6 +340,7 @@
62EDF1199CC5E18BD7651661 /* EmojiPickerPanelLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelLayout.swift; sourceTree = "<group>"; };
656F58E56FE9BC087B6F1D33 /* PermissionReminderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionReminderView.swift; sourceTree = "<group>"; };
67586807ACE8EB13C9014535 /* TickMarkSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TickMarkSlider.swift; sourceTree = "<group>"; };
6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGate.swift; sourceTree = "<group>"; };
6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionWorkController.swift; sourceTree = "<group>"; };
6DC693E00430F46E41CB56E6 /* RequestID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestID.swift; sourceTree = "<group>"; };
70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityResolver.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -709,6 +713,7 @@
723E1EFA85D2E61B6C5F33E8 /* EmojiTriggerStateMachineTests.swift */,
EE8BB19D8EC9A75CD3458A6B /* EmojiVariantResolverTests.swift */,
54BC85605541E913EE57B258 /* ExtendedContextTests.swift */,
1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */,
D4F6D5F94B238F7B4BE7C247 /* FocusCapabilityResolverTests.swift */,
273B4DC844F79B4BE2C8910F /* FocusPollBackoffTests.swift */,
BA705EDFE1C41294F0E381F1 /* FocusSnapshotResolverSelectionTests.swift */,
Expand Down Expand Up @@ -859,6 +864,7 @@
312C7306D916963F519CE0D9 /* EmojiTriggerStateMachine.swift */,
1A8414BEB7E34F57607E37FE /* EmojiVariantResolver.swift */,
CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */,
6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */,
70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */,
09FADF683BE7B3558377FA76 /* FocusPollBackoff.swift */,
FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */,
Expand Down Expand Up @@ -1059,6 +1065,7 @@
5DF31F465A3A1233260BD3A4 /* EngineAndModelPaneView.swift in Sources */,
5BE53CE921664582F593B7B0 /* FieldEdgeIconIndicatorView.swift in Sources */,
6E49ADEB31D04DC77A47DEB0 /* FileLogHandler.swift in Sources */,
C0FE11D76BDF01A5470C554D /* FocusCapabilityFlickerGate.swift in Sources */,
CC98B842D10574C5206BEFA7 /* FocusCapabilityResolver.swift in Sources */,
924489CEE8171F7AD8579D71 /* FocusDebugOverlayController.swift in Sources */,
2402DC57AE2BCF6A686D30ED /* FocusModels.swift in Sources */,
Expand Down Expand Up @@ -1201,6 +1208,7 @@
ED0843752B297D7E9DB2C468 /* EmojiTriggerStateMachineTests.swift in Sources */,
C9B815652CED38966C53A5E8 /* EmojiVariantResolverTests.swift in Sources */,
63054CBDCA87560130BF5ADC /* ExtendedContextTests.swift in Sources */,
78A8713A0E5B4C89E2D715BC /* FocusCapabilityFlickerGateTests.swift in Sources */,
C71B594433F3B411CAE5DE7E /* FocusCapabilityResolverTests.swift in Sources */,
A147C5EC3F2214A670F7556E /* FocusPollBackoffTests.swift in Sources */,
156E6AB3D24134EEC29FDB93 /* FocusSnapshotResolverSelectionTests.swift in Sources */,
Expand Down
17 changes: 16 additions & 1 deletion Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,23 @@ extension SuggestionCoordinator {
}

func handleFocusSnapshotChange(_ snapshot: FocusSnapshot) {
switch capabilityFlickerGate.evaluate(snapshot) {
case .apply:
break
case let .suppress(pendingBlockedReadCount: count):
// Single-poll AX flicker on the same element (see FocusCapabilityFlickerGate). Drop the
// event so the overlay does not bounce; log it so the suppression is observable.
let suppressedDetail = snapshot.capability.summary
CotabbyLogger.suggestion.trace(
// swiftlint:disable:next line_length
"Focus snapshot flicker suppressed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel) detail=\(suppressedDetail) pendingBlockedReads=\(count)"
)
return
}

let changedDetail = snapshot.capability.summary
CotabbyLogger.suggestion.trace(
"Focus snapshot changed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel)"
"Focus snapshot changed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel) detail=\(changedDetail)"
)
// Start capturing visual context for a newly focused input even when predictions are
// temporarily disabled by transient field states (e.g., "text is selected" or "secure
Expand Down
5 changes: 5 additions & 0 deletions Cotabby/App/Coordinators/SuggestionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ final class SuggestionCoordinator: ObservableObject {
/// synchronous `refreshNow()` calls on the main actor. Bumping the token makes older chains
/// no-op before they can perform another expensive AX read.
var hostPublishPollGeneration: UInt64 = 0
/// Suppresses single-poll `Supported → Blocked → Supported` flicker on the same focused element
/// so the overlay does not tear down and rebuild on every transient AX redraw. See
/// `FocusCapabilityFlickerGate` for the rationale and the reproduction (Apple Calendar event
/// editor).
var capabilityFlickerGate = FocusCapabilityFlickerGate()
/// Correlation ID for the most recently built `SuggestionRequest`. Stamped onto every
/// state-transition log line so all events tied to one suggestion (debounce → generating →
/// ready → accepted/rejected) can be joined with a single `jq` filter on `request_id`.
Expand Down
78 changes: 78 additions & 0 deletions Cotabby/Support/FocusCapabilityFlickerGate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Foundation

/// File overview:
/// Suppresses transient `Supported → Blocked → Supported` capability flicker on the *same* focused
/// element so the suggestion overlay does not tear down and rebuild every time a host app momentarily
/// republishes its focused field.
///
/// Background. `FocusCapabilityResolver` re-derives capability from live AX attributes on every poll
/// with no temporal smoothing. Some Catalyst-style fields (Apple Calendar's event editor is the
/// reproduction) briefly drop one of `textValue` / `selectionRange` / `caretBounds` while they
/// redraw, which collapses capability to Blocked for a single poll and bounces back to Supported on
/// the next. Without this gate every flicker drives `handleFocusSnapshotChange` to call
/// `disablePredictionsPreservingVisualContext` → `OverlayController.hide` → `panel.orderOut(nil)`,
/// which the user sees as the overlay opening and closing several times per second.
///
/// The gate is intentionally tiny and pure: it tracks the most recently delivered Supported element
/// identity and a consecutive-Blocked counter, and tells the caller whether to apply the new
/// snapshot or keep treating the field as Supported for now. A persistent loss of capability (the
/// real "field went away" case) clears the gate after `requiredConsecutiveBlockedReads` so the
/// downgrade still propagates promptly — at the observed ~80–150 ms poll cadence that is roughly
/// 160–300 ms of suppression, well above the ~13 ms flicker pairs seen in the logs but short enough
/// that genuine focus loss is not perceptible.
struct FocusCapabilityFlickerGate {
/// How many consecutive Blocked snapshots on the same element must be observed before the gate
/// releases the downgrade. Two is enough to swallow the single-poll flicker without delaying
/// real focus-loss perceptibly at typical poll cadence.
static let requiredConsecutiveBlockedReads = 2

/// Outcome the caller acts on.
enum Decision: Equatable {
/// Apply this snapshot as-is.
case apply
/// Treat as a transient flicker: pretend the previous Supported snapshot is still current.
/// `pendingBlockedReadCount` is exposed for diagnostic logging only.
case suppress(pendingBlockedReadCount: Int)
}

private var lastDeliveredSupportedElementID: String?
private var consecutiveBlockedReadCount: Int = 0

/// Feed every snapshot through here before letting it drive coordinator state.
mutating func evaluate(_ snapshot: FocusSnapshot) -> Decision {
switch snapshot.capability {
case .supported:
lastDeliveredSupportedElementID = snapshot.context?.elementIdentifier
consecutiveBlockedReadCount = 0
return .apply

case .blocked:
// Only debounce when we are still observing the same element that was just Supported.
// A different (or missing) element identifier is a genuine focus change and must
// propagate immediately.
guard let lastID = lastDeliveredSupportedElementID,
let currentID = snapshot.context?.elementIdentifier,
currentID == lastID
else {
lastDeliveredSupportedElementID = nil
consecutiveBlockedReadCount = 0
return .apply
}

consecutiveBlockedReadCount += 1
if consecutiveBlockedReadCount >= Self.requiredConsecutiveBlockedReads {
lastDeliveredSupportedElementID = nil
consecutiveBlockedReadCount = 0
return .apply
}
return .suppress(pendingBlockedReadCount: consecutiveBlockedReadCount)

case .unsupported:
// Unsupported is "no focused text input at all" — never debounce; the user has left the
// field and the overlay must hide immediately.
lastDeliveredSupportedElementID = nil
consecutiveBlockedReadCount = 0
return .apply
}
}
}
140 changes: 140 additions & 0 deletions CotabbyTests/FocusCapabilityFlickerGateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import XCTest
@testable import Cotabby

final class FocusCapabilityFlickerGateTests: XCTestCase {
func testFirstSnapshotIsAlwaysApplied() {
var gate = FocusCapabilityFlickerGate()
XCTAssertEqual(gate.evaluate(supportedSnapshot(elementID: "field-A")), .apply)
}

func testSingleBlockedFlickerOnSameElementIsSuppressed() {
var gate = FocusCapabilityFlickerGate()
_ = gate.evaluate(supportedSnapshot(elementID: "field-A"))

let decision = gate.evaluate(blockedSnapshot(elementID: "field-A"))

XCTAssertEqual(decision, .suppress(pendingBlockedReadCount: 1))
}

func testSupportedReturnAfterFlickerResetsTheGate() {
var gate = FocusCapabilityFlickerGate()
_ = gate.evaluate(supportedSnapshot(elementID: "field-A"))
_ = gate.evaluate(blockedSnapshot(elementID: "field-A"))

XCTAssertEqual(gate.evaluate(supportedSnapshot(elementID: "field-A")), .apply)

// After the reset, a fresh flicker is suppressed again rather than counted on top of the
// previous run.
XCTAssertEqual(
gate.evaluate(blockedSnapshot(elementID: "field-A")),
.suppress(pendingBlockedReadCount: 1)
)
}

func testTwoConsecutiveBlockedReadsReleaseTheDowngrade() {
var gate = FocusCapabilityFlickerGate()
_ = gate.evaluate(supportedSnapshot(elementID: "field-A"))

XCTAssertEqual(
gate.evaluate(blockedSnapshot(elementID: "field-A")),
.suppress(pendingBlockedReadCount: 1)
)
XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-A")), .apply)
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

func testBlockedOnDifferentElementBypassesSuppression() {
var gate = FocusCapabilityFlickerGate()
_ = gate.evaluate(supportedSnapshot(elementID: "field-A"))

XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-B")), .apply)
}

func testBlockedWithoutAnyPriorSupportedIsApplied() {
var gate = FocusCapabilityFlickerGate()
XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-A")), .apply)
}

func testUnsupportedIsNeverSuppressed() {
var gate = FocusCapabilityFlickerGate()
_ = gate.evaluate(supportedSnapshot(elementID: "field-A"))

XCTAssertEqual(gate.evaluate(unsupportedSnapshot()), .apply)
}

func testUnsupportedClearsPendingFlickerState() {
var gate = FocusCapabilityFlickerGate()
_ = gate.evaluate(supportedSnapshot(elementID: "field-A"))
_ = gate.evaluate(blockedSnapshot(elementID: "field-A"))
_ = gate.evaluate(unsupportedSnapshot())

// Without the clear, the next Blocked would resume the previous counter and downgrade
// immediately. The Unsupported observation must reset everything.
XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-A")), .apply)
}

func testSupportedWithoutContextDoesNotArmTheGate() {
var gate = FocusCapabilityFlickerGate()

// A Supported snapshot with no context (rare but possible — e.g. capability inferred from
// app identity before AX details settle) cannot be used as a reference for "same element"
// checks, so the next Blocked must propagate immediately rather than be silently
// suppressed.
let supportedWithoutContext = FocusSnapshot(
applicationName: "Calendar",
bundleIdentifier: "com.apple.iCal",
capability: .supported,
context: nil,
inspection: nil
)
XCTAssertEqual(gate.evaluate(supportedWithoutContext), .apply)
XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-A")), .apply)
}

func testBlockedWithMissingContextIsAppliedEvenAfterSupported() {
var gate = FocusCapabilityFlickerGate()
_ = gate.evaluate(supportedSnapshot(elementID: "field-A"))

// Loss of context on the snapshot means we can no longer prove "same element", so the
// gate has to defer to the downstream evaluator instead of holding the field as Supported.
let blockedWithoutContext = FocusSnapshot(
applicationName: "Calendar",
bundleIdentifier: "com.apple.iCal",
capability: .blocked("Text is currently selected."),
context: nil,
inspection: nil
)
XCTAssertEqual(gate.evaluate(blockedWithoutContext), .apply)
}

// MARK: - Helpers

private func supportedSnapshot(elementID: String) -> FocusSnapshot {
FocusSnapshot(
applicationName: "Calendar",
bundleIdentifier: "com.apple.iCal",
capability: .supported,
context: CotabbyTestFixtures.focusedInputSnapshot(elementIdentifier: elementID),
inspection: nil
)
}

private func blockedSnapshot(elementID: String) -> FocusSnapshot {
FocusSnapshot(
applicationName: "Calendar",
bundleIdentifier: "com.apple.iCal",
capability: .blocked("Text is currently selected."),
context: CotabbyTestFixtures.focusedInputSnapshot(elementIdentifier: elementID),
inspection: nil
)
}

private func unsupportedSnapshot() -> FocusSnapshot {
FocusSnapshot(
applicationName: "Finder",
bundleIdentifier: "com.apple.finder",
capability: .unsupported("No focused text input"),
context: nil,
inspection: nil
)
}
}