Skip to content

Keep ghost text perfectly still through word accepts (TextEdit jitter)#690

Merged
FuJacob merged 1 commit into
mainfrom
fix/textedit-accept-jitter
Jun 12, 2026
Merged

Keep ghost text perfectly still through word accepts (TextEdit jitter)#690
FuJacob merged 1 commit into
mainfrom
fix/textedit-accept-jitter

Conversation

@FuJacob

@FuJacob FuJacob commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Summary

Accepting a word in TextEdit sometimes jumped the remaining ghost text LEFT by roughly the accepted word's width, then snapped it back RIGHT a few dozen milliseconds later. A four-way investigation (full pipeline trace, overlay-layer audit, reconcile/prediction math with measured font widths, and structured-log mining) converged on two motion sources:

  1. The big bounce (intermittent): the +30ms post-insertion refresh races the host's processing of the synthetic keystroke. When TextEdit loses the race, the snapshot carries the PRE-insertion caret. The session reconciler correctly tolerates the lag, but presentation discarded that fact and ran SuggestionOverlayStabilityGate against the stale caret: a full word of "drift" blows past the 6pt tolerance, so the gate re-anchored the ghost onto the just-accepted text (left), and the next 80ms poll re-anchored it onto the published caret (right). The gate never consulted isAwaitingPostInsertionSync, the precise signal that the geometry is suspect.
  2. The residual nudge (systematic): the accept-time slide measured the consumed text in the ghost render font, which is floored at 14pt for legibility, while TextEdit renders Helvetica 12 (rich text) or Menlo 11 (plain text). Measured on this machine, that overshoots the real caret travel by 5.3pt (Helvetica) to 10.9pt (Menlo) per accepted word; the error accumulates in the anchor until the gate fires a sideways correction with no input in flight (every second accept for Helvetica, every accept for Menlo). The slide-fallback path was worse, measuring with a fixed system-14 font (+23% vs Helvetica 12).

The fix addresses each at its source:

  • SuggestionOverlayStabilityGate.shouldRePresent gains isAwaitingPostInsertionSync and holds geometry outright while the host has not published the insert (same field, same text). The hold lifts on the exact reconcile tick that observes the publish, because the interaction state clears the sentinel before presentation reads it. Field switches and text changes still re-anchor mid-window.
  • New pure InsertedTextAdvance measures the caret's true travel for inserted text using the field's own resolved font. advanceInline uses it for trusted .exact geometry (web/derived hosts keep the existing pixel-identical ghost-width slide and their observedCharWidth machinery), and predictedCaretRect's fallback prefers it over the system-14 approximation. With the anchor matching the published caret within kerning noise, post-accept reconciles have nothing left to correct: any residual few-point tail shift now happens at the accept keystroke itself, where the text is visibly changing anyway, and nothing moves afterward.

Validation

xcodebuild test -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' \
  -skip-testing:CotabbyTests/FoundationModelDriftEvalTests \
  CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO
# ** TEST SUCCEEDED ** 1466 tests, 0 failures, 0 crashes

swiftlint lint --quiet
# 0 findings on the files this PR touches

New regression locks:

  • Gate: a word-width caret drift during the sync window holds; the same drift after the publish re-anchors (the legitimate settle); field/text changes re-anchor even mid-window.
  • Coordinator (recording rig): a full accept followed by a pre-publish reconcile produces zero re-presents, the sentinel survives the tolerated tick, and the publish lifts the hold.
  • InsertedTextAdvance: host-face/size measurement, the documented 12pt-vs-14pt per-word delta (>3pt), leading-space travel, unknown-face fallback at the host's size, and unusable-input refusals.
  • predictedCaretRect prefers the host font over the system-14 fallback.

Not verified live in a TextEdit typing session (the logs show TextEdit has never run under -cotabby-debug on this machine); the mechanism is pinned by code-level tracing with measured font arithmetic, and both motion sources are locked by differential tests that fail on the previous behavior.

Linked issues

None filed; reported directly ("fragile... jitters left then jitters back to the right after accepting a word" in TextEdit).

