Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ extension SuggestionCoordinator {
case let .ready(preparedLiveContext, preparedSession, preparedAcceptedChunk):
liveContext = preparedLiveContext
sessionForAcceptance = preparedSession
acceptedChunk = preparedAcceptedChunk
// With "add space after accepting" on, a word-ending accept also takes the suggestion's
// own following space so the trailing space lands now instead of with the next accept.
// This is what makes the setting fire on every word, not only the final one that
// exhausts the suggestion (which `insertionTextApplyingAutoSpace` still covers).
acceptedChunk = autoSpaceAdjustedChunk(preparedAcceptedChunk, session: preparedSession)

case let .invalid(reason):
return passTabThrough(reason: reason)
Expand Down Expand Up @@ -211,15 +215,30 @@ extension SuggestionCoordinator {
}
}

/// Applies the opt-in "add a space after accepting" setting to the text about to be inserted.
///
/// The trailing space is only appended when this accept *exhausts* the suggestion — predicted the
/// same way `commitAcceptedChunk` decides it — because a mid-suggestion word accept is already
/// followed by the next chunk's own leading space, so a space here would double up. Only the
/// inserted text grows: session accounting still advances by the unchanged `acceptedChunk`, and
/// the session tears down on exhaustion, so the extra space never disturbs the consumed-suffix
/// reconciliation a still-live session relies on. Whether the space actually lands (vs. being
/// suppressed after punctuation, whitespace, or a space-less script) is the reconciler's rule.
/// Applies the opt-in "add a space after accepting" setting to the chunk being accepted, by
/// extending a word-ending chunk to also take the suggestion's own following space. The whole
/// flow (insertion text, session advance, overlay) then runs on the extended chunk, so the space
/// is consumed from the buffered suggestion rather than synthesized: the session stays in
/// lockstep with the field and the reconciler's existing rule drops the next chunk's now-leading
/// space, so there is no double space. See `acceptanceChunkConsumingTrailingSpace`.
private func autoSpaceAdjustedChunk(_ chunk: String, session: ActiveSuggestionSession) -> String {
guard settingsSnapshot.addSpaceAfterAccept else {
return chunk
}
return SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace(
chunk,
remainingText: session.remainingText
)
}

