Skip to content

Fix final Tab acceptance routing#387

Open
FuJacob wants to merge 1 commit into
mainfrom
codex/fix-tab-acceptance-routing
Open

Fix final Tab acceptance routing#387
FuJacob wants to merge 1 commit into
mainfrom
codex/fix-tab-acceptance-routing

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented May 28, 2026

Summary

Route accept-key handling through the active consuming event tap so insertion and original Tab suppression are decided in the same callback. This fixes the final-token case where accepting a punctuation-only tail could hide/exhaust the suggestion before the accept tap decided whether to swallow the original Tab.

Also removes the synthetic replay fallback and aligns accept preflight with the actual user-visible invariant: a visible overlay with an active session, even if background refresh work has moved coordinator state out of .ready.

Validation

  • xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' build
    • ** BUILD SUCCEEDED **
  • xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' build-for-testing
    • ** TEST BUILD SUCCEEDED **
  • xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' test -only-testing:CotabbyTests/InputMonitorTests
    • Build completed, but test execution was blocked before tests ran because the app-hosted CotabbyTests.xctest bundle could not be loaded: mapping process and mapped file (non-platform) have different Team IDs.

Linked issues

  • None.

Risk / rollout notes

  • Acceptance now does coordinator validation inside the consuming event-tap callback, but only while ghost text is visible and only for configured accept keys. That tradeoff keeps insertion and original-key suppression atomic, which is the important correctness boundary for this bug.

Greptile Summary

This PR fixes the final-token Tab acceptance bug by making the consuming accept tap the sole owner of both insertion and original-key suppression, eliminating the race where the observer tap could hide the overlay before the accept tap decided whether to swallow the Tab. The synthetic replay path (replayConsumedAcceptKey) is also removed since a false return from onEvent now naturally passes the original event through without any re-posting.

  • handleAcceptTap now constructs the CapturedInputEvent itself and calls onEvent directly; the result (true = consume, false = pass through) is used atomically to decide the callback's return value.
  • handleObserverTap explicitly skips acceptance events with a new isAcceptance guard, keeping the observer strictly listen-only for all non-acceptance keys.
  • shouldConsumeAcceptKeyProvider drops the .ready state requirement, allowing acceptance while a background OCR refresh has moved state to .debouncing — the full AX/session validation inside acceptSuggestion still rejects stale sessions.

Confidence Score: 4/5

Safe to merge; the fix correctly unifies insertion and Tab suppression into a single atomic callback, and the relaxed preflight still has a full AX/session validation backstop inside acceptSuggestion.

The architectural change is well-reasoned and the new design eliminates the race it targets. The two findings are style/efficiency issues only — a minor classify() call that runs unnecessarily for every Tab event, and concatenated doc-comments on the two tap handlers. No logic correctness concerns were identified.

InputMonitor.swift deserves a second look for the classify() placement and the doc-comment concatenation; all other files are straightforward parameter removals and routing simplifications.

Important Files Changed

Filename Overview
Cotabby/Services/Input/InputMonitor.swift Core architectural change: acceptance now flows entirely through the consuming accept tap rather than the listen-only observer. Observer explicitly skips acceptance events; accept tap creates CapturedInputEvent and calls onEvent directly. replayConsumedAcceptKey removed. handleObserverTap/handleAcceptTap promoted to internal for testability. Minor: classify() is still called in handleObserverTap before the isAcceptance guard, doing work that is immediately discarded for every Tab keystroke.
Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift acceptCurrentSuggestion/acceptEntireSuggestion/acceptSuggestion lose originalEvent parameter; passTabThrough loses replay parameter and the replayConsumedAcceptKey call. Return values (true/false) now carry the consume-or-pass-through signal back to the accept tap callback.
Cotabby/App/Coordinators/SuggestionCoordinator.swift shouldConsumeAcceptKeyProvider drops the state == .ready guard, now gating only on activeSession != nil and overlayState.isVisible. Intentional: a background OCR refresh can move state to .debouncing while ghost text remains valid.
Cotabby/Models/SuggestionSubsystemContracts.swift replayConsumedAcceptKey removed from SuggestionInputMonitoring protocol; docstring for shouldConsumeAcceptKeyProvider updated to reflect the relaxed preflight semantics.
CotabbyTests/InputMonitorTests.swift New test file covering observer-tap/accept-tap ownership invariants. Tests verify observer skips onEvent for Tab, accept tap consumes when coordinator accepts, passes through when coordinator declines, and stale accept taps never invoke onEvent.
Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift acceptCurrentSuggestion() and acceptEntireSuggestion() calls updated to drop the originalEvent parameter; all other routing logic unchanged.

