Skip to content

Fix double space on accept by reconciling the word boundary at insert time#303

Merged
FuJacob merged 1 commit into
mainfrom
fix/accept-boundary-whitespace
May 27, 2026
Merged

Fix double space on accept by reconciling the word boundary at insert time#303
FuJacob merged 1 commit into
mainfrom
fix/accept-boundary-whitespace

Conversation

@FuJacob

@FuJacob FuJacob commented May 27, 2026

Copy link
Copy Markdown
Owner

Summary

Accepting a suggestion sometimes left extra spaces between the user's word and the accepted word.
The boundary space was decided once at generation time (SuggestionTextNormalizer, lines 90-92)
against a prefix snapshot and then inserted verbatim — so when that snapshot went stale by accept
time (almost always because the user typed the separating space themselves after the ghost appeared,
or AX reported the prefix before the space landed), the field's trailing space and the suggestion's
leading space both survived. The session reconciler only canceled this opportunistically, so it
slipped through on a timing race.

This moves the boundary decision to accept time, where it can be made deterministically against
the live field: SuggestionSessionReconciler.insertionChunk(forAcceptedChunk:precedingText:) drops a
chunk's leading whitespace run when the live preceding text already ends in whitespace, and
acceptSuggestion uses it against liveContext.precedingText. The session still advances by the full
accepted chunk — the whitespace we skip typing is the field's own, so the post-insertion
consumed-suffix accounting is unchanged — and caret prediction advances by what was actually typed.

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 test -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' \
  -only-testing:CotabbyTests/SuggestionSessionReconcilerTests CODE_SIGNING_ALLOWED=NO
# Executed 35 tests, with 0 failures   (7 new insertionChunk cases)
swiftlint lint --quiet --config .swiftlint.yml <the 3 changed files>
# exit 0

New tests cover: drop leading space when the field already ends in whitespace; keep it when it
doesn't; collapse a whole leading run; leave a no-leading-space chunk untouched; tab counts as
boundary whitespace but a newline does not; inter-word spaces mid-suggestion survive; empty preceding
text is a no-op. App-hosted test only runs with CODE_SIGNING_ALLOWED=NO here (local Team ID
mismatch, per CLAUDE.md); I ran the affected unit suite, not the whole target.

Linked issues

None — surfaced while investigating the recurring double-space-on-accept report.

