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
Binary file removed .github/assets/readme/demo-email.png
Binary file not shown.
10 changes: 2 additions & 8 deletions Cotabby/App/Coordinators/EmojiPickerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ final class EmojiPickerController {
private let isEnabled: () -> Bool
/// Live emoji-customization preferences (skin tone / gender / neutral variant), read at match time.
private let emojiPreferences: () -> EmojiVariantPreferences
/// The accept-word key label shown as a keycap on the highlighted row; `nil` hides the hint.
private let acceptKeyLabel: () -> String?
/// Live personal usage snapshot, read at match time to rank favorites and seed the bare-`:` panel.
/// `@MainActor`: it reads main-actor `EmojiUsageStore` state, matching where the picker runs.
private let emojiUsage: @MainActor () -> EmojiUsageSnapshot
Expand Down Expand Up @@ -85,7 +83,6 @@ final class EmojiPickerController {
inserter: any EmojiTextInserting,
isEnabled: @escaping () -> Bool,
emojiPreferences: @escaping () -> EmojiVariantPreferences,
acceptKeyLabel: @escaping () -> String?,
emojiUsage: @MainActor @escaping () -> EmojiUsageSnapshot,
recordEmojiUsage: @MainActor @escaping (String) -> Void
) {
Expand All @@ -96,7 +93,6 @@ final class EmojiPickerController {
self.inserter = inserter
self.isEnabled = isEnabled
self.emojiPreferences = emojiPreferences
self.acceptKeyLabel = acceptKeyLabel
self.emojiUsage = emojiUsage
self.recordEmojiUsage = recordEmojiUsage
}
Expand Down Expand Up @@ -354,8 +350,7 @@ final class EmojiPickerController {
query: currentQuery,
matches: matches,
selectedIndex: selectedIndex,
caretRect: caretRect,
acceptKeyLabel: acceptKeyLabel()
caretRect: caretRect
)
}

Expand Down Expand Up @@ -426,8 +421,7 @@ final class EmojiPickerController {
query: currentQuery,
matches: matches,
selectedIndex: selectedIndex,
caretRect: context.caretRect,
acceptKeyLabel: acceptKeyLabel()
caretRect: context.caretRect
)
}

