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
13 changes: 12 additions & 1 deletion Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ extension SuggestionCoordinator {
completeActiveSuggestion(
reason: "Overlay hidden because the user typed through the rest of the suggestion.",
scheduleNextPrediction: true,
awaitHostPublish: true,
stage: "typed-match-exhausted",
message: "The user typed the remaining suggestion characters exactly.",
acceptanceAction: "User typed through the rest of the suggestion."
Expand Down Expand Up @@ -356,6 +357,7 @@ extension SuggestionCoordinator {
func completeActiveSuggestion(
reason: String,
scheduleNextPrediction: Bool,
awaitHostPublish: Bool = false,
stage: String,
message: String,
acceptanceAction: String
Expand All @@ -368,7 +370,16 @@ extension SuggestionCoordinator {
logStage(stage, workID: currentWorkID, generation: generation, message: message)

if scheduleNextPrediction {
schedulePrediction()
// Callers reacting to a *synthetic-tap* keystroke (the user typing through a suggestion)
// must wait for the host to publish the keystroke before regenerating, or the new
// suggestion is built against pre-keystroke text in Chromium editors and looks like the
// typed characters were ignored. Reconcile-path callers, where AX has already settled,
// schedule immediately.
if awaitHostPublish {
schedulePredictionAfterHostPublishDelay()
} else {
schedulePrediction()
}
}
}

Expand Down
42 changes: 38 additions & 4 deletions Cotabby/Services/Suggestion/SuggestionInserter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ final class SuggestionInserter {

private(set) var lastErrorMessage: String?

/// In-flight state for the opt-in paste path. The user's real clipboard is snapshotted once and
/// restored after the paste lands. While a restore is pending, a second paste must NOT re-snapshot
/// (the pasteboard then holds OUR completion, which would leak back to the user), so overlapping
/// pastes coalesce onto this single saved clipboard and reschedule the one pending restore.
private var pendingPasteboardRestore: DispatchWorkItem?
private var savedClipboardForRestore: [[NSPasteboard.PasteboardType: Data]]?

/// Virtual key code for Delete/Backspace. Posting these at the HID level deletes one UTF-16 unit
/// of already-typed text per pair, which is how the picker removes the literal `:query` run.
private static let backspaceKeyCode: CGKeyCode = 0x33
Expand Down Expand Up @@ -152,17 +159,28 @@ final class SuggestionInserter {
/// ignores it.
private func insertViaPaste(_ text: String) -> Bool {
let pasteboard = NSPasteboard.general
let saved = Self.snapshotPasteboard(pasteboard)
// Snapshot the user's clipboard only when no restore is already pending. If one is (an
// overlapping paste), the pasteboard currently holds our previous completion, so re-snapshotting
// would save that and leak it back to the user; reuse the already-saved real clipboard.
if pendingPasteboardRestore == nil {
savedClipboardForRestore = Self.snapshotPasteboard(pasteboard)
}
let saved = savedClipboardForRestore ?? []

pasteboard.clearContents()
guard pasteboard.setString(text, forType: .string) else {
pendingPasteboardRestore?.cancel()
Self.restorePasteboard(saved, to: pasteboard)
clearPendingPasteboardRestore()
return false
}
let expectedChangeCount = pasteboard.changeCount

guard let keyDown = CGEvent(keyboardEventSource: nil, virtualKey: Self.vKeyCode, keyDown: true),
let keyUp = CGEvent(keyboardEventSource: nil, virtualKey: Self.vKeyCode, keyDown: false) else {
pendingPasteboardRestore?.cancel()
Self.restorePasteboard(saved, to: pasteboard)
clearPendingPasteboardRestore()
return false
}
keyDown.flags = .maskCommand
Expand All @@ -173,13 +191,29 @@ final class SuggestionInserter {
keyDown.post(tap: .cghidEventTap)
keyUp.post(tap: .cghidEventTap)

// Give the host time to service Cmd-V, then hand the clipboard back to the user.
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pasteboardRestoreDelay) {
Self.restorePasteboard(saved, to: NSPasteboard.general)
// Give the host time to service Cmd-V, then hand the clipboard back, but only if our completion
// is still the thing on it. If the user copied something during the window, `changeCount`
// advanced and we leave their new clipboard alone. An overlapping paste cancels this restore
// and reschedules one for the same saved clipboard.
pendingPasteboardRestore?.cancel()
let restore = DispatchWorkItem { [weak self] in
if NSPasteboard.general.changeCount == expectedChangeCount {
Self.restorePasteboard(saved, to: NSPasteboard.general)
}
self?.clearPendingPasteboardRestore()
}
pendingPasteboardRestore = restore
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pasteboardRestoreDelay, execute: restore)
return true
}

/// Clears the in-flight paste-restore bookkeeping once a restore has run (or been abandoned on a
/// failure path), so the next paste snapshots the user's real clipboard afresh.
private func clearPendingPasteboardRestore() {
pendingPasteboardRestore = nil
savedClipboardForRestore = nil
}

/// Captures every representation of every pasteboard item so the user's clipboard can be restored
/// exactly, not just its plain-text form.
private static func snapshotPasteboard(_ pasteboard: NSPasteboard) -> [[NSPasteboard.PasteboardType: Data]] {
Expand Down
7 changes: 4 additions & 3 deletions Cotabby/Support/InsertionSafetyGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ enum InsertionSafetyGate {
if scalar == "\u{FFFD}" {
return false
}
// C0 control range and DEL. Newlines are already handled upstream; an interior tab or
// other control character is corruption, not content.
if scalar.value < 0x20 || scalar.value == 0x7F {
// C0 control range and DEL. A line feed is legitimate content in a multi-line completion
// (the normalizer keeps newlines when multi-line mode is on), so it must pass; any other
// control character (an interior tab, a stray escape) is corruption, not content.
if scalar.value != 0x0A, scalar.value < 0x20 || scalar.value == 0x7F {
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 comma-separated condition mixes Swift's condition-list && semantics with an infix || inside the second clause. The logic is correct, but a reader who doesn't notice that the comma binds looser than || could misread it as (scalar.value != 0x0A, scalar.value < 0x20) || scalar.value == 0x7F. Using && with explicit parentheses makes the grouping unambiguous.

Suggested change
if scalar.value != 0x0A, scalar.value < 0x20 || scalar.value == 0x7F {
if scalar.value != 0x0A && (scalar.value < 0x20 || scalar.value == 0x7F) {

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

return false
}
if !CharacterSet.whitespacesAndNewlines.contains(scalar) {
Expand Down
11 changes: 11 additions & 0 deletions CotabbyTests/InsertionSafetyGateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,15 @@ final class InsertionSafetyGateTests: XCTestCase {
func test_interiorControlCharacter_isUnsafe() {
XCTAssertFalse(InsertionSafetyGate.isSafeToInsert("a\tb"))
}

func test_multiLineContent_isSafe() {
// A line feed is legitimate content in a multi-line completion, so it must pass (the previous
// behavior rejected it, silently suppressing every multi-line completion).
XCTAssertTrue(InsertionSafetyGate.isSafeToInsert("first line\nsecond line"))
}

func test_newlineOnly_isUnsafe() {
// Newlines are whitespace; a newline-only completion is still nothing worth inserting.
XCTAssertFalse(InsertionSafetyGate.isSafeToInsert("\n\n"))
}
}
Comment on lines +39 to 43
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 new tests document that now passes and that a newline-only string still fails, but there is no explicit test showing that (0x0D) is still rejected. is stripped by SuggestionTextNormalizer before it can reach the gate in production, but a gate-level test would make the 0x0A-only exemption self-documenting and guard against future normalizer changes that might pass through.

Suggested change
func test_newlineOnly_isUnsafe() {
// Newlines are whitespace; a newline-only completion is still nothing worth inserting.
XCTAssertFalse(InsertionSafetyGate.isSafeToInsert("\n\n"))
}
}
func test_newlineOnly_isUnsafe() {
// Newlines are whitespace; a newline-only completion is still nothing worth inserting.
XCTAssertFalse(InsertionSafetyGate.isSafeToInsert("\n\n"))
}
func test_carriageReturn_isUnsafe() {
// Only 0x0A is exempted; \r (0x0D) is still a rejected C0 control character.
XCTAssertFalse(InsertionSafetyGate.isSafeToInsert("a\rb"))
}
}

Fix in Codex Fix in Claude Code