diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift
index f5290b64..1816c22e 100644
--- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift
+++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift
@@ -240,7 +240,21 @@ extension SuggestionCoordinator {
insertionChunk: String,
liveContext: FocusedInputContext
) {
- if overlayController.advanceInline(to: remainingText, insertedText: insertionChunk) {
+ // A layout-estimated anchor lives in the hidden text layout's coordinate system, and the
+ // estimator models the accepted chunk's true advance (soft wrap included) better than any
+ // width shift can: that re-anchor, fed with the pending insertion below, is exactly what
+ // the caret repair was built for on these hosts. Sliding instead leaves the anchor a
+ // ghost-vs-host font error away from the next estimate and the settle showed up as a
+ // post-accept jerk. Skip the slide so the estimator places the tail where the
+ // post-publish estimate will also land; nothing moves afterwards.
+ let heldOverlayQuality: CaretGeometryQuality?
+ if case let .visible(_, geometry, _) = overlayState {
+ heldOverlayQuality = geometry.caretQuality
+ } else {
+ heldOverlayQuality = nil
+ }
+ if heldOverlayQuality != .layoutEstimated,
+ overlayController.advanceInline(to: remainingText, insertedText: insertionChunk) {
return
}
diff --git a/Cotabby/Support/BrowserAppDetector.swift b/Cotabby/Support/BrowserAppDetector.swift
index 35821da0..96c4d875 100644
--- a/Cotabby/Support/BrowserAppDetector.swift
+++ b/Cotabby/Support/BrowserAppDetector.swift
@@ -15,7 +15,7 @@ import Foundation
///
/// Matching is by case-insensitive bundle-identifier prefix to tolerate channel suffixes
/// (`com.google.Chrome.canary`, `com.google.Chrome.beta`, etc.).
-enum BrowserAppDetector {
+nonisolated enum BrowserAppDetector {
/// Every browser family, used for the broad "typing in a browser" tone hint.
private static let browserBundlePrefixes: [String] = [
"com.apple.safari",
diff --git a/Cotabby/Support/ControlTokenMarkers.swift b/Cotabby/Support/ControlTokenMarkers.swift
index 86ea5ca4..811ea203 100644
--- a/Cotabby/Support/ControlTokenMarkers.swift
+++ b/Cotabby/Support/ControlTokenMarkers.swift
@@ -20,7 +20,7 @@ import Foundation
/// into a text field expecting a completion, so removing them cannot eat legitimate prose or code.
/// `…` reasoning blocks are intentionally not listed here: they are handled separately
/// so the text around them survives.
-enum ControlTokenMarkers {
+nonisolated enum ControlTokenMarkers {
/// A Llama-3 role-header block, e.g. `<|start_header_id|>assistant<|end_header_id|>`. The role
/// name sits *between* the markers, so removing the markers individually would leak the role word
/// into the ghost text; this strips the whole block. `|` is escaped because it is a regex
diff --git a/Cotabby/Support/SuggestionOverlayStabilityGate.swift b/Cotabby/Support/SuggestionOverlayStabilityGate.swift
index 60aca22f..4ca65e81 100644
--- a/Cotabby/Support/SuggestionOverlayStabilityGate.swift
+++ b/Cotabby/Support/SuggestionOverlayStabilityGate.swift
@@ -74,12 +74,22 @@ enum SuggestionOverlayStabilityGate {
if isAwaitingPostInsertionSync {
return false
}
- // Hold small caret deltas (post-insertion AX noise and exact-advance residual); re-anchor on
- // genuine moves and on accumulated drift past the tolerance. Compared against the held
- // (already-advanced) caret, not a per-tick previous value, so slow drift still gets corrected.
- if abs(currentGeometry.caretRect.origin.x - newCaretRect.origin.x) > caretDriftTolerance
- || abs(currentGeometry.caretRect.origin.y - newCaretRect.origin.y) > caretDriftTolerance {
- return true
+ // A layout-estimated anchor was computed by the hidden text layout from (text, field
+ // frame, style), not from the resolver's caret. Fresh snapshots still carry the RAW
+ // resolver rect (an AXFrame proportional guess, or a derived rect the repair overrode),
+ // which lives in a different trust system and routinely sits a word or more away from the
+ // estimate; treating that gap as caret drift re-presented (and re-estimated) on every
+ // reconcile tick. With text and field unchanged the estimate is pure-function stable, so
+ // only the frame check below can demand a re-anchor for these overlays.
+ if currentGeometry.caretQuality != .layoutEstimated {
+ // Hold small caret deltas (post-insertion AX noise and exact-advance residual); re-anchor
+ // on genuine moves and on accumulated drift past the tolerance. Compared against the held
+ // (already-advanced) caret, not a per-tick previous value, so slow drift still gets
+ // corrected.
+ if abs(currentGeometry.caretRect.origin.x - newCaretRect.origin.x) > caretDriftTolerance
+ || abs(currentGeometry.caretRect.origin.y - newCaretRect.origin.y) > caretDriftTolerance {
+ return true
+ }
}
// `observedCharWidth` is intentionally NOT compared here. Drift in that value also affects
// `GhostSuggestionLayout.singleLineFits` (and therefore the panel-origin branch), so during
diff --git a/Cotabby/Support/TerminalAppDetector.swift b/Cotabby/Support/TerminalAppDetector.swift
index a9d07a23..336613a2 100644
--- a/Cotabby/Support/TerminalAppDetector.swift
+++ b/Cotabby/Support/TerminalAppDetector.swift
@@ -5,7 +5,7 @@ import Foundation
/// Terminal apps have their own completion, history, and shell integrations that conflict with
/// ghost-text autocomplete. Cotabby stays out of the way automatically so the user doesn't have to
/// manually disable each terminal they use.
-enum TerminalAppDetector {
+nonisolated enum TerminalAppDetector {
/// Bundle identifiers of well-known macOS terminal emulators.
private static let terminalBundleIdentifiers: Set = [
"com.apple.Terminal",
diff --git a/CotabbyTests/SuggestionCoordinatorPredictionTests.swift b/CotabbyTests/SuggestionCoordinatorPredictionTests.swift
index 257718f8..a8a69bae 100644
--- a/CotabbyTests/SuggestionCoordinatorPredictionTests.swift
+++ b/CotabbyTests/SuggestionCoordinatorPredictionTests.swift
@@ -338,6 +338,42 @@ final class SuggestionCoordinatorPredictionTests: XCTestCase {
// MARK: - Post-insertion stillness
+ func test_accept_layoutEstimatedOverlaySkipsTheSlideAndReAnchorsViaTheEstimator() {
+ // TextKit-mirror hosts: the overlay anchor came from the hidden layout estimate, so a
+ // width-based slide leaves it a ghost-vs-host font error away from the next estimate and
+ // the settle reads as a post-accept jerk. The accept must skip the slide and go through
+ // the presenting path, where the layout repair (fed the pending insertion) re-anchors at
+ // exactly the position the post-publish estimate will reproduce.
+ let rig = retained(makeCoordinatorRig())
+ let context = FocusedInputContext(snapshot: rig.focusProvider.snapshot.context!, generation: 1)
+ _ = rig.interactionState.startSession(fullText: " world again", liveContext: context, latency: 0.05)
+ rig.overlayController.showSuggestion(
+ " world again",
+ geometry: CotabbyTestFixtures.overlayGeometry(caretQuality: .layoutEstimated)
+ )
+
+ XCTAssertTrue(rig.coordinator.acceptCurrentSuggestion())
+
+ XCTAssertTrue(
+ rig.overlayController.advanceInlineCalls.isEmpty,
+ "A layout-estimated overlay must never width-slide on accept"
+ )
+ XCTAssertEqual(rig.overlayController.shownTexts.last, " again", "The estimator path re-presented the tail")
+ }
+
+ func test_accept_trustedGeometryStillAttemptsTheSlideFirst() {
+ let rig = retained(makeCoordinatorRig())
+ let context = FocusedInputContext(snapshot: rig.focusProvider.snapshot.context!, generation: 1)
+ _ = rig.interactionState.startSession(fullText: " world again", liveContext: context, latency: 0.05)
+ rig.overlayController.showSuggestion(" world again", geometry: CotabbyTestFixtures.overlayGeometry())
+
+ XCTAssertTrue(rig.coordinator.acceptCurrentSuggestion())
+
+ XCTAssertEqual(rig.overlayController.advanceInlineCalls.count, 1)
+ XCTAssertEqual(rig.overlayController.advanceInlineCalls.first?.remaining, " again")
+ XCTAssertEqual(rig.overlayController.advanceInlineCalls.first?.inserted, " world")
+ }
+
func test_reconcileDuringPostInsertionSyncWindow_neverReAnchorsTheOverlay() {
// The TextEdit accept jitter: Tab inserts " world" and the overlay advances immediately,
// but the +30ms refresh can read AX BEFORE the host publishes the insert. That snapshot's
diff --git a/CotabbyTests/SuggestionCoordinatorTestSupport.swift b/CotabbyTests/SuggestionCoordinatorTestSupport.swift
index 37a13b57..bc9e0013 100644
--- a/CotabbyTests/SuggestionCoordinatorTestSupport.swift
+++ b/CotabbyTests/SuggestionCoordinatorTestSupport.swift
@@ -64,11 +64,19 @@ final class RigOverlayController: SuggestionOverlayControlling {
var onStateChange: ((OverlayState) -> Void)?
private(set) var shownTexts: [String] = []
private(set) var hideReasons: [String] = []
+ /// Records slide attempts (and declines them, like the protocol default) so tests can assert
+ /// which accept paths even try to slide versus re-anchor through a present.
+ private(set) var advanceInlineCalls: [(remaining: String, inserted: String)] = []
init(state: OverlayState = .hidden(reason: "initial")) {
self.state = state
}
+ func advanceInline(to remainingText: String, insertedText: String) -> Bool {
+ advanceInlineCalls.append((remainingText, insertedText))
+ return false
+ }
+
func showSuggestion(_ text: String, geometry: SuggestionOverlayGeometry) {
shownTexts.append(text)
state = .visible(text: text, geometry: geometry, mode: .inline)
diff --git a/CotabbyTests/SuggestionOverlayStabilityGateTests.swift b/CotabbyTests/SuggestionOverlayStabilityGateTests.swift
index 5f4c5837..3c3de16f 100644
--- a/CotabbyTests/SuggestionOverlayStabilityGateTests.swift
+++ b/CotabbyTests/SuggestionOverlayStabilityGateTests.swift
@@ -18,12 +18,13 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase {
private static func geometry(
caretRect: CGRect = caretRect,
inputFrameRect: CGRect? = inputFrame,
+ caretQuality: CaretGeometryQuality = .exact,
focusChangeSequence: UInt64 = 7
) -> SuggestionOverlayGeometry {
SuggestionOverlayGeometry(
caretRect: caretRect,
inputFrameRect: inputFrameRect,
- caretQuality: .exact,
+ caretQuality: caretQuality,
observedCharWidth: 8,
isRightToLeft: false,
focusChangeSequence: focusChangeSequence
@@ -330,4 +331,71 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase {
)
)
}
+
+ // MARK: - Layout-estimated anchors (TextKit mirror hosts)
+
+ func test_layoutEstimatedAnchor_ignoresRawCaretDrift() {
+ // The held anchor came from the hidden text layout; fresh snapshots still carry the RAW
+ // resolver caret (an AXFrame proportional guess), which routinely sits a word or more
+ // away. Treating that gap as drift re-presented and re-estimated on every reconcile
+ // tick, and around accepts it was the jerk-left-then-back: with text and field unchanged
+ // the estimate cannot move, so the gate must hold.
+ let current: OverlayState = .visible(
+ text: " again",
+ geometry: Self.geometry(
+ caretRect: CGRect(x: 320, y: 210, width: 2, height: 18),
+ caretQuality: .layoutEstimated
+ ),
+ mode: .inline
+ )
+
+ XCTAssertFalse(
+ SuggestionOverlayStabilityGate.shouldRePresent(
+ currentOverlay: current,
+ newText: " again",
+ newCaretRect: Self.caretRect,
+ newInputFrameRect: Self.inputFrame,
+ newFocusChangeSequence: 7
+ )
+ )
+ }
+
+ func test_layoutEstimatedAnchor_stillReAnchorsOnFrameTextOrFieldChange() {
+ // The estimate is a pure function of (text, field frame, style): when one of its real
+ // inputs changes, or the field itself does, the re-anchor must still happen.
+ let current: OverlayState = .visible(
+ text: " again",
+ geometry: Self.geometry(caretQuality: .layoutEstimated),
+ mode: .inline
+ )
+
+ XCTAssertTrue(
+ SuggestionOverlayStabilityGate.shouldRePresent(
+ currentOverlay: current,
+ newText: " again",
+ newCaretRect: Self.caretRect,
+ newInputFrameRect: Self.inputFrame.offsetBy(dx: 40, dy: 0),
+ newFocusChangeSequence: 7
+ ),
+ "A field-frame move re-positions the estimate and must re-anchor"
+ )
+ XCTAssertTrue(
+ SuggestionOverlayStabilityGate.shouldRePresent(
+ currentOverlay: current,
+ newText: " different",
+ newCaretRect: Self.caretRect,
+ newInputFrameRect: Self.inputFrame,
+ newFocusChangeSequence: 7
+ )
+ )
+ XCTAssertTrue(
+ SuggestionOverlayStabilityGate.shouldRePresent(
+ currentOverlay: current,
+ newText: " again",
+ newCaretRect: Self.caretRect,
+ newInputFrameRect: Self.inputFrame,
+ newFocusChangeSequence: 8
+ )
+ )
+ }
}