Risk / rollout notes

  • The generation-time space handling in SuggestionTextNormalizer is intentionally left in place as a
    first pass for the ghost-text preview; the accept-time check is now the authoritative one for what
    actually gets typed.
  • Accounting is exact in the dominant case (field has one trailing space, chunk has one leading
    space). In the rare case of a chunk with more leading whitespace than the field supplies (a model
    emitting multiple internal spaces, which the normalizer doesn't collapse), the result is still a
    single clean boundary but the session's consumed count can sit one ahead of the field for that
    cycle — which only triggers the existing post-insertion lag tolerance (an overlay refresh), never a
    double space. Collapsing multi-space model output is a separate, pre-existing normalizer concern.
  • No change to the reconciler's own logic, so existing reconcile/acceptance tests are unaffected.
  • Does not address the inverse (missing space when the user deletes their trailing space after
    generation); that needs the suggestion to carry the model's original leading-whitespace intent and
    is a larger change.

Greptile Summary

This PR fixes the double-space-on-accept bug by moving the word-boundary space decision from generation time to accept time. Instead of trusting the leading space baked into a suggestion chunk (which could be stale if the user typed the separating space after the ghost appeared), the new insertionChunk(forAcceptedChunk:precedingText:) helper trims the chunk's leading whitespace against the live preceding text right before the keystroke is synthesized.

  • New insertionChunk method in SuggestionSessionReconciler strips leading horizontal whitespace from the chunk when the live field already ends in whitespace, and returns the chunk unchanged otherwise; session accounting continues to advance by the full untrimmed acceptedChunk, keeping consumed-suffix reconciliation correct.
  • acceptSuggestion in SuggestionCoordinator+Acceptance now inserts insertionChunk (trimmed) instead of acceptedChunk, skips the keystroke entirely for empty results (all-whitespace chunks), and uses insertionChunk for caret-position prediction.
  • Seven new unit tests cover all documented cases: drop on trailing space, keep on no trailing space, multi-space collapse, tab-as-boundary, newline-as-non-boundary, inter-word space survival, and empty preceding text.

Confidence Score: 4/5

Safe to merge; the fix is well-scoped and session accounting is unaffected. One small whitespace-definition mismatch in the new helper warrants a look before shipping to wider audiences.

The acceptance path, caret prediction, and consumed-suffix reconciliation all look correct. The only concern is that the guard in insertionChunk uses CharacterSet.whitespaces (horizontal only) to decide whether to trim, but the drop(while:) closure uses Character.isWhitespace, which also matches vertical whitespace like . In the normal word-by-word acceptance path this difference is harmless, but a full-accept chunk that begins with a newline could have that newline silently stripped when the preceding text ends with a space or tab.

The insertionChunk method in SuggestionSessionReconciler.swift — specifically the drop(while:) predicate on line 303.

Important Files Changed

Filename Overview
Cotabby/Support/SuggestionSessionReconciler.swift Adds insertionChunk(forAcceptedChunk:precedingText:) — drops leading whitespace from a chunk when the live preceding text already ends in horizontal whitespace. Minor semantic mismatch: guard uses CharacterSet.whitespaces (excludes newlines) but drop(while:) uses Character.isWhitespace (includes newlines), which could silently drop a leading newline in a full-accept scenario.
Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift Wires insertionChunk into the acceptance path: inserts insertionChunk (trimmed) instead of acceptedChunk, skips the keystroke entirely when the result is empty, and updates caret prediction to use insertionChunk. Session advancement and word-count accounting correctly keep using acceptedChunk, so consumed-suffix reconciliation is unaffected.
CotabbyTests/SuggestionSessionReconcilerTests.swift Adds 7 focused unit tests for insertionChunk: drop on trailing space, keep on no trailing space, collapse multi-space run, untouched no-leading-space chunk, tab as boundary / newline as non-boundary, inter-word space preservation, and empty preceding text. Coverage is thorough for the documented behaviour.

Sequence Diagram

sequenceDiagram
    participant User
    participant Coordinator as SuggestionCoordinator
    participant Reconciler as SuggestionSessionReconciler
    participant Inserter as SuggestionInserter
    participant Session as ActiveSuggestionSession

    User->>Coordinator: Tab pressed (accept)
    Coordinator->>Coordinator: prepareAcceptance() → acceptedChunk (e.g. " you")
    Coordinator->>Reconciler: insertionChunk(forAcceptedChunk: " you", precedingText: "How are ")
    Reconciler-->>Coordinator: "you" (leading space dropped — field already ends in space)
    alt insertionChunk is non-empty
        Coordinator->>Inserter: insert("you")
        Inserter-->>Coordinator: success
    else insertionChunk is empty
        Note over Coordinator: Skip keystroke — field already supplies the whitespace
    end
    Coordinator->>Session: commitAcceptedChunk(" you") [full chunk — accounting unchanged]
    Session-->>Coordinator: .advanced / .exhausted
    Coordinator->>Coordinator: predictedCaretRect(after: "you") [trimmed chunk for caret shift]
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Fix double space on accept by reconcilin..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

… time

The suggestion's leading boundary space was decided once at generation time (SuggestionTextNormalizer) against a prefix snapshot, then inserted verbatim. When that snapshot went stale — overwhelmingly because the user typed the separating space themselves after the ghost appeared, or AX reported the prefix before the space landed — the field's trailing space and the suggestion's leading space both survived, producing a double space. The session reconciler only canceled this opportunistically, so it slipped through on a timing race.

Add SuggestionSessionReconciler.insertionChunk(forAcceptedChunk:precedingText:), which drops a chunk's leading whitespace run when the live preceding text already ends in whitespace, and call it in acceptSuggestion against liveContext.precedingText. The session still advances by the full accepted chunk (the skipped whitespace is the field's own, so consumed-suffix accounting is unchanged); caret prediction advances by what was actually typed.
return chunk
}

return String(chunk.drop(while: { $0.isWhitespace }))

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 guard deliberately uses CharacterSet.whitespaces (horizontal whitespace only — space and tab) to exclude newlines from triggering the boundary trim, which the test comment explicitly confirms ("Newlines are not horizontal whitespace"). However, the drop(while:) closure uses Character.isWhitespace, which also matches , , , and other vertical whitespace. If a chunk ever starts with a newline (e.g. a full-accept spanning a line break where the first character is ), and the preceding text ends with a space or tab, the guard passes and the newline gets silently dropped — potentially corrupting the suggestion's structure. The drop predicate should mirror the guard's definition for consistent behaviour.

Suggested change
return String(chunk.drop(while: { $0.isWhitespace }))
return String(chunk.drop(while: { $0.unicodeScalars.allSatisfy { CharacterSet.whitespaces.contains($0) } }))

Fix in Codex Fix in Claude Code

@FuJacob FuJacob merged commit b03c8fd into main May 27, 2026
4 checks passed
@FuJacob FuJacob deleted the fix/accept-boundary-whitespace branch May 27, 2026 07:01
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