Risk / rollout notes

  • The gate hold is scoped tightly: it requires an unpublished synthetic insert AND unchanged field AND unchanged text, and lasts at most one or two poll ticks (the sentinel clears the moment AX catches up). A window drag inside that sub-100ms window settles one tick later than before.
  • Host-font slide math is gated to .exact caret quality, so Gmail/Outlook-class derived hosts keep today's slide behavior bit-for-bit.
  • advanceInline gained an insertedText parameter (protocol + default); the only behavioral callers are word-accept and type-through advancement.
  • If a host swallows the insert entirely, the sentinel-driven hold ends the same way it does today: the session invalidates through the normal divergence rules.

Greptile Summary

This PR eliminates two sources of ghost-text jitter that triggered when accepting a word in TextEdit (and similar apps): a timing-race where the +30ms post-insertion AX refresh reads the pre-insertion caret and re-anchors the overlay left, and a systematic font-size mismatch where the ghost render font (floored at 14pt) measured accepted text wider than the host's 12pt Helvetica, accumulating anchor error past the stability gate's tolerance.

  • SuggestionOverlayStabilityGate.shouldRePresent gains an isAwaitingPostInsertionSync short-circuit that holds geometry for the same field and text while the host has not yet published the synthetic insert; field switches and text changes still re-anchor immediately through checks that remain ahead of the new guard.
  • New InsertedTextAdvance utility measures the host field's true caret travel using its AX-resolved font; advanceInline uses it for .exact-quality geometry (the pixel-correct slide), and predictedCaretRect's fallback prefers it over the previous system-14 approximation.
  • Web/derived hosts and observedCharWidth hosts are explicitly kept on their existing paths, so only the targeted TextEdit-class scenario changes behavior.

Confidence Score: 4/5

Safe to merge; the changes are tightly scoped, well-tested, and fallback paths keep existing host types on their previous behavior.

Both motion sources are addressed at their actual root cause, test coverage is thorough across unit and integration levels, and the behavioral gating correctly limits the new code to the intended case. The only gaps are that the integration test exercises the ghost-font fallback rather than the new host-font slide, and a width > 0 guard is dead code — neither affects correctness.

CotabbyTests/SuggestionCoordinatorPredictionTests.swift — the integration test would be more complete with a resolvedFieldStyle-bearing overlay geometry so the host-font slide path is exercised end-to-end.

Important Files Changed

Filename Overview
Cotabby/Support/InsertedTextAdvance.swift New utility measuring host-font caret advance for accepted text; clean, well-documented, and guarded against nil/zero/infinite widths.
Cotabby/Support/SuggestionOverlayStabilityGate.swift Adds isAwaitingPostInsertionSync guard after field-switch and text-change checks, correctly ordering the short-circuit so field switches and text changes still re-anchor even during the sync window.
Cotabby/Services/UI/OverlayController.swift advanceInline now takes insertedText and uses host-font width for .exact-quality geometry, falling back to ghost-font diff for web/derived hosts; logic is sound.
Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift Passes insertedText and fieldStyle through the acceptance and type-through paths; fieldStyle is now included in predictedCaretRect for the non-slide fallback, improving accuracy.
Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift Threads isAwaitingPostInsertionSync from interactionState into the stability gate call; minimal, correctly placed change.
CotabbyTests/InsertedTextAdvanceTests.swift Thorough unit tests for InsertedTextAdvance: host-font measurement, 12pt vs 14pt delta, leading-space travel, unknown-face fallback, nil/zero point-size refusals, and predictedCaretRect host-font preference.
CotabbyTests/SuggestionOverlayStabilityGateTests.swift Adds three gate tests covering the sync-window hold under word-width drift, the re-anchor on field/text changes even while awaiting, and the post-publish re-anchor restoration.
CotabbyTests/SuggestionCoordinatorPredictionTests.swift New integration test verifies the full accept → pre-publish reconcile → publish cycle; uses overlayGeometry() without resolvedFieldStyle so advanceInline exercises the ghost-font fallback path rather than the new host-font slide.
Cotabby/Models/SuggestionSubsystemContracts.swift Protocol and default updated to require insertedText; default still returns false so test doubles transparently fall back to caret-anchored present without changes.

Sequence Diagram

