Fix double space on accept by reconciling the word boundary at insert time#303
Conversation
… 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 })) |
There was a problem hiding this comment.
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.
| return String(chunk.drop(while: { $0.isWhitespace })) | |
| return String(chunk.drop(while: { $0.unicodeScalars.allSatisfy { CharacterSet.whitespaces.contains($0) } })) |
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 achunk's leading whitespace run when the live preceding text already ends in whitespace, and
acceptSuggestionuses it againstliveContext.precedingText. The session still advances by the fullaccepted 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
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
testonly runs withCODE_SIGNING_ALLOWED=NOhere (local Team IDmismatch, 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
SuggestionTextNormalizeris intentionally left in place as afirst pass for the ghost-text preview; the accept-time check is now the authoritative one for what
actually gets typed.
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.
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.insertionChunkmethod inSuggestionSessionReconcilerstrips 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 untrimmedacceptedChunk, keeping consumed-suffix reconciliation correct.acceptSuggestioninSuggestionCoordinator+Acceptancenow insertsinsertionChunk(trimmed) instead ofacceptedChunk, skips the keystroke entirely for empty results (all-whitespace chunks), and usesinsertionChunkfor caret-position prediction.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
insertionChunkusesCharacterSet.whitespaces(horizontal only) to decide whether to trim, but thedrop(while:)closure usesCharacter.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
insertionChunkmethod inSuggestionSessionReconciler.swift— specifically thedrop(while:)predicate on line 303.Important Files Changed
insertionChunk(forAcceptedChunk:precedingText:)— drops leading whitespace from a chunk when the live preceding text already ends in horizontal whitespace. Minor semantic mismatch: guard usesCharacterSet.whitespaces(excludes newlines) butdrop(while:)usesCharacter.isWhitespace(includes newlines), which could silently drop a leading newline in a full-accept scenario.insertionChunkinto the acceptance path: insertsinsertionChunk(trimmed) instead ofacceptedChunk, skips the keystroke entirely when the result is empty, and updates caret prediction to useinsertionChunk. Session advancement and word-count accounting correctly keep usingacceptedChunk, so consumed-suffix reconciliation is unaffected.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]Reviews (1): Last reviewed commit: "Fix double space on accept by reconcilin..." | Re-trigger Greptile