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
124 changes: 114 additions & 10 deletions Cotabby.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions Cotabby/Models/Suggestion/ActiveSuggestionSession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import Foundation

/// Durable interaction state for type-through and partial acceptance after generation completes.

/// Represents one active inline-completion session after the model has produced a suggestion.
/// The key architectural shift is that a suggestion is no longer "fire once and forget."
/// Instead, it becomes durable interaction state that can be partially consumed over time.
struct ActiveSuggestionSession: Equatable, Sendable {
/// The focused field state that produced the original suggestion.
/// We keep this as the anchor so later text changes can be interpreted as:
/// "user consumed part of the suggestion" vs "user diverged from it."
let baseContext: FocusedInputContext
let fullText: String
let consumedCharacterCount: Int
let latency: TimeInterval
/// `.continuation` for normal forward suggestions; `.correction(typoWord:)` when the session
/// represents a typo fix. The acceptance path branches on this so corrections always commit the
/// whole word and replace the typo rather than appending forward text.
let kind: SuggestionKind

init(
baseContext: FocusedInputContext,
fullText: String,
consumedCharacterCount: Int = 0,
latency: TimeInterval,
kind: SuggestionKind = .continuation
) {
self.baseContext = baseContext
self.fullText = fullText
self.consumedCharacterCount = min(max(consumedCharacterCount, 0), fullText.count)
self.latency = latency
self.kind = kind
}

var acceptedText: String {
fullText.leadingCharacters(consumedCharacterCount)
}

var remainingText: String {
fullText.droppingLeadingCharacters(consumedCharacterCount)
}

var acceptedCount: Int {
consumedCharacterCount
}

var remainingCount: Int {
remainingText.count
}

/// A whitespace-only tail is effectively exhausted for inline UX.
/// Showing "ghost spaces" is visually confusing and not worth preserving.
var isExhausted: Bool {
remainingText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}

/// Returns a new session advanced by the accepted or typed character count.
/// The original value stays unchanged because this type models immutable interaction state.
func advancing(by consumedCharacters: Int) -> ActiveSuggestionSession {
ActiveSuggestionSession(
baseContext: baseContext,
fullText: fullText,
consumedCharacterCount: self.consumedCharacterCount + max(consumedCharacters, 0),
latency: latency,
kind: kind
)
}

/// Rebuilds the session from a fully observed live editor state during reconciliation.
/// This is useful when AX catches up after optimistic UI updates such as partial Tab accepts.
func withConsumedCharacters(_ consumedCharacters: Int) -> ActiveSuggestionSession {
ActiveSuggestionSession(
baseContext: baseContext,
fullText: fullText,
consumedCharacterCount: consumedCharacters,
latency: latency,
kind: kind
)
}
}

/// Records the chunk committed by the most recent full acceptance and the field text it was
/// appended after. The coordinator stamps this on a final-chunk accept and consumes it on the next
/// generation. If the model only re-proposes `text` while the live preceding text still equals
/// `precedingText`, the host has not published our insert yet (the Chromium AX-publish race), so the
/// suggestion is dropped instead of looping accept/regenerate/accept on the last word.
struct AcceptedSuggestionTail: Equatable, Sendable {
let text: String
let precedingText: String
}

private extension String {
/// Swift `String` is a collection of extended grapheme clusters, not bytes.
/// These helpers slice by user-visible characters so emoji and composed characters stay intact.
/// That matters because autocomplete acceptance is a user-facing action, not a byte-level one.
func leadingCharacters(_ count: Int) -> String {
String(prefix(max(count, 0)))
}

func droppingLeadingCharacters(_ count: Int) -> String {
String(dropFirst(max(count, 0)))
}
}
118 changes: 118 additions & 0 deletions Cotabby/Models/Suggestion/FocusedInputContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import CoreGraphics
import Foundation

/// Stable, bounded focus state captured before debounce and carried through generation.
/// Keeping this value independent of live AX elements is a central stale-result safety boundary.

