Fix rapid Tab leaking into the host after a final-word accept#484
Open
FuJacob wants to merge 2 commits into
Open
Fix rapid Tab leaking into the host after a final-word accept#484FuJacob wants to merge 2 commits into
FuJacob wants to merge 2 commits into
Conversation
Accepting the last word of a suggestion exhausts the buffer, which clears the session and hides the overlay synchronously and then regenerates the continuation asynchronously. A fast follow-up Tab in that gap found no session and no visible overlay, so the accept tap forwarded it to the host as a real Tab and focus jumped out of the field. Slow Tabbing was fine because regeneration finished first. Keep the accept tap owning Tab through the regeneration window: swallow a fast follow-up press instead of leaking it, queue it, and accept the next continuation's first word when it lands so rapid Tabbing keeps inserting words. The window self-releases when the next suggestion shows, when any teardown hides the overlay, or via a token-keyed backstop so Tab can never be trapped.
Addresses Greptile review on #484: - Bump the generation token whenever the window ends (new clearPostExhaustionAcceptanceWindow helper, used by the overlay-hidden teardown, the flush, and the backstop release) so a fast normal release cancels the pending 0.8s backstop instead of letting it fire later as a redundant no-op. - Log a 'flush-queued-accept-failed' stage when a flushed queued accept returns false, so a Tab that was swallowed during the window but failed on the follow-up acceptance is diagnosable instead of vanishing silently.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Rapid
Tabpresses leaked into the focused app and moved focus out of the field. Accepting the last word of a suggestion exhausts the buffer, which synchronously clears the session and hides the overlay and then regenerates the continuation asynchronously. A fast secondTablanding in that gap found no active session and no visible overlay, so the accept-tap preflight (which keys on overlay visibility) declined and the tap forwarded the originalTabto the host as a real key. Slow tabbing was always fine because regeneration finished and re-showed ghost text before the next press. Short completions (maxPredictionTokens: 8) make the exhausting boundary common, so the second quickTabwas routinely the one that leaked.This keeps the accept tap owning
Tabacross the regeneration window: a fast follow-up press is swallowed (never forwarded to the host) and queued, and the next continuation that lands accepts its first word, so rapid Tabbing keeps inserting words across the exhaustion boundary instead of jumping focus away.Validation
Three new tests in
SuggestionCoordinatorAcceptanceTestslock down the fix:test_rapidSecondAcceptDuringRegenerationIsConsumedNotPassedThrough: a fast secondTabduring regeneration is consumed (returnstrue) and queued, never forwarded to the host.test_postExhaustionWindowReleasesAcceptKeyWhenOverlayHides: any teardown that hides the overlay releases the window, soTabcan never be trapped in the field.test_queuedPostExhaustionAcceptInsertsNextWordWhenContinuationArrives: the queuedTabaccepts the continuation's first word once it lands.Not verified end to end in this environment (no live rapid-
Tabreproduction against a real text field); the behavior is covered at the coordinator boundary by the unit tests above.Linked issues
Refs #478 (addresses item 2, "tabs jump off" on fast double-
Tab; the optional-cat-image and suggestion-quality items in that issue are out of scope here).Risk / rollout notes
Tabduring the brief regeneration window instead of releasing it immediately.0.8sbackstop, so it cannot trapTabor persist when idle.Tabaccepts the next continuation's first word as soon as it generates, so the second word appears slightly after the keypress (it does not exist until the model produces it). Bounded to one queued accept per regeneration so mashingTabcannot run away.project.yml/pbxproj changes. New source and test files are auto-discovered by folder.Greptile Summary
This PR fixes the "rapid Tab jumps focus out of the field" bug: accepting the last buffered word tears down the session and regenerates asynchronously, leaving a gap where a fast follow-up Tab found no active session and no visible overlay, so the accept tap forwarded it to the host as a real key. The fix keeps the coordinator owning Tab across that regeneration gap, queues any Tab that arrives in it, and flushes the queued accept as the continuation's first word once the new suggestion is on screen.
isPostExhaustionAcceptanceArmed,hasQueuedPostExhaustionAccept,postExhaustionAcceptanceGeneration) drive the window; a generation-keyed 0.8 s backstop timer guarantees Tab can never be permanently trapped even if regen stalls.armPostExhaustionAcceptance()is called in the.exhaustedbranch afterhideOverlay()so it always follows the.hiddenstate callback that clears the previous window;clearPostExhaustionAcceptanceWindow()is wired into every teardown path through the singleonStateChange(.hidden)catch-all.Confidence Score: 5/5
Safe to merge. The change is well-scoped to the coordinator boundary, has a hard-bounded backstop that prevents any Tab-trapping regression, and the new state is cleaned up through a single, authoritative teardown path.
The fix is mechanically sound: the ordering invariant (arm always follows a clear from the
.hiddencallback) is preserved and documented, the generation token correctly invalidates stale backstop timers, and all known teardown paths reachclearPostExhaustionAcceptanceWindowthroughonStateChange(.hidden). The only observation is a missing log when an in-flight queued Tab is silently dropped by an early-return path inapply()(stale echo, empty result, etc.), which affects debuggability but not correctness.No files require special attention.
Important Files Changed
armPostExhaustionAcceptance()call after the.exhaustedbranch'shideOverlay, correctly ordering it after the.hiddenstate change so re-arming always follows a clean clear.shouldConsumeAcceptKeyProviderto also returntruewhileisPostExhaustionAcceptanceArmed; clears the window on every.hiddenoverlay state transition as the single catch-all teardown path.flushQueuedPostExhaustionAcceptIfNeeded()at the very end ofapply()'s success path, after the new session and overlay are live — correct placement since any earlier call would race withstartSession.Sequence Diagram
sequenceDiagram participant User participant InputMonitor participant Coordinator participant OverlayController participant Engine Note over User,Engine: Slow Tab (no issue — regen finishes before next press) User->>InputMonitor: Tab (final word) InputMonitor->>Coordinator: acceptCurrentSuggestion() Coordinator->>OverlayController: hideOverlay → onStateChange(.hidden) OverlayController-->>Coordinator: clearPostExhaustionAcceptanceWindow() Coordinator->>Coordinator: armPostExhaustionAcceptance() [re-asserts interception] Coordinator->>Engine: schedulePredictionAfterHostPublishDelay() Engine-->>Coordinator: apply(result) → presentOverlay → flushQueuedPostExhaustionAcceptIfNeeded() Note over Coordinator: No queued Tab → window cleared, normal flow resumes User->>InputMonitor: Tab (next word, overlay visible) InputMonitor->>Coordinator: acceptCurrentSuggestion() → inserts next word Note over User,Engine: Rapid Tab (the bug this PR fixes) User->>InputMonitor: Tab (final word) InputMonitor->>Coordinator: acceptCurrentSuggestion() Coordinator->>OverlayController: hideOverlay → onStateChange(.hidden) OverlayController-->>Coordinator: clearPostExhaustionAcceptanceWindow() Coordinator->>Coordinator: "armPostExhaustionAcceptance() [isPostExhaustionAcceptanceArmed=true]" Coordinator->>Engine: schedulePredictionAfterHostPublishDelay() User->>InputMonitor: Tab (rapid, regen in flight) InputMonitor->>Coordinator: shouldConsumeAcceptKeyProvider() → true (armed) InputMonitor->>Coordinator: acceptCurrentSuggestion() Note over Coordinator: No active session, but isPostExhaustionAcceptanceArmed=true Coordinator->>Coordinator: "hasQueuedPostExhaustionAccept=true, return true (swallowed)" Engine-->>Coordinator: apply(result) → presentOverlay Coordinator->>Coordinator: flushQueuedPostExhaustionAcceptIfNeeded() Coordinator->>Coordinator: clearPostExhaustionAcceptanceWindow() Coordinator->>Coordinator: acceptCurrentSuggestion() → inserts first word of continuationReviews (2): Last reviewed commit: "Cancel post-exhaustion backstop on relea..." | Re-trigger Greptile