Sequence Diagram

sequenceDiagram
    participant User as User (Tab key)
    participant ObsTap as Observer Tap (head, listen-only)
    participant AccTap as Accept Tap (tail, consuming)
    participant Coord as SuggestionCoordinator
    participant App as Focused App

    User->>ObsTap: keyDown (Tab)
    ObsTap->>ObsTap: classify() → .acceptance
    ObsTap->>ObsTap: guard !isAcceptance → skip onEvent
    ObsTap-->>AccTap: event passes through (listen-only)

    AccTap->>AccTap: matches acceptanceKeyCode?
    AccTap->>AccTap: shouldConsumeAcceptKeyProvider()

    alt Preflight fails (stale tap / no session)
        AccTap-->>App: passUnretained(event) — Tab reaches app
    else Preflight passes
        AccTap->>Coord: onEvent(CapturedInputEvent(.acceptance))
        Coord->>Coord: handleInputEvent → acceptCurrentSuggestion()

        alt Acceptance succeeds
            Coord-->>AccTap: return true
            AccTap-->>App: return nil (Tab consumed)
        else Acceptance declined (passTabThrough)
            Coord->>Coord: clearSuggestion + hideOverlay → destroyAcceptTap
            Coord-->>AccTap: return false
            AccTap-->>App: passUnretained(event) — Tab reaches app
        end
    end
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Fix final Tab acceptance routing" | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Comment on lines 247 to 255
let capturedEvent = classify(event: event)
guard !capturedEvent.kind.isAcceptance else {
// Acceptance is handled by the active default tap, because only that callback can
// make insertion and "consume the original key" one atomic decision. If the active
// tap is absent, the Tab naturally belongs to the focused app.
return Unmanaged.passUnretained(event)
}

_ = onEvent?(capturedEvent)
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 The isAcceptance guard fires after classify() has already done its work — reading provider closures, comparing key codes, and constructing a CapturedInputEvent. Because every Tab keystroke reaches both taps and the observer always skips acceptance events, this is wasted per-keystroke classification work on a hot path. Moving the comment before classify() avoids the allocation for the one event class that most frequently arrives while the overlay is visible.

Suggested change
let capturedEvent = classify(event: event)
guard !capturedEvent.kind.isAcceptance else {
// Acceptance is handled by the active default tap, because only that callback can
// make insertion and "consume the original key" one atomic decision. If the active
// tap is absent, the Tab naturally belongs to the focused app.
return Unmanaged.passUnretained(event)
}
_ = onEvent?(capturedEvent)
// Acceptance is handled by the active default tap, because only that callback can
// make insertion and "consume the original key" one atomic decision. If the active
// tap is absent, the Tab naturally belongs to the focused app.
let capturedEvent = classify(event: event)
guard !capturedEvent.kind.isAcceptance else {
return Unmanaged.passUnretained(event)
}
_ = onEvent?(capturedEvent)

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

Comment on lines 220 to 228
/// Listen-only observer: classifies the event and notifies the coordinator. The return value
/// of `onEvent` is ignored here because a listen-only tap cannot drop or modify events.
/// Consumption of the accept key is handled by the separate active accept tap.
private func handleObserverTap(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
/// Handles the listen-only tap callback. Internal so tests can lock down which tap owns
/// acceptance without installing real global event taps.
func handleObserverTap(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
switch type {
case .tapDisabledByTimeout, .tapDisabledByUserInput:
CotabbyLogger.app.warning("Observer tap was disabled by system, re-enabling")
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 Concatenated doc-comments on both tap handlers

handleObserverTap and handleAcceptTap each have two independent doc-comment paragraphs pasted together without a blank /// separator — the original functional description followed immediately by the new "Internal so tests can…" sentence. Adding a blank /// line between them or merging into a single coherent paragraph would fix the readability break. The same issue exists for handleAcceptTap at its corresponding location.

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.

1 participant