Expand Down
41 changes: 6 additions & 35 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,6 @@ extension SuggestionCoordinator {
latestGenerationNumber = liveContext.generation
clearSuggestion(clearDiagnostics: false)
hideOverlay(reason: "Overlay hidden because \(keyName) accepted the final suggestion chunk.")
latestAcceptanceAction = "Accepted final chunk with \(keyName)."
state = .idle
// Remember what we just committed and the text it followed. `apply` consumes this to drop
// a regeneration that only re-proposes the same tail before the host publishes the insert
Expand Down Expand Up @@ -214,10 +213,6 @@ extension SuggestionCoordinator {
schedulePostInsertionRefresh()
let workID = currentWorkID
deferAcceptanceBookkeeping { [weak self] in
self?.applySessionDiagnostics(
advancedSession,
acceptanceAction: "Accepted next chunk with \(keyName)."
)
self?.logStage(
"\(keyName)-accepted-chunk",
workID: workID,
Expand Down Expand Up @@ -407,7 +402,6 @@ extension SuggestionCoordinator {
let workID = currentWorkID
deferAcceptanceBookkeeping { [weak self] in
self?.recordAcceptedWords(from: replacement.replacementText)
self?.latestAcceptanceAction = "Accepted typo correction with \(keyName)."
self?.logStage(
"\(keyName)-accepted-correction",
workID: workID,
Expand Down Expand Up @@ -536,16 +530,14 @@ extension SuggestionCoordinator {
}

cancelPredictionWork()
applySessionDiagnostics(advancedSession, acceptanceAction: "User typed the next expected characters.")

if advancedSession.isExhausted {
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."
message: "The user typed the remaining suggestion characters exactly."
)
return true
}
Expand Down Expand Up @@ -595,12 +587,10 @@ extension SuggestionCoordinator {
scheduleNextPrediction: Bool,
awaitHostPublish: Bool = false,
stage: String,
message: String,
acceptanceAction: String
message: String
) {
let generation = latestGenerationNumber
clearSuggestion(clearDiagnostics: false)
latestAcceptanceAction = acceptanceAction
hideOverlay(reason: reason)
state = .idle
logStage(stage, workID: currentWorkID, generation: generation, message: message)
Expand All @@ -619,17 +609,6 @@ extension SuggestionCoordinator {
}
}

func applySessionDiagnostics(_ session: ActiveSuggestionSession, acceptanceAction: String?) {
latestSuggestionPreview = session.remainingText
latestFullSuggestionPreview = session.fullText
latestRemainingSuggestionPreview = session.remainingText
latestAcceptedCharacterCount = session.acceptedCount
latestRemainingCharacterCount = session.remainingCount
if let acceptanceAction {
latestAcceptanceAction = acceptanceAction
}
}

/// Marks the session's suggestion accepted in the quality counters, once per suggestion: only
/// the first chunk counts, so word-by-word walks of one suggestion add nothing further and the
/// acceptance rate stays suggestions-accepted over suggestions-shown.
Expand Down Expand Up @@ -767,13 +746,11 @@ extension SuggestionCoordinator {
isCorrection: isCorrection,
resolvedFieldStyle: context.resolvedFieldStyle
)
if let message = overlayPresenter.present(
_ = overlayPresenter.present(
text: text,
geometry: geometry,
previousState: overlayState
) {
latestOverlayMessage = message
}
)
}

/// Repairs untrustworthy caret anchors with a hidden-text-layout estimate before presentation.
Expand Down Expand Up @@ -913,8 +890,7 @@ extension SuggestionCoordinator {
/// joined to the exact gate decision via `request_id`. The metadata deliberately carries the
/// estimate-vs-AX vertical delta and which host measurements calibrated the layout, because
/// field reports of "ghost is N lines off" are only diagnosable from those numbers.
/// Deliberately not routed through `logStage`: that helper also mutates the UI-facing
/// `latestStageMessage`, and a per-present geometry detail should not overwrite the
/// Deliberately not routed through `logStage`: this is a geometry-specific event rather than a
/// user-visible pipeline stage.
private func logCaretLayoutRepair(
anchor: LayoutRepairedAnchor,
Expand Down Expand Up @@ -982,7 +958,7 @@ extension SuggestionCoordinator {
}

func hideOverlay(reason: String) {
latestOverlayMessage = overlayPresenter.hide(reason: reason)
_ = overlayPresenter.hide(reason: reason)
}

func logStage(
Expand All @@ -994,11 +970,6 @@ extension SuggestionCoordinator {
rawOutput: String? = nil,
normalizedOutput: String? = nil
) {
// Repeated keystrokes produce identical stage messages; republishing the same string
// would still fire `objectWillChange` and re-render every coordinator observer.
if latestStageMessage != message {
latestStageMessage = message
}
logger.logStage(
stage,
workID: workID,
Expand Down
10 changes: 0 additions & 10 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ extension SuggestionCoordinator {
clearSuggestion(clearDiagnostics: true)
hideOverlay(reason: "Overlay hidden because the runtime model is switching.")
state = .idle
latestStageMessage = "Idle: runtime model switching reset active suggestion state."
}

// MARK: - Settings
Expand All @@ -51,22 +50,13 @@ extension SuggestionCoordinator {
}

CotabbyLogger.suggestion.info("Settings changed, resetting suggestion state")
let previousSnapshot = settingsSnapshot
settingsSnapshot = snapshot
cancelPredictionWork()
resetCachedGenerationContext()
clearSuggestion(clearDiagnostics: true)
hideOverlay(reason: "Overlay hidden because autocomplete settings changed.")
state = .idle

if previousSnapshot.selectedEngine != snapshot.selectedEngine {
latestStageMessage = "Updated autocomplete engine to \(snapshot.selectedEngine.displayLabel)."
} else if previousSnapshot.selectedWordCountPreset != snapshot.selectedWordCountPreset {
latestStageMessage = "Updated suggestion length to \(snapshot.selectedWordCountPreset.displayLabel)."
} else {
latestStageMessage = "Updated autocomplete settings."
}

// Cancel any obsolete context, then restart only when the subsystem is not disabled.
visualContextCoordinator.cancel(resetState: true)
if let focusedSnapshot = focusModel.snapshot.context,
Expand Down
38 changes: 4 additions & 34 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,6 @@ extension SuggestionCoordinator {
visualContextSummary: visualContextSummary
)
latestGenerationNumber = context.generation
latestPromptPreview = requestBuildResult.promptPreview
latestRawModelOutput = nil
let request = requestBuildResult.request
latestRequestID = request.requestID

Expand Down Expand Up @@ -169,13 +167,11 @@ extension SuggestionCoordinator {

lastAcceptedTail = nil
latestGenerationNumber = context.generation
latestLatencyMilliseconds = 0
let session = interactionState.startSession(
fullText: remainder,
liveContext: context,
latency: 0
)
applySessionDiagnostics(session, acceptanceAction: "Restored a cached suggestion.")
state = .ready(text: session.remainingText, latency: session.latency)
presentOverlay(
text: session.remainingText,
Expand Down Expand Up @@ -242,8 +238,6 @@ extension SuggestionCoordinator {
visualContextSummary: visualContextSummary
)
latestGenerationNumber = context.generation
latestPromptPreview = requestBuildResult.promptPreview
latestRawModelOutput = nil
let request = requestBuildResult.request
latestRequestID = request.requestID

Expand Down Expand Up @@ -544,7 +538,6 @@ extension SuggestionCoordinator {
cancelPredictionWork()
clearSuggestion(clearDiagnostics: false)
hideOverlay(reason: "Overlay hidden because Cotabby automatically fixed a typo.")
latestAcceptanceAction = "Automatically corrected \"\(typoWord)\" to \"\(correctedWord)\"."
state = .idle
logStage(
"typo-auto-corrected",
Expand All @@ -570,14 +563,12 @@ extension SuggestionCoordinator {
) {
let liveContext = interactionState.materializeContext(from: rawContext)
latestGenerationNumber = liveContext.generation
latestLatencyMilliseconds = 0
let session = interactionState.startSession(
fullText: correctedWord,
liveContext: liveContext,
latency: 0,
kind: .correction(typoWord: typoWord)
)
applySessionDiagnostics(session, acceptanceAction: "Offered a correction for \"\(typoWord)\".")
state = .ready(text: session.remainingText, latency: 0)
presentOverlay(
text: session.remainingText,
Expand Down Expand Up @@ -677,7 +668,6 @@ extension SuggestionCoordinator {

guard isPaidOffSpeculation || liveContext.generation == result.generation else {

latestRawModelOutput = SuggestionDebugLogger.debugPreview(result.rawText)
// Lifecycle discards are counted under their own reasons so `generated` always equals
// `shown` plus the suppression histogram; without this, every drop here silently
// inflated the generated count against the others.
Expand All @@ -694,8 +684,6 @@ extension SuggestionCoordinator {
return
}

latestRawModelOutput = SuggestionDebugLogger.debugPreview(result.rawText)

guard !result.text.isEmpty else {
discardEmptyResult(result, workID: workID)
return
Expand Down Expand Up @@ -766,7 +754,6 @@ extension SuggestionCoordinator {
return
}

latestLatencyMilliseconds = Int(result.latency * 1000)
latestGenerationNumber = liveContext.generation
// One shown event per suggestion: this is the only place a fresh generation becomes
// visible (re-presentations after partial accepts reuse the same session).
Expand All @@ -781,7 +768,6 @@ extension SuggestionCoordinator {
liveContext: liveContext,
latency: result.latency
)
applySessionDiagnostics(session, acceptanceAction: "Generated new suggestion.")
state = .ready(text: session.remainingText, latency: session.latency)

presentOverlay(
Expand Down Expand Up @@ -890,15 +876,13 @@ extension SuggestionCoordinator {
advancement: SuggestionSessionAdvancement?
) {
latestGenerationNumber = liveContext.generation
applySessionDiagnostics(reconciledSession, acceptanceAction: advancement?.actionSummary ?? latestAcceptanceAction)

if reconciledSession.isExhausted {
completeActiveSuggestion(
reason: "Overlay hidden because the active suggestion was fully consumed.",
scheduleNextPrediction: true,
stage: advancement?.exhaustionStage ?? "session-exhausted",
message: advancement?.exhaustionMessage ?? "The active suggestion was fully consumed.",
acceptanceAction: advancement?.actionSummary ?? "Suggestion tail was fully consumed."
message: advancement?.exhaustionMessage ?? "The active suggestion was fully consumed."
)
return
}
Expand Down Expand Up @@ -1013,7 +997,6 @@ extension SuggestionCoordinator {
clearSuggestion(clearDiagnostics: true)
hideOverlay(reason: reason)
state = .disabled(reason)
latestStageMessage = "Disabled: \(reason)"
}

/// Disables predictions without tearing down the visual context session.
Expand All @@ -1033,7 +1016,6 @@ extension SuggestionCoordinator {
clearSuggestion(clearDiagnostics: true)
hideOverlay(reason: reason)
state = .disabled(reason)
latestStageMessage = "Disabled: \(reason)"
}

/// True when a previous teardown already disabled the pipeline for this exact reason and
Expand All @@ -1049,13 +1031,10 @@ extension SuggestionCoordinator {
}

/// True when the no-session clear path still has anything to tear down. With no active
/// session, most keystrokes arrive with the overlay already hidden and the published
/// suggestion state already nil; assigning nil to an already-nil `@Published` property still
/// fires `objectWillChange`, so skipping the redundant clear avoids re-rendering every
/// coordinator observer on every key. `.disabled` counts as nothing-to-clear because entering
/// it already ran the full teardown.
/// session, most keystrokes arrive with the overlay already hidden and the state already idle.
/// `.disabled` counts as nothing-to-clear because entering it already ran the full teardown.
var hasSuggestionArtifactsToClear: Bool {
if overlayState.isVisible || latestSuggestionPreview != nil || latestPromptPreview != nil {
if overlayState.isVisible || interactionState.activeSession != nil {
return true
}

Expand All @@ -1074,18 +1053,9 @@ extension SuggestionCoordinator {
lastAcceptedTail = nil
// Stream bookkeeping follows the session it was rendering for.
suggestionStreamingState.clearSession()
latestSuggestionPreview = nil
latestFullSuggestionPreview = nil
latestRemainingSuggestionPreview = nil
latestAcceptedCharacterCount = nil
latestRemainingCharacterCount = nil
latestAcceptanceAction = nil
latestLatencyMilliseconds = nil
interactionState.clearSuggestion()

if clearDiagnostics {
latestPromptPreview = nil
latestRawModelOutput = nil
latestGenerationNumber = nil
// Clear so the next session's terminal logStage doesn't carry the previous
// request_id forward. `+Acceptance.logStage` falls back to "req_none" on nil,
Expand Down
Loading
Loading