Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ extension SuggestionCoordinator {
caretRect: caretRect,
inputFrameRect: context.inputFrameRect,
caretQuality: context.caretQuality,
isCaretAtEndOfLine: context.isCaretAtEndOfLine,
observedCharWidth: context.observedCharWidth,
isRightToLeft: isRightToLeft,
focusChangeSequence: context.focusChangeSequence,
Expand Down
6 changes: 6 additions & 0 deletions Cotabby/Models/CompletionRenderMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ nonisolated enum CompletionRenderMode: Equatable, Sendable {
/// drifts as the user types.
case caretGeometryEstimated

/// The caret sits mid-line: real characters follow it before the next line break. Inline
/// ghost text would draw on top of those trailing characters, so the suggestion is promoted
/// to the card, which anchors to the field rect instead of the caret. This is also the
/// surface fill-in-middle completions render in, since a FIM result has no inline home.
case caretMidLine
Comment on lines +30 to +34
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 doc comment says the card "anchors to the field rect instead of the caret" for .caretMidLine, but MirrorOverlayLayout.computeAnchorTopY groups .caretMidLine with .userPreference and .perAppOverride, all of which anchor to the caret rect (falling back to the field rect only when the caret rect is empty). Since caret geometry is trustworthy in this case, anchoring to the caret is the intentional choice — the comment just describes the wrong rect.

Suggested change
/// The caret sits mid-line: real characters follow it before the next line break. Inline
/// ghost text would draw on top of those trailing characters, so the suggestion is promoted
/// to the card, which anchors to the field rect instead of the caret. This is also the
/// surface fill-in-middle completions render in, since a FIM result has no inline home.
case caretMidLine
/// The caret sits mid-line: real characters follow it before the next line break. Inline
/// ghost text would draw on top of those trailing characters, so the suggestion is promoted
/// to the card, which anchors to the caret rect (the geometry is trustworthy here), sitting
/// just under the cursor the same way an inline ghost would. This is also the surface
/// fill-in-middle completions render in, since a FIM result has no inline home.
case caretMidLine

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


/// User set their global preference to always use mirror mode. Phase 2 wiring.
case userPreference

Expand Down
9 changes: 9 additions & 0 deletions Cotabby/Models/SuggestionModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,12 @@ struct SuggestionOverlayGeometry: Equatable, Sendable {
let caretRect: CGRect
let inputFrameRect: CGRect?
let caretQuality: CaretGeometryQuality
/// True when the caret is at the end of its line: only whitespace, if anything, precedes the
/// next line break. When false, real characters follow the caret on this line, so the
/// render-mode policy promotes the suggestion to the card: inline ghost text would otherwise
/// paint over those trailing characters. Carried from `FocusedInputContext.isCaretAtEndOfLine`.
/// Defaults to `true` so call sites that predate the mid-line rule keep the prior inline path.
let isCaretAtEndOfLine: Bool
/// Average character width from AX child-frame sampling when available. Layout uses this as a
/// cheap approximation for host-editor text width before falling back to local font metrics.
let observedCharWidth: CGFloat?
Expand Down Expand Up @@ -560,6 +566,7 @@ struct SuggestionOverlayGeometry: Equatable, Sendable {
caretRect: CGRect,
inputFrameRect: CGRect?,
caretQuality: CaretGeometryQuality,
isCaretAtEndOfLine: Bool = true,
observedCharWidth: CGFloat?,
isRightToLeft: Bool,
focusChangeSequence: UInt64 = 0,
Expand All @@ -570,6 +577,7 @@ struct SuggestionOverlayGeometry: Equatable, Sendable {
self.caretRect = caretRect
self.inputFrameRect = inputFrameRect
self.caretQuality = caretQuality
self.isCaretAtEndOfLine = isCaretAtEndOfLine
self.observedCharWidth = observedCharWidth
self.isRightToLeft = isRightToLeft
self.focusChangeSequence = focusChangeSequence
Expand All @@ -585,6 +593,7 @@ struct SuggestionOverlayGeometry: Equatable, Sendable {
caretRect: caretRect,
inputFrameRect: inputFrameRect,
caretQuality: caretQuality,
isCaretAtEndOfLine: isCaretAtEndOfLine,
observedCharWidth: observedCharWidth,
isRightToLeft: isRightToLeft,
focusChangeSequence: focusChangeSequence,
Expand Down
26 changes: 25 additions & 1 deletion Cotabby/Support/CompletionRenderModePolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import Foundation
/// to mirror mode. `alwaysInline` and `alwaysMirror` let power users pin a strategy when the
/// auto rule misfires for their host mix.
///
/// Phase 1 hardcodes `.auto` everywhere; the global setting and per-app overrides land in Phase 2.
/// The global preference is live (Appearance settings Picker); per-app overrides are not wired yet.
/// Note that a mid-line caret promotes inline to the card regardless of this preference; see
/// `CompletionRenderModePolicy.mode(for:bundleIdentifier:)`.
enum MirrorPreference: String, Codable, CaseIterable, Identifiable, Equatable, Sendable {
case auto
case alwaysInline
Expand Down Expand Up @@ -59,6 +61,28 @@ struct CompletionRenderModePolicy: Equatable, Sendable {
func mode(
for geometry: SuggestionOverlayGeometry,
bundleIdentifier: String?
) -> CompletionRenderMode {
let baseMode = preferenceMode(for: geometry, bundleIdentifier: bundleIdentifier)

// A caret parked mid-line (real characters follow it before the next line break) has no
// inline home: ghost text would paint over those trailing characters. Promote any inline
// result to the card, which anchors to the field rect instead of the caret. This deliberately
// overrides an explicit `.alwaysInline` pin too, because inline cannot render mid-line at all,
// and the card is the surface fill-in-middle completions will use. The promotion only
// upgrades inline results; a presentation already routed to the card keeps its original, more
// specific reason (e.g. `.caretGeometryEstimated`).
if case .inline = baseMode, !geometry.isCaretAtEndOfLine {
return .mirror(reason: .caretMidLine)
}
return baseMode
}

/// The render mode implied by the user (or per-app) preference and caret-geometry quality, before
/// the mid-line override in `mode(for:bundleIdentifier:)` is applied. Split out so that override
/// reads as a single, well-scoped rule rather than another branch threaded through the switch.
private func preferenceMode(
for geometry: SuggestionOverlayGeometry,
bundleIdentifier: String?
) -> CompletionRenderMode {
let effectivePreference: MirrorPreference
if let bundleIdentifier, let override = perAppOverrides[bundleIdentifier] {
Expand Down
12 changes: 6 additions & 6 deletions Cotabby/Support/MirrorOverlayLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,11 @@ struct MirrorOverlayLayout: Equatable {
/// - `.caretGeometryEstimated` means the host did not expose any of the trusted caret paths, so
/// the caret rect itself is unreliable. We anchor to the input field rect when available
/// because the field rect stays stable even when the caret estimate drifts.
/// - `.userPreference` and `.perAppOverride` mean the user pinned popup mode despite the caret
/// geometry being trustworthy (`.exact` or `.derived`). Anchoring to the field rect in this
/// case wastes the precise caret signal and lands the card far below where the eye is. We
/// anchor to the caret rect instead, with the input field as a safety net only for the
/// degenerate case where the caret rect is empty.
/// - `.userPreference`, `.perAppOverride`, and `.caretMidLine` all mean the caret geometry is
/// trustworthy (`.exact` or `.derived`); the card is up because the user pinned popup mode or
/// the caret is mid-line. Anchoring to the field rect would waste the precise caret signal and
/// land the card far below where the eye is, so we anchor to the caret rect instead, with the
/// input field as a safety net only for the degenerate case where the caret rect is empty.
private static func computeAnchorTopY(
geometry: SuggestionOverlayGeometry,
reason: CompletionRenderMode.MirrorReason
Expand All @@ -169,7 +169,7 @@ struct MirrorOverlayLayout: Equatable {
// height as unreliable; the extra slack keeps the card from overlapping the typed line.
return geometry.caretRect.minY - Metrics.caretFallbackVerticalOffset

case .userPreference, .perAppOverride:
case .userPreference, .perAppOverride, .caretMidLine:
// Caret geometry is trustworthy in these cases. Sit just under the caret line so the
// popup tracks the cursor like the inline ghost does, instead of floating below the
// entire field.
Expand Down
104 changes: 104 additions & 0 deletions CotabbyTests/CompletionRenderModePolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,108 @@ final class CompletionRenderModePolicyTests: XCTestCase {
.mirror(reason: .userPreference)
)
}

// MARK: - Mid-line caret promotion

func test_auto_midLineCaret_promotesExactGeometryToMirror() {
// Exact geometry renders inline at end of line, but a caret with real characters after it has
// no inline home (the ghost would paint over the trailing text), so it promotes to the card.
let policy = CompletionRenderModePolicy(userPreference: .auto)
let geometry = CotabbyTestFixtures.overlayGeometry(
caretQuality: .exact,
isCaretAtEndOfLine: false
)

XCTAssertEqual(
policy.mode(for: geometry, bundleIdentifier: "com.apple.TextEdit"),
.mirror(reason: .caretMidLine)
)
}

func test_auto_midLineCaret_promotesDerivedGeometryToMirror() {
let policy = CompletionRenderModePolicy(userPreference: .auto)
let geometry = CotabbyTestFixtures.overlayGeometry(
caretQuality: .derived,
isCaretAtEndOfLine: false
)

XCTAssertEqual(
policy.mode(for: geometry, bundleIdentifier: "com.google.Chrome"),
.mirror(reason: .caretMidLine)
)
}

func test_auto_midLineCaret_keepsEstimatedReasonRatherThanOverwriting() {
// Estimated geometry already routes to the card; the promotion only upgrades inline results,
// so the more specific geometry reason is retained instead of being relabeled mid-line.
let policy = CompletionRenderModePolicy(userPreference: .auto)
let geometry = CotabbyTestFixtures.overlayGeometry(
caretQuality: .estimated,
isCaretAtEndOfLine: false
)

XCTAssertEqual(
policy.mode(for: geometry, bundleIdentifier: "com.microsoft.VSCode"),
.mirror(reason: .caretGeometryEstimated)
)
}

func test_alwaysInline_midLineCaret_isOverriddenToMirror() {
// Inline cannot render mid-line, so the mid-line rule overrides even an explicit inline pin.
// At the end of a line the pin is still honored (see the next test).
let policy = CompletionRenderModePolicy(userPreference: .alwaysInline)
let geometry = CotabbyTestFixtures.overlayGeometry(
caretQuality: .exact,
isCaretAtEndOfLine: false
)

XCTAssertEqual(
policy.mode(for: geometry, bundleIdentifier: nil),
.mirror(reason: .caretMidLine)
)
}

func test_alwaysInline_endOfLineCaret_staysInline() {
let policy = CompletionRenderModePolicy(userPreference: .alwaysInline)
let geometry = CotabbyTestFixtures.overlayGeometry(
caretQuality: .exact,
isCaretAtEndOfLine: true
)

XCTAssertEqual(
policy.mode(for: geometry, bundleIdentifier: nil),
.inline
)
}

func test_alwaysMirror_midLineCaret_keepsUserPreferenceReason() {
// Already a card; the promotion never runs, so the user-preference reason is preserved.
let policy = CompletionRenderModePolicy(userPreference: .alwaysMirror)
let geometry = CotabbyTestFixtures.overlayGeometry(
caretQuality: .exact,
isCaretAtEndOfLine: false
)

XCTAssertEqual(
policy.mode(for: geometry, bundleIdentifier: nil),
.mirror(reason: .userPreference)
)
}

func test_perAppInlineOverride_midLineCaret_isOverriddenToMirror() {
// A per-app inline override is still a request to render inline, which mid-line can't honor.
let policy = CompletionRenderModePolicy(
userPreference: .auto,
perAppOverrides: ["com.example.InlinePinned": .alwaysInline]
)
let geometry = CotabbyTestFixtures.overlayGeometry(
caretQuality: .exact,
isCaretAtEndOfLine: false
)

XCTAssertEqual(
policy.mode(for: geometry, bundleIdentifier: "com.example.InlinePinned"),
.mirror(reason: .caretMidLine)
)
}
}
2 changes: 2 additions & 0 deletions CotabbyTests/CotabbyTestFixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,15 @@ enum CotabbyTestFixtures {
caretRect: CGRect = CGRect(x: 10, y: 20, width: 2, height: 18),
inputFrameRect: CGRect? = CGRect(x: 0, y: 0, width: 240, height: 32),
caretQuality: CaretGeometryQuality = .exact,
isCaretAtEndOfLine: Bool = true,
observedCharWidth: CGFloat? = nil,
isRightToLeft: Bool = false
) -> SuggestionOverlayGeometry {
SuggestionOverlayGeometry(
caretRect: caretRect,
inputFrameRect: inputFrameRect,
caretQuality: caretQuality,
isCaretAtEndOfLine: isCaretAtEndOfLine,
observedCharWidth: observedCharWidth,
isRightToLeft: isRightToLeft
)
Expand Down