Skip to content
Draft
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
164 changes: 164 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

178 changes: 160 additions & 18 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,12 @@ extension SuggestionCoordinator {
// the corrected one in one gesture. Partial acceptance would be incoherent because the
// corrected word and the typo can disagree on prefix length, so route to a dedicated path
// before the continuation preparation below.
if activeSession.kind.isCorrection {
return acceptCorrection(session: activeSession, keyName: keyName, rawContext: rawContext)
if let replacementResult = acceptReplacementSessionIfNeeded(
activeSession,
keyName: keyName,
rawContext: rawContext
) {
return replacementResult
}

// `acceptEntireSuggestion` forces the full-acceptance path so the dedicated full-accept key
Expand Down Expand Up @@ -123,7 +127,9 @@ extension SuggestionCoordinator {

// An empty chunk means the accepted span was entirely a boundary space the field already
// supplies: advance the session without synthesizing a keystroke.
if !insertionText.isEmpty, !suggestionInserter.insert(insertionText) {
let insertionMode = insertionMode(for: rawContext)
if !insertionText.isEmpty,
!suggestionInserter.insert(insertionText, mode: insertionMode) {
let message = suggestionInserter.lastErrorMessage ?? "Suggestion insertion failed."
cancelPredictionWork()
clearSuggestion(clearDiagnostics: true)
Expand Down Expand Up @@ -183,22 +189,13 @@ extension SuggestionCoordinator {
// *after* the `hideOverlay` above, which routes through `onStateChange(.hidden)` and
// turns interception off; arming re-asserts it. See `armPostExhaustionAcceptance`.
armPostExhaustionAcceptance()
// Start the continuation against the text the host is about to publish instead of
// idling through the publish poll first; the poll below validates the bet (see
// dispatchSpeculativePostAcceptanceGeneration).
dispatchSpeculativePostAcceptanceGeneration(
return refreshAfterExhaustedAcceptance(
mode: insertionMode,
context: liveContext,
insertedText: insertionText,
rawContext: rawContext,
insertionChunk: insertionChunk
)
// Wait for the host to actually publish the inserted text before regenerating. A bare
// `schedulePrediction()` here reads pre-insertion AX in Chromium editors (the publish lags
// the synthetic keystroke), so the model re-proposes the word just accepted and the next
// accept re-inserts it. That is the final-word accept/regenerate/accept loop reported as
// the suggestion "flickering" without committing. Polling until the insert surfaces (the
// same path typing uses) makes the regeneration read the settled text and return a genuine
// next suggestion, or nothing.
schedulePredictionAfterHostPublishDelay()
return true

case let .advanced(advancedSession, _):
latestGenerationNumber = liveContext.generation
Expand All @@ -211,7 +208,11 @@ extension SuggestionCoordinator {
insertionChunk: insertionChunk,
liveContext: liveContext
)
schedulePostInsertionRefresh()
refreshAfterAdvancedAcceptance(
mode: insertionMode,
context: liveContext,
insertedText: insertionText
)
let workID = currentWorkID
deferAcceptanceBookkeeping { [weak self] in
self?.applySessionDiagnostics(
Expand All @@ -230,6 +231,69 @@ extension SuggestionCoordinator {
}
}

/// Publishes Cotabby's optimistic shell echo and tells the caller whether AX refresh must be
/// skipped. TUI input also returns true, but its OCR heartbeat supplies the eventual value.
private func publishTerminalInsertionIfNeeded(
mode: SuggestionInsertionMode,
context: FocusedInputContext,
insertedText: String
) -> Bool {
guard mode == .terminalPaste else { return false }
if !insertedText.isEmpty {
onTerminalInsertion?(context, insertedText)
}
return true
}

private func insertionMode(for context: FocusedInputSnapshot) -> SuggestionInsertionMode {
context.terminalInputRole.map { _ in .terminalPaste } ?? .automatic
}

private func refreshAfterAdvancedAcceptance(
mode: SuggestionInsertionMode,
context: FocusedInputContext,
insertedText: String
) {
if !publishTerminalInsertionIfNeeded(
mode: mode,
context: context,
insertedText: insertedText
) {
schedulePostInsertionRefresh()
}
}

private func refreshAfterExhaustedAcceptance(
mode: SuggestionInsertionMode,
context: FocusedInputContext,
insertedText: String,
rawContext: FocusedInputSnapshot,
insertionChunk: String
) -> Bool {
if publishTerminalInsertionIfNeeded(
mode: mode,
context: context,
insertedText: insertedText
) {
// The authoritative terminal source is the host-publication signal. Never poll AX.
// When acceptance consumed only a boundary already present in the buffer, there is no
// paste for that source to publish, so explicitly refresh against the unchanged value.
if insertedText.isEmpty {
schedulePrediction()
}
return true
}

// Speculate against the imminent host value, then use the normal AX publication poll as
// the validator. This retains the existing Chromium accept/regenerate race protection.
dispatchSpeculativePostAcceptanceGeneration(
rawContext: rawContext,
insertionChunk: insertionChunk
)
schedulePredictionAfterHostPublishDelay()
return true
}

/// 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
Expand Down Expand Up @@ -423,6 +487,84 @@ extension SuggestionCoordinator {
return true
}

/// Routes both accept-as-a-unit session kinds away from continuation chunking. `nil` means the
/// session is a normal continuation; a non-nil Bool is the concrete acceptance outcome.
private func acceptReplacementSessionIfNeeded(
_ session: ActiveSuggestionSession,
keyName: String,
rawContext: FocusedInputSnapshot
) -> Bool? {
switch session.kind {
case .continuation:
return nil
case .correction:
return acceptCorrection(session: session, keyName: keyName, rawContext: rawContext)
case .terminalCommandReplacement:
return acceptTerminalCommandReplacement(
session: session,
keyName: keyName,
rawContext: rawContext
)
}
}

/// Replaces a reviewed plain-English shell instruction with the complete generated command.
/// The command is pasted but Return is never synthesized, so destructive output remains visible
/// and inert until the user explicitly executes it. Exact live-buffer validation prevents a
/// delayed Tab from deleting text the user changed after generation.
private func acceptTerminalCommandReplacement(
session: ActiveSuggestionSession,
keyName: String,
rawContext: FocusedInputSnapshot
) -> Bool {
guard case let .terminalCommandReplacement(originalText) = session.kind,
rawContext.terminalInputRole == .shell,
rawContext.precedingText == originalText,
rawContext.trailingText.isEmpty,
rawContext.elementIdentifier == session.baseContext.elementIdentifier else {
return passTabThrough(
reason: "Key passed through because the shell instruction changed before replacement."
)
}

guard suggestionInserter.replaceTerminalLine(
deletingCharacterCount: originalText.count,
with: session.fullText
) else {
Comment on lines +530 to +533

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.

P1 Unicode Delete Count Mismatch

When a terminal command-replacement prompt contains emoji or another multi-unit character, this path deletes originalText.count graphemes even though the inserter sends repeated Backspace events. The terminal can leave part of the original instruction in the buffer before pasting the generated command, so the user may execute a corrupted command after pressing Return.

Suggested change
guard suggestionInserter.replaceTerminalLine(
deletingCharacterCount: originalText.count,
with: session.fullText
) else {
guard suggestionInserter.replaceTerminalLine(
deletingCharacterCount: originalText.utf16.count,
with: session.fullText
) else {

Fix in Codex Fix in Claude Code

let message = suggestionInserter.lastErrorMessage ?? "Terminal command replacement failed."
cancelPredictionWork()
clearSuggestion(clearDiagnostics: true)
hideOverlay(reason: "Overlay hidden because terminal command replacement failed.")
state = .idle
logStage(
"terminal-command-replace-failed",
workID: currentWorkID,
generation: session.baseContext.generation,
message: message,
normalizedOutput: session.fullText
)
return false
}

onTerminalReplacement?(session.baseContext, session.fullText)
lastAcceptanceAt = Date()
focusModel.invalidateTransientCaretCaches()
cancelPredictionWork()
latestGenerationNumber = session.baseContext.generation
clearSuggestion(clearDiagnostics: false)
hideOverlay(reason: "Overlay hidden because \(keyName) replaced the shell instruction.")
state = .idle
latestAcceptanceAction = "Replaced shell instruction with a command using \(keyName)."
logStage(
"\(keyName)-accepted-terminal-command-replacement",
workID: currentWorkID,
generation: session.baseContext.generation,
message: "Replaced the reviewed plain-English shell instruction without executing it.",
normalizedOutput: session.fullText
)
return true
}

/// Returns control of the accept key to the host app and clears stale suggestion UI.
///
/// `InputMonitor` calls this from the consuming tap before returning a callback result. A `false`
Expand Down Expand Up @@ -584,7 +726,7 @@ extension SuggestionCoordinator {
CotabbyLogger.suggestion.debug("Invalidating active suggestion: \(reason)")
// The dying session is exactly what a backspace-rollback wants restored a moment later;
// remember it (string-only) before the state is torn down.
if let session = interactionState.activeSession, !session.kind.isCorrection {
if let session = interactionState.activeSession, !session.kind.isReplacement {
suggestionAnchorCache.record(
identityKey: session.baseContext.focusedInputIdentityKey,
precedingText: session.baseContext.precedingText,
Expand Down
46 changes: 39 additions & 7 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ extension SuggestionCoordinator {
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
disabledDomains: PerDomainDisableSettings.disabledDomains(),
suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals,
terminalIntegrationActive: terminalIntegrationActiveProvider(),
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
focusSnapshot: focusModel.snapshot
) {
Expand Down Expand Up @@ -63,6 +64,7 @@ extension SuggestionCoordinator {
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
disabledDomains: PerDomainDisableSettings.disabledDomains(),
suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals,
terminalIntegrationActive: terminalIntegrationActiveProvider(),
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
screenRecordingGranted: permissionManager.screenRecordingGranted,
focusSnapshot: snapshot,
Expand Down Expand Up @@ -93,6 +95,7 @@ extension SuggestionCoordinator {
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
disabledDomains: PerDomainDisableSettings.disabledDomains(),
suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals,
terminalIntegrationActive: terminalIntegrationActiveProvider(),
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
screenRecordingGranted: permissionManager.screenRecordingGranted,
focusSnapshot: snapshot,
Expand Down Expand Up @@ -126,6 +129,20 @@ extension SuggestionCoordinator {
if overlayState.isVisible {
hideOverlay(reason: "Overlay hidden because no ready suggestion remains.")
}

// AX fields are scheduled from the key event after their host publishes the edit. Terminal
// sources are the opposite: the socket/OCR snapshot itself is the authoritative publish,
// so schedule once per source signature and never poll the opaque AX terminal surface.
if focusedContext.terminalInputRole != nil {
let signature = focusedContext.contentSignature
if signature != lastScheduledTerminalSignature,
SuggestionRequestFactory.shouldGenerateSuggestion(for: focusedContext.precedingText) {
lastScheduledTerminalSignature = signature
schedulePrediction()
}
} else {
lastScheduledTerminalSignature = nil
}
}

/// Fire-and-forget warmup that builds a minimal request shape for the current focus and asks
Expand Down Expand Up @@ -155,11 +172,16 @@ extension SuggestionCoordinator {
}

func handleInputEvent(_ event: CapturedInputEvent) -> Bool {
// TUI OCR must see keystrokes even before it owns effective focus; dedicated terminals are
// disabled in the normal evaluator until that verified OCR snapshot arrives.
tuiInputObserver?(event)

// Give the emoji picker first look at every keystroke so it can drive its trigger state
// machine. When a capture is involved, the picker owns the interaction: the suggestion
// pipeline stands down and any lingering ghost text is cleared so it does not show behind the
// panel. Consumption still happens through the active tap's `emojiCaptureKeyDecider`.
if emojiInputObserver?(event) == true {
if focusModel.snapshot.context?.terminalInputRole == nil,
emojiInputObserver?(event) == true {
if overlayState.isVisible || interactionState.activeSession != nil {
cancelPredictionWork()
clearSuggestion(clearDiagnostics: true)
Expand Down Expand Up @@ -199,17 +221,22 @@ extension SuggestionCoordinator {
}

if event.shouldSchedulePrediction {
// Same Chromium AX-publish race as the with-session paths below: the CGEvent tap runs
// *before* the host app processes the keystroke, so a synchronous `refreshNow()` here
// reads pre-keystroke text and feeds it into generation. The result is a suggestion
// that looks like the typed character was ignored — see
// `schedulePredictionAfterHostPublishDelay` for the full rationale.
schedulePredictionAfterHostPublishDelay()
schedulePredictionAfterInputEvent()
}

return false
}

private func schedulePredictionAfterInputEvent() {
guard focusModel.snapshot.context?.terminalInputRole == nil else {
// The terminal source will schedule when its post-keystroke value arrives.
return
}
// The event tap runs before the host processes the key. Poll normal AX fields until they
// publish; terminal sources use their own authoritative publication path above.
schedulePredictionAfterHostPublishDelay()
}

/// Maximum wall time we'll wait for the host app to publish post-keystroke AX before giving
/// up and generating against whatever's there. Chosen empirically: long enough to cover
/// Chrome's slower contenteditable publish on a busy page, short enough that the user can
Expand Down Expand Up @@ -241,6 +268,11 @@ extension SuggestionCoordinator {
/// still collapse cleanly. The `hostPublishPollGeneration` token adds the missing outer
/// coalescing layer: only the newest keystroke's polling chain may keep reading AX.
func schedulePredictionAfterHostPublishDelay() {
guard focusModel.snapshot.context?.terminalInputRole == nil else {
// Shell hooks and TUI OCR are their own post-edit publication channels. Refreshing AX
// here would only read terminal output and could never validate the authoritative text.
return
}
hostPublishPollGeneration &+= 1
let pollGeneration = hostPublishPollGeneration
let baseline = focusModel.snapshot.context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ extension SuggestionCoordinator {
overlayController.onStateChange = nil
visualContextCoordinator.onStateChange = nil
visualContextCoordinator.onInjectedContextReady = nil
lastScheduledTerminalSignature = nil
}

/// Clears any active suggestion work before the runtime swaps to a different model.
Expand All @@ -38,6 +39,7 @@ extension SuggestionCoordinator {
clearSuggestion(clearDiagnostics: true)
hideOverlay(reason: "Overlay hidden because the runtime model is switching.")
state = .idle
lastScheduledTerminalSignature = nil
latestStageMessage = "Idle: runtime model switching reset active suggestion state."
}

Expand Down Expand Up @@ -76,6 +78,7 @@ extension SuggestionCoordinator {
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
disabledDomains: PerDomainDisableSettings.disabledDomains(),
suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals,
terminalIntegrationActive: terminalIntegrationActiveProvider(),
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
screenRecordingGranted: permissionManager.screenRecordingGranted,
focusSnapshot: focusModel.snapshot,
Expand All @@ -90,6 +93,7 @@ extension SuggestionCoordinator {
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
disabledDomains: PerDomainDisableSettings.disabledDomains(),
suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals,
terminalIntegrationActive: terminalIntegrationActiveProvider(),
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
focusSnapshot: focusModel.snapshot
) {
Expand Down
Loading
Loading