/// This is the stable context used across debounce and generation boundaries.
/// It extends the AX snapshot with a monotonically increasing generation number.
struct FocusedInputContext: Equatable, Sendable {
let applicationName: String
let bundleIdentifier: String
let processIdentifier: Int32
let elementIdentifier: String
let role: String
let subrole: String?
let caretRect: CGRect
let inputFrameRect: CGRect?
let caretQuality: CaretGeometryQuality
/// Human-readable label of the geometry source that produced `caretRect` (e.g. "derived
/// primary"), carried through so presentation-time repair logs can attribute a misplaced
/// overlay to the exact resolver branch.
let caretSource: String
/// Average character width in points observed from AX child frame measurements.
/// Used by caret prediction after tab insertion to match the target app's actual font.
let observedCharWidth: CGFloat?
/// Content edges measured from the host's child text-run frames, carried through so the caret
/// layout estimator can anchor to the field's real padding instead of guessed insets.
let observedContentEdges: ObservedContentEdges?
let precedingText: String
let trailingText: String
let selection: NSRange
let isSecure: Bool
/// Whether the field's text is rendered by a web engine (see `WebContentFieldDetector`),
/// carried through so the presentation-time caret repair can scope estimator authority to
/// hosts whose AX caret geometry actually needs repairing.
let isWebContentField: Bool
/// The host field's own text font/color, carried through so the overlay can match it.
let resolvedFieldStyle: ResolvedFieldStyle?
/// Surface metadata captured once per field session, carried through so the request factory
/// can condition the prompt on what the user is writing in (see `SurfaceContextComposer`).
let windowTitle: String?
let fieldPlaceholder: String?
let focusedURLString: String?
let isIntegratedTerminal: Bool
/// Carries the immutable focus-observation identity across debounce/generation boundaries.
/// Without this, later visual-context lookups could fall back to `elementIdentifier` alone and
/// reintroduce the CFHash collision class this sequence is meant to avoid.
let focusChangeSequence: UInt64
let generation: UInt64

init(snapshot: FocusedInputSnapshot, generation: UInt64) {
applicationName = snapshot.applicationName
bundleIdentifier = snapshot.bundleIdentifier
processIdentifier = snapshot.processIdentifier
elementIdentifier = snapshot.elementIdentifier
role = snapshot.role
subrole = snapshot.subrole
caretRect = snapshot.caretRect
inputFrameRect = snapshot.inputFrameRect
caretQuality = snapshot.caretQuality
caretSource = snapshot.caretSource
observedCharWidth = snapshot.observedCharWidth
observedContentEdges = snapshot.observedContentEdges
precedingText = snapshot.precedingText
trailingText = snapshot.trailingText
selection = snapshot.selection
isSecure = snapshot.isSecure
isWebContentField = snapshot.isWebContentField
resolvedFieldStyle = snapshot.resolvedFieldStyle
windowTitle = snapshot.windowTitle
fieldPlaceholder = snapshot.fieldPlaceholder
focusedURLString = snapshot.focusedURLString
isIntegratedTerminal = snapshot.isIntegratedTerminal
focusChangeSequence = snapshot.focusChangeSequence
self.generation = generation
}

var identity: FocusedInputIdentity {
FocusedInputIdentity(
elementIdentifier: elementIdentifier,
focusChangeSequence: focusChangeSequence
)
}

/// True when the caret is at the end of its line (only whitespace, if anything, before the next
/// line break). Derived from `trailingText` via `CaretLinePosition`; used to decide when a
/// mid-line completion strategy like fill-in-middle applies versus a plain forward continuation.
var isCaretAtEndOfLine: Bool {
CaretLinePosition.isAtEndOfLine(trailingText: trailingText)
}

/// Stable per-process key for the focused field, intentionally NOT including the input frame
/// rect. The polling signature in `FocusTracker` bumps `focusChangeSequence` whenever the
/// field's frame changes (e.g., a chat composer growing taller as the user types wraps onto a
/// second line). For consumers that should treat self-resizing as "same field" — chief among
/// them ghost-font stabilization — this key gives them a session identity that survives field
/// growth. `hashValue` is randomized per process, which is fine: the key is only ever compared
/// within one process's lifetime.
var focusedInputIdentityKey: UInt64 {
var hasher = Hasher()
hasher.combine(bundleIdentifier)
hasher.combine(processIdentifier)
hasher.combine(elementIdentifier)
return UInt64(bitPattern: Int64(hasher.finalize()))
}

/// Content-only fingerprint — mirrors `FocusedInputSnapshot.contentSignature`.
/// See that type's doc comment for why `elementIdentifier` is excluded.
var contentSignature: String {
[
String(selection.location),
String(selection.length),
precedingText,
trailingText,
isSecure ? "secure" : "plain"
].joined(separator: "::")
}
}
22 changes: 22 additions & 0 deletions Cotabby/Models/Suggestion/SuggestionClientError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

/// Errors shared by suggestion backends and coordinator-facing normalization.

/// Errors specific to suggestion generation and normalization.
enum SuggestionClientError: LocalizedError {
case unavailable(String)
case unsupportedLanguageOrLocale(String)
case generationFailed(String)
case cancelled

var errorDescription: String? {
switch self {
case let .unavailable(message),
let .unsupportedLanguageOrLocale(message),
let .generationFailed(message):
return message
case .cancelled:
return "Generation was cancelled."
}
}
}
Loading