/// Appends the trailing space for the *final* accept of a suggestion — the one case
/// `autoSpaceAdjustedChunk` cannot cover, because an exhausting accept has no following
/// suggestion whitespace to consume. Fires only when this accept exhausts the suggestion
/// (predicted the same way `commitAcceptedChunk` decides it). Here the inserted text grows by a
/// synthesized space, but the session tears down on exhaustion, so it never disturbs the
/// consumed-suffix reconciliation a still-live session relies on. Whether the space actually
/// lands (vs. being suppressed after punctuation, whitespace, or a space-less script) is the
/// reconciler's rule.
private func insertionTextApplyingAutoSpace(
insertionChunk: String,
acceptedChunk: String,
Expand Down
27 changes: 27 additions & 0 deletions Cotabby/Support/SuggestionSessionReconciler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,33 @@ enum SuggestionSessionReconciler {
return chunk + " "
}

/// Extends a word-ending accepted chunk to also take the suggestion's *own* following horizontal
/// whitespace, so when "add space after accepting" is on the trailing space lands with this
/// accept rather than arriving only on the next one. This is what makes the setting fire on every
/// word of a multi-word suggestion, not just the final chunk that exhausts it.
///
/// Only whitespace the model already placed after the word is consumed — never a synthesized
/// character — so the session stays in lockstep with the live field: the extended chunk is still
/// an exact prefix of the buffered suggestion, and the next chunk simply loses the leading space
/// this one absorbed, so no double space is produced. The chunk is returned unchanged when it
/// does not end on a finished word, ends on a space-less script, or the suggestion's next
/// character is not horizontal whitespace (punctuation stays attached to the model's own layout;
/// a newline must not be swallowed as a space; and the end-of-suggestion case is handled at
/// exhaustion by `insertionChunkAppendingTrailingSpace` instead).
///
/// `remainingText` is the session's full remaining tail, which begins with `chunk`; the
/// whitespace examined is what follows it.
static func acceptanceChunkConsumingTrailingSpace(_ chunk: String, remainingText: String) -> String {
guard let last = chunk.last,
last.isAcceptanceWordCharacter,
!last.beginsSpacelessScriptWord else {
return chunk
}
let remainder = remainingText.dropFirst(chunk.count)
let trailingSpace = remainder.prefix(while: { $0 == " " || $0 == "\t" })
return trailingSpace.isEmpty ? chunk : chunk + trailingSpace
}

/// Counts word-like tokens so punctuation-only accepts do not inflate productivity metrics.
static func acceptedWordCount(in text: String) -> Int {
text
Expand Down
89 changes: 87 additions & 2 deletions CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,88 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase {
}
}

func test_acceptCurrentSuggestion_withAddSpaceAfterAccept_insertsTrailingSpaceOnNonFinalWord() {
// Full coordinator path with the setting ON and a multi-word suggestion: accepting the first
// word must insert the word plus the suggestion's own following space (so the toggle fires
// per word, not only when the suggestion is exhausted), while the tail keeps the rest.
runOnMainActor {
let snapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello")
let context = FocusedInputContext(snapshot: snapshot, generation: 7)
let interactionState = SuggestionInteractionState()
let session = interactionState.startSession(
fullText: " world how",
liveContext: context,
latency: 0.1
)
let overlayState = OverlayState.visible(
text: session.remainingText,
geometry: CotabbyTestFixtures.overlayGeometry(caretRect: context.caretRect),
mode: .inline
)
let inserter = StubSuggestionInserter()
let coordinator = makeCoordinator(
snapshot: snapshot,
overlayState: overlayState,
inputMonitor: StubSuggestionInputMonitor(),
inserter: inserter,
interactionState: interactionState,
settingsSnapshot: CotabbyTestFixtures.settingsSnapshot(addSpaceAfterAccept: true)
)
coordinator.state = .debouncing

XCTAssertTrue(coordinator.acceptCurrentSuggestion())

// " world" plus the model's own following space, consumed in one accept.
XCTAssertEqual(inserter.insertedChunks, [" world "])
if case let .ready(remainingText, _) = coordinator.state {
XCTAssertEqual(remainingText, "how", "The consumed following space should not lead the tail.")
} else {
XCTFail("Partial acceptance should leave the remaining suggestion ready.")
}
Self.retainedCoordinators.append(coordinator)
}
}

func test_acceptCurrentSuggestion_withoutAddSpaceAfterAccept_insertsWordWithoutTrailingSpace() {
// Same setup with the setting OFF: byte-for-byte the prior behavior (no trailing space, the
// following space leads the next chunk).
runOnMainActor {
let snapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello")
let context = FocusedInputContext(snapshot: snapshot, generation: 7)
let interactionState = SuggestionInteractionState()
let session = interactionState.startSession(
fullText: " world how",
liveContext: context,
latency: 0.1
)
let overlayState = OverlayState.visible(
text: session.remainingText,
geometry: CotabbyTestFixtures.overlayGeometry(caretRect: context.caretRect),
mode: .inline
)
let inserter = StubSuggestionInserter()
let coordinator = makeCoordinator(
snapshot: snapshot,
overlayState: overlayState,
inputMonitor: StubSuggestionInputMonitor(),
inserter: inserter,
interactionState: interactionState,
settingsSnapshot: CotabbyTestFixtures.settingsSnapshot(addSpaceAfterAccept: false)
)
coordinator.state = .debouncing

XCTAssertTrue(coordinator.acceptCurrentSuggestion())

XCTAssertEqual(inserter.insertedChunks, [" world"])
if case let .ready(remainingText, _) = coordinator.state {
XCTAssertEqual(remainingText, " how")
} else {
XCTFail("Partial acceptance should leave the remaining suggestion ready.")
}
Self.retainedCoordinators.append(coordinator)
}
}

func test_acceptCurrentSuggestionCleansVisibleOverlayWhenSessionDisappears() {
runOnMainActor {
let snapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello")
Expand Down Expand Up @@ -381,7 +463,8 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase {
overlayState: OverlayState,
inputMonitor: StubSuggestionInputMonitor,
inserter: StubSuggestionInserter,
interactionState: SuggestionInteractionState
interactionState: SuggestionInteractionState,
settingsSnapshot: SuggestionSettingsSnapshot = CotabbyTestFixtures.settingsSnapshot()
) -> SuggestionCoordinator {
let focusSnapshot = FocusSnapshot(
applicationName: snapshot.applicationName,
Expand All @@ -390,14 +473,16 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase {
context: snapshot,
inspection: nil
)
let settingsProvider = StubSuggestionSettingsProvider()
settingsProvider.snapshot = settingsSnapshot
let coordinator = SuggestionCoordinator(
permissionManager: StubSuggestionPermissionProvider(),
focusModel: StubSuggestionFocusProvider(snapshot: focusSnapshot),
inputMonitor: inputMonitor,
overlayController: StubSuggestionOverlayController(state: overlayState),
suggestionInserter: inserter,
suggestionEngine: StubSuggestionEngine(),
suggestionSettings: StubSuggestionSettingsProvider(),
suggestionSettings: settingsProvider,
clipboardContextProvider: StubClipboardContextProvider(),
clipboardRelevanceFilter: StubClipboardRelevanceFilter(),
visualContextCoordinator: StubVisualContextCoordinator(),
Expand Down
60 changes: 60 additions & 0 deletions CotabbyTests/SuggestionSessionReconcilerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,66 @@ final class SuggestionSessionReconcilerTests: XCTestCase {
)
}

func test_acceptanceChunkConsumingTrailingSpace_takesFollowingSpaceAfterWord() {
XCTAssertEqual(
SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("world", remainingText: "world how are you"),
"world "
)
}

func test_acceptanceChunkConsumingTrailingSpace_keepsLeadingWhitespaceAndTakesFollowingSpace() {
// nextAcceptanceChunk returns leading whitespace with the token, so the extension must keep it
// and still consume the space that follows the word.
XCTAssertEqual(
SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace(" world", remainingText: " world how"),
" world "
)
}

func test_acceptanceChunkConsumingTrailingSpace_takesWholeHorizontalRun() {
XCTAssertEqual(
SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("world", remainingText: "world\t how"),
"world\t "
)
}

func test_acceptanceChunkConsumingTrailingSpace_noFollowingWhitespaceLeavesChunkUntouched() {
// End of the suggestion: nothing to consume here — the exhaustion-time append covers it.
XCTAssertEqual(
SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("world", remainingText: "world"),
"world"
)
}

func test_acceptanceChunkConsumingTrailingSpace_doesNotCrossNewline() {
XCTAssertEqual(
SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("line", remainingText: "line\nnext"),
"line"
)
}

func test_acceptanceChunkConsumingTrailingSpace_doesNotConsumeBeforePunctuation() {
XCTAssertEqual(
SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("world", remainingText: "world, how"),
"world"
)
}

func test_acceptanceChunkConsumingTrailingSpace_skipsWhenChunkEndsInPunctuation() {
XCTAssertEqual(
SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("done.", remainingText: "done. next"),
"done."
)
}

func test_acceptanceChunkConsumingTrailingSpace_skipsForSpacelessScript() {
// CJK scripts do not separate words with spaces, so even a stray following space is not taken.
XCTAssertEqual(
SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("資料", remainingText: "資料 です"),
"資料"
)
}
Comment on lines +677 to +735

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 No coordinator-level integration test for per-word spacing

The 8 new unit tests thoroughly exercise acceptanceChunkConsumingTrailingSpace in isolation, but there is no test in SuggestionCoordinatorAcceptanceTests that exercises the full path: autoSpaceAdjustedChunkinsertionChunkcommitAcceptedChunkpresentAdvancedOverlay with addSpaceAfterAccept = true and a multi-word suggestion. If a future refactor accidentally breaks the wiring at the coordinator layer (e.g., passing the pre-adjusted chunk to commitAcceptedChunk by mistake), no test would catch it.

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good call — added two coordinator-level integration tests in 76bcae2 (test_acceptCurrentSuggestion_withAddSpaceAfterAccept_insertsTrailingSpaceOnNonFinalWord and the OFF counterpart). They drive the full path through acceptCurrentSuggestion() with a multi-word suggestion and assert the inserted chunk and remaining tail, so a coordinator-layer wiring break is caught.


func test_acceptedWordCount_countsOnlyTokensWithAlphanumerics() {
let count = SuggestionSessionReconciler.acceptedWordCount(
in: "hello, !!! world 123 --"
Expand Down