sequenceDiagram
    participant U as User (Tab accept)
    participant SC as SuggestionCoordinator
    participant IS as InteractionState
    participant OC as OverlayController
    participant ITA as InsertedTextAdvance
    participant SOSG as StabilityGate
    participant AX as Accessibility (AX)

    U->>SC: acceptCurrentSuggestion()
    SC->>IS: "arm isAwaitingPostInsertionSync = true"
    SC->>OC: advanceInline(to: ' again', insertedText: ' world')
    OC->>ITA: width(of: ' world', style: resolvedFieldStyle)
    ITA-->>OC: host-font advance (e.g. 34pt for Helvetica 12)
    OC->>OC: "shift = hostAdvance (not ghost-font diff)"
    OC->>OC: slide panel right by shift, update state
    Note over SC,AX: +30ms post-insertion refresh (AX may not have published insert yet)
    SC->>AX: snapshot()
    AX-->>SC: pre-insertion caret (left of correct position)
    SC->>SOSG: shouldRePresent(..., isAwaitingPostInsertionSync: true)
    SOSG-->>SC: false (hold - same field, same text, sentinel armed)
    Note over OC: overlay stays perfectly still
    Note over SC,AX: Next poll - AX publishes the insert
    SC->>AX: snapshot()
    AX-->>SC: post-insertion caret (correct position)
    SC->>IS: reconcile clears isAwaitingPostInsertionSync
    SC->>SOSG: shouldRePresent(..., isAwaitingPostInsertionSync: false)
    SOSG-->>SC: "false if drift < 6pt, true only if genuine drift"
Loading

Comments Outside Diff (1)

  1. CotabbyTests/SuggestionCoordinatorPredictionTests.swift, line 475 (link)

    P2 Integration test exercises the ghost-font fallback, not the new host-font slide

    CotabbyTestFixtures.overlayGeometry() sets caretQuality: .exact but leaves resolvedFieldStyle: nil (it has no parameter for it). In advanceInline, the new host-font branch requires both geometry.caretQuality == .exact AND a non-nil InsertedTextAdvance.width, which itself requires a non-nil style. With style == nil, InsertedTextAdvance.width returns nil, so the slide falls back to the ghost-font diff path — the old behavior rather than the new fix.

    The test still correctly validates the stability-gate hold (no re-present during the sync window), but it doesn't exercise the InsertedTextAdvance-driven slide that is the core of fix Explore contextual awareness for Use My Instructions mode #2. The InsertedTextAdvanceTests cover the measurement path in isolation, and test_predictedCaretRect_prefersTheHostFontOverTheSystemFallback covers the prediction fallback, so coverage is ultimately adequate — but an overlayGeometry(resolvedFieldStyle: ...) variant here would lock the full new slide path end-to-end.

    Fix in Codex Fix in Claude Code

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(overlay): keep ghost text perfectly ..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Accepting a word in TextEdit sometimes jumped the remaining ghost left by
the accepted word's width and snapped it back right one poll tick later.
Root cause: the +30ms post-insertion refresh can read AX before the host
publishes the synthetic insert. The session reconciler tolerates that lag,
but presentation discarded the fact and ran the stability gate against the
pre-insertion caret; a full word of drift blows past the 6pt tolerance, so
the gate re-anchored the overlay onto the just-accepted text, and the
post-publish poll bounced it back.

Two changes, one per motion source:
- The stability gate now holds geometry outright while the interaction
  state is awaiting the post-insertion AX sync (same field, same text).
  Stale pre-publish geometry is never worth re-anchoring to, and the hold
  lifts on the exact tick the publish lands.
- The accept-time slide and the predicted-caret fallback now measure the
  caret's true travel with the field's own resolved font instead of the
  ghost render font (floored at 14pt) or a fixed system-14 approximation.
  Helvetica-12 hosts were overshooting 5pt per word and Menlo-11 hosts
  11pt, accumulating past the gate tolerance and surfacing as a sideways
  nudge with no input in flight. With the travel measured in the host's
  font, the anchor matches the published caret within kerning noise and
  post-accept reconciles have nothing left to correct.
@FuJacob FuJacob merged commit 86f17f9 into main Jun 12, 2026
4 checks passed
Comment on lines +31 to +32
guard width.isFinite, width > 0 else {
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 width > 0 guard is redundant given NSString.size advance-width semantics

NSString.size(withAttributes:) returns the typographic advance width of the string, which is positive for any non-empty string including pure whitespace (confirmed: a single space returns its glyph advance, not 0). The guard width > 0 cannot fire for any input that passed the !text.isEmpty check, so it is dead code in practice. Removing it would make the boundary condition explicit in the guard above (!text.isEmpty) and remove a subtle implication that whitespace might somehow return zero. Separately, the width.isFinite check is still useful for pathological custom fonts.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explore contextual awareness for Use My Instructions mode

1 participant