Skip to content

Match ghost text to the host's own rendering, and finish the word being typed - #827

Open
Mason363 wants to merge 88 commits into
FuJacob:mainfrom
Mason363:fix/ghost-text-engine
Open

Mason363 wants to merge 88 commits into
FuJacob:mainfrom
Mason363:fix/ghost-text-engine

Conversation

@Mason363

Copy link
Copy Markdown

Summary

Inline ghost text now reads as the host app's own text: it is set in the host's face and size, on the host's baseline, from the caret the host actually shows, and wraps onto the host's own lines at its line pitch. Where Accessibility can't say where the caret is or how the text is set (CodeMirror editors such as Obsidian, Chromium's address bar, Electron apps with bundled fonts, contenteditables that answer no bounds queries), the answer is read from the host's own pixels. Completions now finish the word being typed instead of starting another, and the ghost no longer shows punctuation runs, text that duplicates what follows the caret, or prompt scaffolding.

Every placement change was measured before it landed: a harness types into real hosts, captures the ghost, accepts it, captures the host's own rendering of the same words, and reports the ghost's offset per line. Each commit message records the measurement behind it.

Where the changes are:

  • Placement and typography. GhostTextLayout, GhostFontResolver and GhostBaselinePolicy lay the ghost out, and HostTextMetricsProbe finds the host's line box and pitch. The host's size comes from its own caret advance (CaretAdvanceSampler, HostAdvanceFit) and its face from a pixel match (TypefaceMatcher, including faces an Electron app bundles, HostBundledFontRegistry), snapped to the zoom step (HostZoomLadder), held steady (TypefaceEvidence) and remembered per app style (HostFaceMemory). The baseline comes from pixels (HostBaselineCalibrator), and so does the caret where AX has none (PixelCaretLocator, InkCaretAnalyzer).
  • Never over the host's text. A caret with text after it on its line gets the card under the caret (CompletionRenderModePolicy), and a single-line field keeps the ghost to one row. Chromium paragraphs are read from AXValue rather than the run-together range text (BlockBreakAlignment), and marker lines from glyph boxes.
  • Text the host owns. macOS inline predictions, Chromium's address-bar completion and Gmail's Smart Compose suggestion are held as the host's marked text (HostMarkedTextPolicy). Mail's header rows are left to Mail, where Tab moves to the next field (MailHeaderFieldDetector), and a caret that still lags text the host already published holds the ghost (CaretLagPolicy).
  • Completions. A mid-word request prompts up to the word boundary and requires the typed letters (WordBoundaryAnchorPolicy with the engine's setRequiredPrefix). The leading space is decided against the live text and the model's own word boundary (GhostSpaceBoundary), and output cleanup drops punctuation runs, duplicated trailing text and scaffolding (CompletionContentPolicy). Typed line breaks reach the prompt, the writer's name enters only at a sign-off (SignOffCue), the llama window is 4096 tokens, and the next suggestion is prefetched into the anchor cache. The engine's abort target is withdrawn once the prompt is decoded, so a keystroke's cancel can no longer kill the next request (LlamaAbortTarget).
  • Evals and tooling. A context-recall eval (test_reportRecallSuite), a per-run model override for the eval suites, and developer tooling (forced suggestions, key tracing, a focus-overlay switch, capture dumps).
  • Docs. AGENTS.md, ARCHITECTURE.md, and the README's Screen Recording line.

Validation

xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' \
  CODE_SIGNING_ALLOWED=NO build-for-testing
xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' \
  CODE_SIGNING_ALLOWED=NO test-without-building
# Executed 2109 tests, with 8 tests skipped and 0 failures
# (the 8 skipped are the local, model-backed evals)

swiftlint lint --strict --quiet
# exit 0, no violations

xcodegen generate && git diff --exit-code -- Cotabby.xcodeproj
# no diff (XcodeGen 2.45.4)

Live pixel alignment on the final build, with the harness described above (every keystroke guarded against a focus change or a system prompt):

  • Obsidian, one wrapped paragraph (two passes) and several paragraphs: every line within 0.03pt vertically, and −0.33 to +0.03pt horizontally.
  • Obsidian, short capital-led openings: within 0.02pt vertically and 0.11pt horizontally, except one opening ("Should ") that starts 0.48pt left.
  • TextEdit (Helvetica 12): every line within 0.01pt both ways.
  • Chrome, a monospaced field: every line that fits in the field within 0.01pt both ways.
  • Chrome forms at 100% zoom (contenteditable, ProseMirror, textbox, Lexical, iframe, shadow DOM, textarea, input): a ghost in every field, each mid-line suggestion in the card 1pt under its caret, and the ghost's words within −0.33 to +0.30pt of where the host then draws them, Georgia +0.65pt (see the limitations below).

Evals, local (gemma-4-E2B Q6_K, 117 continuation cases), measured during development: quality 0.734 → 0.855, precision when shown 0.780 → 0.901, wrong-show rate 0.188 → 0.077. Context recall: 18 of 23 facts (16 with the previous 2048-token window).

Not verified end to end: the address bar's size fit and the Gmail Smart Compose hold are covered by unit tests and replays of real captures only. The suite ran on macOS 26; CI runs it on macOS 15.

Linked issues

Depends on FuJacob/cotabbyinference#13.

Risk / rollout notes

  • Engine dependency. Until Add a required-prefix constraint for word completion cotabbyinference#13 merges, project.yml resolves CotabbyInference from the feat/required-prefix branch of Mason363/cotabbyinference (upstream main plus that one commit). Once it merges, a follow-up commit here points it back at FuJacob/cotabbyinference main and regenerates the project.
  • pbxproj. Regenerated with XcodeGen 2.45.4: the same build files, in the order XcodeGen writes them.
  • Settings. A new defaults key, cotabbyHostFaceMemory, keeps the settled face and line pitch per app style across launches; browser page origins are never written. Opt-out switches: cotabbyContinuationPrefetchDisabled and cotabbyChromiumEnhancedAccessibilityDisabled (below). Developer switches, off by default: cotabbyDebugForcedSuggestion (read only under -cotabby-debug), cotabbyDebugFocusOverlayHidden, and cotabbyDumpCalibrationStrips (writes captures under ~/Library/Logs).
  • Chromium accessibility. In Chromium browsers, Cotabby now sets AXEnhancedUserInterface (the VoiceOver signal) so Chrome computes character bounds and font attributes; Chrome rejects AXManualAccessibility, the Electron flag. Some window managers misplace windows of apps with that flag set, so cotabbyChromiumEnhancedAccessibilityDisabled turns it off, and placement then uses caret markers with font matching by size only.
  • Permissions. Pixel measurements use ScreenCaptureKit and run only when Screen Recording is granted; without it they are skipped and placement follows Accessibility.
  • Behavior users will notice. Mid-line suggestions appear in the card under the caret rather than over the text, single-line fields never wrap the ghost, the ghost waits while the host shows its own prediction, Mail's header rows get no suggestions, and a mid-word completion finishes the typed word.
  • Performance. A pixel read is one small ScreenCaptureKit capture, about 40ms off the main actor; the typeface match and size fit also run off the main actor. The llama window doubled to 4096 tokens: more KV memory, with no latency regression in the evals.
  • Known limitations. Chromium reports its caret in whole points, so in Chrome a ghost can start a quarter to half a point off horizontally on some lines (measured −0.25 to +0.75pt; averaging the readings was tried and reverted). Carets read from pixels (Obsidian, the address bar) land on a device pixel, up to half a point off. Semibold and bold web text get the regular face. SF in zoomed Chrome (zoom other than 100%) runs about 1.6% narrow. A field's first wrap, before its pitch can be measured, steps by the caret box height. Web fonts that aren't installed get the system face scaled to the host's advance, a few percent off in glyph widths.
  • Size. 151 files and 88 commits, including three reverts and their measured re-lands, in the order they were made. Squash-merging works, and I'm happy to split this into smaller PRs if that's easier to review.

…ine and lines

The inline ghost is now laid out and drawn by CoreText in the host's exact typeface at the host's exact size, on the host's baseline, and wrapped where the host wraps:

- GhostFontResolver picks the face from the field's reported PostScript name or family, else matches a web host's measured text width against common families at the reported size, else solves the size from the caret box.
- GhostBaselinePolicy asks TextKit for its default baseline offset for native fields and reproduces Blink/WebKit's rounded ascent and descent (including the legacy Times/Helvetica/Courier ascent adjustment) for web fields.
- GhostTextLayout builds rows from the caret with CTTypesetter line breaking inside a wrap band; accepted or typed-through prefixes only advance a consumed offset so remaining glyphs never move. GhostWrapBandPolicy derives the band from the element's real frame and its measured line-box inset rather than the widened card frame.
- GhostTextPanelView draws rows with CTLineDraw at whole-point panel origins, since AppKit rounds window frames to whole points.
- HostTextMetricsProbe/HostTextMetricsCache measure the host's width sample, line box and line pitch once per field, retrying Chromium's lazily loaded bounds and re-measuring when the frame moves or the caret first reaches measurable text.
- HostBaselineCalibrator measures a web host's painted baseline from its own pixels (Screen Recording permitting) to recover the sub-point line position Accessibility rounds away; InkBaselineAnalyzer is the pure detector.
- The caret resolver prefers the previous character's trailing edge (TextKit's zero-length rect is one line high), corrects zero-length rects from line boxes, and accepts zero-width marker rects.

Removes GhostSuggestionLayout, GhostFontMetrics and GhostFontSizeStabilizer, whose caret-height-derived sizing and legibility floor were the source of oversized, drifting ghosts. Pixel-alignment tests render the ghost and an NSTextView offscreen and compare glyph positions.
- Hold, instead of invalidating, while the host shows uncommitted text of its own. macOS inline predictive text appears in every NSTextView's AXValue right after the caret (TextEdit showed "mps" after "ju"), which read as "text after the caret changed" and dropped the live tail, then re-showed a card on top of the system prediction. The resolver now reads AXTextInputMarkedRange, strips a prediction after the caret from the trailing text, and the coordinator pauses generation and hides the ghost while marked text exists, keeping the session; typed characters still advance it silently and the ghost returns on the next clean snapshot.
- Treat notification/control-centre agents as focus shadows: when a system alert owns the system-wide focused element while another app is frontmost, resolve focus from the frontmost app instead of disabling autocomplete.
- Enable Chromium's full accessibility mode (AXEnhancedUserInterface) when a Chromium host answers attributeUnsupported, so inline text boxes and range bounds become available.
- Say how many trailing characters changed when a trailing-text reconciliation fails.
…overlay switch

Under -cotabby-debug: a forced suggestion text (defaults key cotabbyDebugForcedSuggestion) replaces the engine so ghost placement can be measured deterministically; every observed key lands in the trace log; and the caret badge/frame overlays can be hidden (cotabbyDebugFocusOverlayHidden) so they do not paint over host text during pixel comparisons.
Chromium contenteditables report a font size and nothing else: no family, and AXBoundsForRange answers an empty rect, so the width match had nothing to work with and Gmail/Slack/Notion-style editors got the system face (measured live: Georgia 18px rendered as SF, 0.7pt of drift per word). The strip of host text already captured for baseline calibration is now also compared, as a column ink profile, against the known text rendered right-aligned at the caret in each common face; the best normalized correlation names the face (Georgia scored 0.94 against a 0.67 runner-up). The match is cached per field and size, applied to the first ghost when the generation-time prewarm finished in time and otherwise on the next present, and the analysis runs off the main actor. Guards keep the caret column, which lies past the strip's edge, inside every profile.
…ng punctuation, duplicates and scaffolding

Measured on the llama eval (gemma-4-E2B Q6_K, 117 cases): quality 0.734 → 0.842, precision when shown 0.780 → 0.919, wrong-show rate 0.188 → 0.060, p50 latency 113ms → 94ms.

- Word-boundary anchoring: a mid-word request ("…I really apprec") leaves the partial word out of the prompt so the model completes a whole word from its boundary, and the normalizer shows only the remainder ("iate it!") or nothing when the model chose another word. Thirteen of fourteen mid-word eval cases had been misspelled continuations ("rd to this", "etly try") suppressed by the seam guard; four now insert correctly and the rest suppress cleanly. A lone partial word keeps the plain prompt.
- The typo gate lets a word still being typed through when the spell checker can complete it ("apprec" → "appreciate"), instead of suppressing or correcting mid-keystroke; a finished word keeps the typo behavior.
- No generation with the caret inside a token ("head|phones", "3|:30", "jane|@example.com"): every eval dup-trailing case showed a duplicate there.
- Content policy on normalized output: punctuation-only completions, closing punctuation right after a typed space, forum/chat UI residue, stray markup in prose and the model talking back about the prompt are suppressed with attributable reasons.
- The base-model preface is terse metadata ("Email draft.", "Website: notion.so.", "Window title: …") instead of a sentence about typing, which the model had answered ("I'm not sure what you mean by 'the text is being typed in Google Chrome'").
- The eval harness mirrors the production typo gate and caret gate so it scores what users see.
Measured in Safari against accepted text: all four test fields (Menlo textarea, Helvetica input, Georgia and system-font contenteditables) now land on the same device pixels as the host; VS Code's Search field shows an inline ghost instead of the card.

- WebKit rounds caret and character boxes to enclosing pixels, which put the ghost a full point right of the host's text. When the host names its exact face and the caret's line is not soft-wrapped, the caret x is recomputed as the line's left edge plus the text's own advance in that face (GhostCaretRefinement), trusted only within rounding distance of the reported caret.
- WebKit's caret box for a loose CSS line-height (Georgia at 1.6) is not the line box the baseline policy assumes; its text sat 3.5pt below the policy value. The pixel calibration tolerance is widened to 4pt, and the ink analyzer now stops at the first contiguous block of letter-body rows so a spell-check underline (Safari's squiggle under "juliet" read 3.5pt low) can no longer pass as the baseline.
- A width-sample re-measure taken at the end of the text, where WebKit answers no line, no longer discards the line box learned at focus time.
- A single-row field too narrow for the accept-key pill (VS Code's Search box) lays the ghost out without the pill rather than declining inline.
…s, measured native baselines

Measured in Xcode's source editor and VS Code's Monaco editor (screen-reader mode) against accepted text: both now sit on the host's baseline with identical glyph edges.

- Xcode names its editor face "SFMono-Medium" (family "SF Mono"), which NSFont(name:) cannot load; the ghost fell back to a scaled proportional system face. SF Mono names and family now resolve through the monospaced system font at the reported weight.
- A named face whose measured advances disagree with the face at the reported size by more than rounding keeps the face and scales its size to the host's own measurement.
- Xcode puts its extra line spacing below the text where TextKit puts it above, so the policy baseline sat 1pt high. Pixel baseline calibration now also runs for native fields whose caret box is taller than the face's default line fragment; plain TextKit fields keep the exact policy answer without a capture.
- Code editors built on a hidden textarea (Monaco) report an element only as wide as the current line's text, so a single-row ghost never fit and fell back to the card; for code editors the wrap band comes from the editor container instead.
A suggestion that does not fit the caret's line now continues on the host's
next lines wherever the line pitch is known, even when host text sits there:
each continuation row is backed by an opaque band in the field's own
background color, and a caret with text after it on its line gets a band
from the pen to the band's edge instead of being sent to the card. The
color comes from the field's pixels (HostBackgroundSampler through the
calibrator), measured separately on the caret's line and the line below
because code editors tint the current line; without Screen Recording the
ghost keeps to rows over blank space as before and a mid-line caret still
falls back to the card. Bands tile at the measured pitch and are clipped
to the element frame.

A host that offers a single row (no pitch) shows the head of the
suggestion that fits, at a word boundary, rather than nothing; acceptance
and type-through reveal the rest. CoreText splits a word that does not
fit its budget, so the caret row keeps whole words and moves a word that
does not fit to the next line like the host does.

Measured live: TextEdit and Chrome textarea, band color equal to the
field background within 1/255, wrapped row on the host's next line with
zero pixel shift against the accepted text.
Chromium answers AXLineForIndex with indices that never yield a usable line
above, so a Chrome textarea never had a line pitch and its ghost could not
wrap. The probe now asks the bounds of single characters at word starts
before, then after, the caret until one answers from another visual line,
and divides the farthest delta by the lines it spans: web engines snap each
line's top to whole pixels (Chrome at line-height 16.25px answered 16 and
17 on consecutive lines), so averaging over several lines recovers the
fractional pitch. A field whose only line is the caret's re-measures once
the caret has travelled far enough that the text may have wrapped, a
bounded number of times.
Measured while typing prose at 90ms a key into a Chrome textarea with the
shipped Gemma model, the ghost was mostly junk mid-word: the prompt ended
in a word cut at an arbitrary byte and the model continued the tokens it
saw ("yest" -> "arday", "sen" -> "-ding", "th" -> "x! Jacob."), and
anchoring the prompt at the word boundary instead left the model free to
pick any next word, which matched the typed letters 8 times in 67.

Every mid-word llama request is now anchored at the word boundary and the
engine is handed the boundary whitespace plus the typed letters as a
required prefix: it masks every token inconsistent with them until they
are produced, so the model finishes the word the user started from a
prompt whose last token is a whole word. The constraint lives in the
CotabbyInference fork (Mason363/cotabbyinference, feat/required-prefix)
this branch resolves until it lands upstream. The coordinator's mid-word
retry and its eval mirror go away with it.

Same paragraph, before and after: generations 148 -> 69 (the right
suggestion is typed through instead of regenerated), shown 73 -> 67 of
them with 59 -> 0 boundary mismatches, ghost on screen 66% -> 94% of the
typing time at 90ms a key and 96% at 250ms; TextEdit 79%.

Also: suggestions are held to the word-count preset (a length policy trims
to the upper bound at a clause boundary and the decoder's sentence stop
waits for the lower bound), streaming partials are on by default,
completions that loop a short word sequence or copy the text just before
the caret are rejected, and the llm-io log records the anchor.
Obsidian exposes each paragraph as one static-text run whose frame is the
union of its wrapped lines, plus a single-space spacer run at the start of
every line. The spacer runs anchored at arbitrary spaces in the parent text
and pushed the caret onto neighbouring runs, so the ghost landed two lines
away and flapped between lines while typing (measured: 58 jumps in 70s).
Whitespace-only runs no longer anchor. A caret inside a union run now
carries the run's frame and its paragraph up to the caret, plus the line
pitch and line box measured from the sibling single-line runs; the
coordinator lays that paragraph out inside the frame at that pitch and
takes the line the caret lands on, which also gives the ghost the pitch it
needs to wrap in those editors. Result on the same note: one initial jump,
no card, ghost on screen 88% of the typing time.

Switching apps left the previous app's context in place, so every snapshot
in the new app read as a field change and cancelled the pending generation
before it could run; the new field is adopted at once.
Chrome's address bar completes a typed prefix inline and leaves the added
text selected through to the end of the value, which read as a user
selection and blocked every keystroke. A selection shaped like that, after
at least one typed character, in a Chromium text field is the browser's
suggestion: it is stripped and the caret stays where the user stopped.
Predictions then show in the address bar (as the card, since it exposes
no glyph geometry).
A field whose line pitch is not yet measured (a web field's first line)
wraps on the caret box height instead of falling back to the card: the row
can be a pixel off until the field has a second line to measure, which
beats the card at the end of a line. A completion opening with punctuation
(". I'll be") binds the punctuation to the word after it so one Tab accepts
". I'll" instead of the lone period. The prompt's prefix window is bounded
by the token budget rather than a 150-word cap, so long documents reach the
model. The copied-text rule now fires only when a completion restarts the
words right before the caret: a document that repeats a paragraph makes
reproducing it the right prediction (a whole-window rule had thrown away
168 of 179 completions in such a note).
Typing the exact text a suggestion proposed made the ghost disappear in
Chromium contenteditable fields (Chrome, and any Electron/CodeMirror
editor on the same engine). The tail should just advance as the user
types it, never vanish.

Root cause: Chromium stores a space typed at the end of a line as a
non-breaking space (U+00A0) and rewrites it back to U+0020 once the next
character lands. SuggestionSessionReconciler compared the live preceding
text against the session's frozen base with plain string equality, so the
NBSP/space swap read as a divergence and invalidated the session on the
next focus snapshot: the space keystroke tripped "typed text diverged"
and the following letter tripped "text no longer matches the anchor".

Fix: normalize U+00A0 to U+0020 on both sides of every reconciliation
comparison (trailing text, prefix anchor, consumed suffix, and the
consumed-text prefix check). The substitution is one-for-one in UTF-16,
so slice offsets are unchanged.

Also adds debug-only, escaped-text diagnostics that made this findable:
the reconciler mismatch (both sides of the failing comparison with
invisible characters escaped), the typed-vs-expected mismatch on the
input path, and the visible ghost tail on the inline-present record.
All are gated to -cotabby-debug.

Verified live in Chrome against a local test page (Georgia contenteditable and
a Menlo textarea): type-through went from 12/24 characters with a hide on
every space to 23/23 with zero disappearances and zero divergence hides.
Full suite: 1881 tests, 0 failures; swiftlint --strict clean.
Cotabby feeds the model three kinds of context it did not type: earlier
text in the same field, screen OCR, and the clipboard. Nothing measured
whether any of it survives into the completion. The existing 117-case
suite is all generic email/chat continuation, which a model can score
well on while ignoring context entirely, so "does it actually use what
it was given" had no number at all.

This adds that number:

- A `recall` expectation kind whose cases hide a fact in the context and
  require the completion to reproduce it. Suppression scores as a miss
  rather than an acceptable abstention, because the fact was there and
  the completion was supposed to carry it. Matching folds out case and
  punctuation, so "WF 1000XM5" counts the same as "WF-1000XM5".
- Per-case `visualContextSummary` and `clipboardContext`, injected into
  the real request factory. Clipboard is enabled only for cases that
  supply it, so the continuation suite keeps the exact prompt shape it
  has always been scored against.
- 23 cases over five context sources: screen (7), same-field short range
  (6), same-field long range (5), same-field past the prompt budget (2),
  and clipboard (3).
- A dataset invariant that every required fact actually appears in that
  case's own context. Without it a case can quietly drift into demanding
  a fact the model was never given, and score a hallucination as recall.

Reported separately from the continuation suite on purpose: averaging
the two would let fluent prose hide a total failure to use context.

Baseline on the shipped model (gemma-4-E2B Q6): 16/23. Numbers and codes
recall reliably; person names fail every time, and the model tends to
answer generically ("it.", "the following address:") when it has the
fact available.
Facts more than ~1900 tokens before the caret were invisible: the llama
context window was 2048, and the prompt budget derived from it left
about 1934 tokens for preface plus prefix. The prefix cap of 7000
characters bound at roughly the same point, so a fact stated at the top
of a long note was cut before the model ever saw it.

Both caps had to move together. Raising the window alone would have
changed nothing, because the character cap still truncated the prefix to
its last 7000 characters, dropping exactly the early text that carries
the setup.

- contextWindowTokens 2048 -> 4096 (prompt budget 1934 -> 3982)
- maxPrefixCharacters 7000 -> 14000, maxPrefixWords 1200 -> 2400

Gemma 3n handles far more than this natively; 2048 was Cotabby's own
conservatism, not a model limit.

Measured on the recall suite, identical cases either side:

  window 2048 / 7000 chars   16/23   p95 769ms
  window 4096 / 14000 chars  18/23   p95 603ms

The whole delta is the two cases whose fact sat past the old budget.
Latency did not regress; p95 improved and p50 stayed inside noise, since
KV prefix reuse means a longer window is prefilled once per field and
later keystrokes still decode only the delta.

Continuation suite is unchanged within noise (quality 0.858 -> 0.852,
precision 0.892 -> 0.891, wrong-show 0.085 flat), so the recall gain
costs nothing on ordinary writing.
ARCHITECTURE.md gains the prompt-budget arithmetic (why the token window
and the character cap have to move together) and a pointer to the recall
dataset as the thing that measures whether injected context survives
into the completion.

AGENTS.md gains the two eval commands, the rule that any change to
prompt content, context budgets, sampling, or the model is justified
with a before/after on both suites over identical cases, and the
`Cotabby Dev` scheme gotcha: building the `Cotabby` scheme leaves the
dev bundle stale, which silently invalidates live testing.
Both suites loaded whichever GGUF the locator preferred first, so
comparing two models meant moving files around on disk. A filename in
`eval-model.txt` beside the runtime directory now selects the model for
the run and names it in the report header.

A file rather than an environment variable because xcodebuild does not
forward the shell environment into the macOS test host, which is the
same reason these suites are gated behind a compile flag. Absent or
empty file keeps shipped behavior, so this is inert outside a benchmark.
A note whose paragraph is the only thing on screen still showed the
popup card, in an editor with plenty of empty space around the caret.

Cause, measured live: Obsidian's CodeMirror publishes a single-paragraph
note as ONE AXStaticText whose frame is the union of its wrapped lines,
with no per-line spacer siblings at all. The probe returned exactly one
run (605x44) and nothing else. `lineGeometry` needs two line tops to
subtract, so with one run it yields neither a pitch nor a box height,
`wrappedRunAnchor`'s guard fails, and the caret stays the whole-field
`.estimated` rect the render policy turns into the card.

Everything needed was still measured, just not in the form the code
expected: AX gives the union's height and width, and laying the
paragraph out at that width says how many visual lines the height is
divided into. `pitchFromRunHeight` recovers the pitch as height over
that count, bounded to plausible line-box sizes so a layout that
disagrees with the host falls through rather than inventing geometry.
`WrappedRunAnchor` now carries the caret's whole paragraph (not just the
text before the caret) because the lines after the caret are part of
that count.

That guard also used to return with no outcome and no skip reason, which
made `logCaretLayoutRepair` bail: it was the one route to the card that
left no trace in the log at all. It now records the numbers that decided
it.

Verified live in the same note that showed the card: 45 presentations,
all inline, no card, and the caret is a 22pt line box rather than the
484pt whole-editor box it was before.
The baseline offset was cached per line: `lineTop` was part of the
calibration key, so every line the caret visited was measured
independently from screen pixels. Those measurements disagree slightly,
and the acceptance window is +/-4pt (wide on purpose, for Safari's loose
CSS line heights), so the disagreements were accepted and the ghost sat
visibly higher on some lines. Measured in one session: a 19pt caret box
read 15.0 on most lines, 14.5 on one and 11.0 on another. Each new line
also rendered on the policy guess first and jumped when its own
measurement landed, which is why the source flipped between "policy" and
"calibrated" mid-session.

That offset is a property of the field's font and line box, so it is the
same on every line and has no business being keyed by one. Dropping
`lineTop` leaves `caretHeight` and `fontPointSize` in the key, so a field
whose lines genuinely differ in size still separates.

With one value now serving the whole field, a single stray reading would
have defined it, so the key keeps its accepted samples (up to five) and
publishes their median. A lone outlier can no longer move the rendered
baseline, and an outlier that arrives first is outvoted by the next two
instead of sticking for the session.

Verified across three lines in a Chrome contenteditable: 298
presentations, all "calibrated", offset 16.0 on 281 of them after
converging from an initial 16.5, with two stray readings (5.5, 4.5)
rejected. Before, the offset split between two values across lines.
In Gemini's pop-up prompt bar (Option+Space) the ghost changed typeface
while a single sentence was typed. Measured: one session walked the
system font, Georgia, Helvetica and Trebuchet MS, while size, caret
height and baseline all stayed constant.

The host names no face, so `resolveBySize` picks whichever candidate
family best matches a measured width sample. It is a pure function with
no memory, the sample is the text before the caret, and so the winner
was re-decided on every keystroke as the sample grew. AX reports widths
rounded to whole points, which on a few characters is more noise than
the width gap between the candidates.

Two changes, matching the two ways the decision went wrong:

- A sample shorter than 12 characters no longer decides a family; the
  system font is used, which is stable and the likeliest face anyway.
- The family a width match settles on is remembered per host app and
  point size, so later presentations reuse it instead of re-deciding.
  Keyed by bundle and size rather than focused-element identity because
  this popup republished a new identity 44 times while one sentence was
  typed, which would have reset an identity-keyed memo as fast as it was
  written. Only a guess is ever pinned: a host that names its own face
  never reaches this path.

Verified in the same popup: a fresh sentence now renders 71
presentations in one typeface with one baseline, no changes at all.
Before the memo existed the same test produced four faces.
…tice

In live use, ghost text spacing and positioning are clearly worse than
before these landed. Each was verified in isolation against the symptom
it targeted, so the regression is something those measurements did not
cover, and the right move is to put the known-good behavior back before
diagnosing rather than to keep guessing on top of a worse build.

Reverts a9c971e (typeface memo per bundle/size), 9c6cb62 (baseline cache
keyed per field with a median), and fe82521 (Obsidian pitch derived from
the union run's height).

The two prime suspects, to be tested one at a time before anything
re-lands:

- The typeface memo is keyed by bundle and point size, so ONE field's
  width match now pins the face for every field in that app at that
  size. A wrong pin changes glyph advances everywhere, which reads
  exactly as bad spacing.
- The baseline cache now publishes one offset per field instead of one
  per line. That assumed the per-line differences were measurement
  noise; if any part of them is real (whole-point caret rounding differs
  by line), a single shared value is now slightly wrong on many lines
  where each line used to be right.
Of the three reverted changes this is the one an "it got worse in most
apps" report cannot explain: it only runs for a caret inside a wrapped
union run, which is a shape Obsidian's CodeMirror produces and ordinary
hosts do not. The typeface change stays abandoned; it was the one with
the global blast radius.

Restores deriving the line pitch from the union run's own height when
there are no sibling line runs to measure a gap between, so a
single-paragraph note keeps inline ghost text instead of falling back to
the popup card.

Adds the check the first attempt was missing. The division is only as
good as the line count, and that count comes from Cotabby's layout, not
the host's. It is now cross-checked against the natural line height of
the font that layout used: out by one turns a 2-line paragraph into a
44pt pitch or a 4-line one into 11pt, and either misplaces every line
after the first by a multiple of the error. Outside 0.75x-1.8x of the
font's line height the derivation is refused, which leaves the caller
exactly where it was before this path existed.
… own

The re-landed fix put ghost glyphs on top of existing text: on a line
reading "in apps like Obsidian, a" the ghost rendered starting mid-line,
superimposed on the host's own words instead of after the caret.

The approach cannot work in the state it targeted. That state is defined
by the paragraph arriving as ONE union run with no per-line siblings,
and those siblings are also the only source of `observedCharWidth`. So
exactly when the pitch has to be derived, the horizontal estimate has no
width calibration either, and `TextLayoutCaretEstimator` is left
approximating the host's font from metrics alone. Deriving the line
pitch fixed which line the ghost lands on while leaving the X wrong,
which is worse than the card: a misplaced popup is ugly, overlapping
glyphs are unreadable.

The card stays for this Obsidian shape until there is a real measurement
to place the caret horizontally, rather than an approximation.
Ghost text sat visibly higher on some lines than others. The pixel
alignment harness measured it in Obsidian: line 1's baseline reading was
16.0 and the ghost landed 0.1pt from the host's text; line 2's reading
was 15.0 and the ghost landed a full point high. Each line's own reading
was applied verbatim, and one line's reading is noisy.

The offset from a caret box's top to the painted baseline is a property
of the field's font and line box, so every line of a field shares it.
`BaselineOffsetConsensus` now holds one value per field: the first
accepted reading defines it, a later reading that agrees confirms it, a
reading that disagrees is held as a dissent rather than applied, and only
a second reading agreeing with that dissent switches the field, at most
once. So a single stray line can never move the text, a stray first line
is outvoted by the next two, and the value never drifts sample by sample.

Per-line readings are still taken (each line measured once) because they
are the evidence the consensus is built from; what changed is that the
calibrator publishes the field's agreed value, to `cachedOffset` and to
calibration listeners alike, never a line's raw reading.

This is deliberately not the running median tried earlier: that moved
text that was already placed correctly as samples accumulated.

Verified with the harness on the shipping build, four lines each:
Obsidian single- and multi-paragraph notes within 0.13pt vertically on
every line; TextEdit and a Chrome contenteditable unchanged at 0.01pt.
…face steady

Two presentation-time fixes that share the overlay controller.

Obsidian's CodeMirror exposes a paragraph as ONE static-text element
whose frame is the union of its wrapped lines, and answers every geometry
query for anything inside it with nothing: AXBoundsForRange returns a
zero-size rect for any range, the selection's text-marker bounds are
zero-size, a previous-character marker range (built with the HIServices
marker accessors) is zero-size too, AXTextMarkerRangeForLine returns the
whole element for line 0 and is unsupported past it, and the font
attributes are empty. So a ghost there was either the popup card, or a
caret laid out with an approximated font that landed five or six points
left of the truth on wrapped lines (measured) and on top of the host's
own glyphs when no sibling runs existed to calibrate from.

The host has painted the answer. `PixelCaretLocator` captures the run's
frame (excluding Cotabby's own windows), `InkCaretAnalyzer` finds the
inked lines in it, and the caret is the end of the last line: the number
of lines and their pitch come from the line tops, the line box is the
frame height less a pitch per extra line, and x is the last line's ink
edge plus the advance of any unpainted trailing spaces. Only a caret at
the end of its paragraph is measured; a caret inside one keeps the card,
which is honest about not knowing. The overlay holds the first
presentation for a paragraph text until the capture answers (tens of
milliseconds, started early while the model generates) and reuses it for
every later presentation of that text.

Typeface: in a host that names no loadable face, the resolver picked a
family from one width sample, and a field the host keeps re-measuring as
it grows re-decided on every sample. Gemini's prompt bar walked through
the system font, Trebuchet, Georgia and Helvetica in one sentence; its
six samples' per-character widths ranged 7.4-9.3pt, which no single font
produces. Two rules, both inert for a field measured once:
- a host that names a face this Mac cannot load (Gemini names its
  bundled Google Sans) renders the system face scaled to its longest
  sample, from the first sample and for the field's life, including on
  snapshots whose style momentarily carries only a size;
- a size-only field is judged by `TypefaceEvidence` across all its
  samples: keep the adopted family while every long sample fits it,
  switch once to the only family fitting every sample, else settle on the
  scaled system face for good.

Verified on the shipping build with the pixel alignment harness:
Obsidian single-paragraph note, four lines, within 0.09pt horizontally
and 0.13pt vertically, 748 inline presentations and 113 pixel
measurements with no failures; multi-paragraph note within 0.09pt/0.10pt;
TextEdit and Chrome contenteditable unchanged at 0.1pt. Gemini: one
typeface for the whole sentence with a 0.11pt size refinement, versus
four face changes before.
ARCHITECTURE.md describes the baseline consensus and the pixel caret
locator as presentation-time measurements and why each exists. AGENTS.md
records the verification standard placement changes are held to: the
ghost's measured offset from the host's own rendering, per line, across
Obsidian, TextEdit and Chrome, not log counters.
In live use the ghost's height is clearly wrong and the experience is much
worse than before these landed, despite the alignment harness reading
every line within 0.13pt. The harness measured where the ghost sat, not
how large its glyphs were, and nothing in it exercised the hosts being
typed in. Restoring the previous behavior first; the diagnosis comes
from the session log, and any re-land is held to a check on glyph size
as well as position.

Reverts ce6a13b, b5e1be6, c0f67b5.
…er range text

A Chromium contenteditable reads out in two spaces: its AXValue puts a
line break between blocks (paragraphs, list items), while its range
queries and text-marker strings run the blocks together and count the
caret in that space. A replica of Claude's composer gave the value
"abc\ndef\nghi" against the range text "abc\ndefghi", the caret at 10
of the value's 11. Cotabby read the range text, so:
- the model was handed "here.Se" and continued it as "conde
  paragraph", and "starts here andA third one" further down;
- the caret line's text for the pixel match ran back through every
  paragraph before it, and the face never settled past the first;
- the text after the caret came back empty whenever the after-window,
  sized from AXNumberOfCharacters (the value's length), reached past
  the range text's end: Chrome answers nil there, so a caret moved into
  earlier text read as the end of its line and got an inline ghost over
  the host's own words.

The resolver now aligns the range text before the caret with the value
(BlockBreakAlignment: the value with only line breaks left out) and
reads the field's text and caret from the value. The end of a block and
the start of the next share one range offset; the caret's text marker
tells them apart (AXHelper.caretStartsTextBlock: at a block's start the
marker names a different text element than the character before it),
asked only when a break follows the caret. The document caret stays in
the range space for the host's own offset queries. A field whose window
already holds the whole value at the value's length skips the extra
read.

Verified in the replica: the three paragraphs reach the field
snapshot as "one line.\nThe second paragraph starts here and\nA third
one begins", and a caret moved into the first paragraph now sees the
text after it, so a letter typed inside a word generates nothing
there, where it had painted an inline ghost over the host's words. The
space cases in the replica, a Chrome contenteditable and Obsidian, and
the Obsidian and Chrome placement cases, are unchanged. (The prompt
window still rejoined the words with single spaces, and a new line's
first word still got no ghost; both are the next change.)
… word a ghost

Two rules threw away the line breaks the user typed, in every host:
- The prompt's word window split the text on any whitespace and
  rejoined the words with single spaces, so "Hi Sam,\n\nThanks for"
  reached the model as "Hi Sam, Thanks for", and three paragraphs in a
  replica of Claude's composer as "one line. The second paragraph starts
  here and A third one". The window now keeps the text between its
  words as typed (SuggestionRequestFactory.lastWords), and a trailing
  line break stays, since the caret then opens a new line.
- A request anchored at a new line's first word required the model to
  begin with the line break and the typed letters. The model opened
  with a token whose text is empty ("<unused9>"), nothing matched the
  typed letters afterwards, and every such request was dropped as a
  word-boundary mismatch: in the replica and in Obsidian the first word
  after Return never had a ghost. A line break now stays at the end of
  the prompt (the renderer trims only trailing spaces and tabs) and the
  anchor requires just the letters.

SignOffCue's note about folded line breaks is updated to match.

Measured on the same model before and after: the continuation suite's
quality 0.852 -> 0.855, precision when shown 0.891 -> 0.901, wrong
shows 0.086 -> 0.077, positive coverage 0.897 -> 0.887 (117 cases, 17
with line breaks); code and lists now continue on their own lines. The
recall suite is unchanged at 0.783. Live, the first word after Return
now has a ghost in Obsidian and in a Chrome contenteditable ("Second"
-> " paragraph starts here."), where every such request had been dropped.
The continuation prefetch starts on the keystroke that types through a
ghost, before the host publishes that keystroke, and it composed the
live snapshot with the session's already advanced remainder, so the
characters just typed fell out of the prompt: "The budget" plus
"is $100," prompted the model with "The budgetis $100," and it
continued "000. The budgetis $10"; "the pilot " plus "s $250,00" gave
"pilot s" (2026-09-11). Such an entry is what a Tab after the ghost
shows next. The prefetch now takes the text from the session itself,
the text the ghost was generated for plus all of it
(ActiveSuggestionSession.precedingTextOnceTypedThrough), and skips
correction sessions, whose text replaces a word rather than following
it.

Unit-tested on the two measured shapes: a snapshot that still lacks the
typed characters and one that already holds some of them both yield the
text after the whole ghost, with the caret moved by the rest.
With the prompt keeping its line breaks, a pasted chat transcript reads
as one, and the model continued the note and then wrote the next turn:
"to unplug the router for 10 seconds.\n<|im_start|>user\nHow do I reset
my router?", shown with the role word and the question once the opening
marker was removed in place. An opening marker at the start of a line,
after text, now ends the completion there, the way a stop marker does;
a marker inline or at the very start is still removed in place.

On the eval suites the one case that changed is that transcript
(forbid-scaffold-01), now "to unplug the router for 10 seconds."; every
score is unchanged (continuation 0.855, recall 0.783).
HostFaceMemory starts a field that has measured nothing yet in the face,
size and line pitch its host's last settled field had, but it lived in
memory only, so the first field after every launch started from
nothing: Obsidian's showed the caret box's 17pt for three seconds of
typing until its pixel match named the 16pt system face, and Claude's
the reported 14pt for 17 presentations before Anthropic Sans at 15.3
(2026-09-11, the second from a real session in the composer). The
memory is now Codable and the overlay controller loads it at launch and
saves it whenever a record changes it, which is once per settled style,
not per presentation.

A browser page's style is kept only while the app runs: its key names
the page's origin, and the sites a user typed on do not belong in the
app's preferences. Unit-tested: faces, sizes and pitches survive a round
trip, unreadable or missing data restores nothing, and a page's entry
is left out of what is saved.
A Chromium or Electron host reports its CSS font size and nothing of
its zoom, so the ghost's size in such a field is measured, and the
pixel fit wanders by about half a percent between fields: Obsidian's
16px system text matched at 15.99 in one field and at 16.10 in the
next, and at 16.10 the ghost's words drifted up to two device pixels
by the end of a line, where 15.99 held every word within one. Zoom is
not continuous, though: Chrome steps through fixed presets, and
Electron through zoom levels half a level apart, a factor of 1.2 per
level. Claude keeps windowControlsZoomFactor 1.0954451150103321 in its
config.json (level 0.5), so its composer paints 14px at 15.336pt, not
the 15.4 a "110%" reading gives.

HostZoomLadder snaps a matched size within a percent of the reported
size times a step on the host's ladder to that product;
HostZoomLadderResolver names the ladder, Chrome's presets for Chromium
browsers and Electron's levels for any app whose bundle carries
Electron Framework.framework. Only a pixel-matched face snaps: a
stand-in scaled to the host's widths keeps its own size, and a size
fitted to the host's own advance more than a percent off a step keeps
its fit.

The calibration that lands while a ghost is on screen re-applies the
match through the same step, or every streamed word showed one frame
at the raw fit (Georgia 17.892 between 18.0 frames).

Measured live on the snapped build: Obsidian's system face at 16.0 with
every word of four lines within a device pixel (at 16.10 they had
drifted up to two by a line's end), and Chrome's Georgia field at 18.0
with the first line's residual at -0.03pt, where the 17.89 and 18.02
fits had left -0.14 and +0.29.
… reaches the line above

Claude's Code composer sets 15.3pt text on a 20pt pitch, and Chromium's caret spans the font's
whole ascent and descent (19.2pt). On a wrapped line the caret's top comes within two device rows
of the line above's descenders, which the ink analyzer tolerates inside one line, so every capture
after a wrap read the paragraph as ONE line: the caret landed at the end of the first line
(x 990 for a caret at 375), in a 39pt box, and the ghost was drawn there for the rest of the
paragraph (measured 2026-09-11 in the live log, and reproduced from a rendered capture).

The analyzer now finds a standing caret bar before it finds the lines: the tallest column of ink,
at most four columns wide, clear of the ink beside it and 1.3 times taller than any other column
(a caret spans more than any glyph: 38 rows against 24-row stems and a 30-row "|"). The bar's
pixels are left out while the lines are split and its rows are given back to the line it stands
on, so it is still found as that line's caret. When no such bar is in the capture (its blink, a
host without one) the lines are found exactly as before: over the 3,409 captures dumped from
Obsidian and Chrome on 2026-09-10 the old and new analyzers agree on every line.
…ts no size

A web field that reports no font size is sized by the pixel match of a short strip, and a match
that scores well is settled for the field's life. Its size is not what the score measures: in
Claude's Code composer the match settled on Anthropic Sans at 15.1585 from thirteen characters,
too few for its advance fit, while the host paints its 14px at the app's 1.0954 zoom, 15.336.
Every ghost ran 1.2% short, a point and a half by the end of a line (measured 2026-09-11: 23 of
the user's own caret captures along one line fit 15.339).

Every caret the pixels read on a paragraph's first line lies on one line, caret = start +
scale x advance(text before it), so the captures the locator already makes are a measurement of
the host's size. HostAdvanceFit takes the Theil-Sen slope of caret against the face's advance
(five of those 23 captures read the host a glyph late, and a least-squares line bent with them)
and adopts it once it spans 150pt, refining only from a fit twice as long, at most twice. On the
real captures it adopted 15.367 after 244pt and 15.345 after 490pt.

The locator hands every fresh capture (never a cached or carried-forward one) to the overlay,
which records those of web fields that report no size, in a face the pixels named, after a glyph,
on the paragraph's first line. The adopted size replaces the match's, and HostFaceMemory keeps it
marked as measured, so the next field of the style starts in it rather than in the next short
strip's size. Hosts that report their size keep snapping to their zoom ladder.
…them

An earlier build kept browser pages' styles, keyed by the page's origin, in the app's preferences;
the dev app's still held one (a local test page's) on 2026-09-11. Saving already leaves page styles
out, but restoring took back whatever was stored, so an origin written by that build came back into
use and stayed in memory. Restoring now skips page-scoped styles as well, so they neither return
nor get written out again, while the app's own styles restore as saved.
With a size reported and no face named, the resolver took whichever
candidate family came closest to one width sample, and a whole-point
caret sample is off by up to half a percent: a system-font Chrome field
sampled " was thinking t" at 99.0pt, the system face 0.41% off and
Trebuchet MS 0.20%, so Trebuchet showed for three presentations until
the pixel match named the system face (2026-09-11, in two separate
runs). A family now replaces the system face only when it fits the
sample more than a point better (familyMargin); Georgia's own advance
still beats the system face by 1.4 points and is still taken. The
multi-sample verdict judges the system face first as well, so a second
family that merely also fits every sample leaves the verdict undecided
(the scaled system face) instead of electing it.

Live (2026-09-11): eleven fields of a system-font contenteditable in
Chrome each showed the system face from their first presentation to their
last, at the reported 15 and then the pixel match snapped to 15.0; no
other family showed.
Claude's own composer, as logged while it was used on 2026-09-11,
answers a text-marker line query with its paragraph's padded block, not
the glyph line: x 84 for text that starts at 95, 33pt tall for 20pt
glyph lines, growing only in width. The box passed the one-line checks,
so a wrapped ghost row started 11pt left of the text. Once the paragraph
wrapped, the box covered both lines and was dropped as too tall, which
left no line pitch to measure, and rows stepped down by the 19pt caret
box instead of the composer's 23pt lines. The replica of the composer
had answered glyph lines, so neither showed there.

The caret line now starts at its first glyph's box (past a list
marker, as the bullet case did), and the pitch is the distance between
the first glyphs of the caret line and the line above in the same text
element, taken between box centers: Chrome rounds each edge to whole
points on its own, and a pair of 19 and 20pt boxes 23.1pt apart had
read 24 between bottoms and 23 between tops. An empty line keeps its
own box, and a box the size of the frame is still no line.

Live in the composer replica in Chrome (glyph lines there, 2026-09-11): a
wrapped row starts at the text's edge (x 113; its first word 0.2pt from
where the accepted text lands) and, once the pitch is measured (23.5 for
23.1pt lines), the second row sits within half a point of the host's
next line. The padded boxes of Claude's own composer are covered by
unit tests built from the numbers its session logged.
…ace is the system face

A web field whose face the resolver judges to be the system face shows the system face at the
reported size, then scaled to the field's first dozen-character width sample, then at the pixel
match's size, which is snapped to the host's zoom step. Whole-point caret samples carry about half
a percent, so the middle stage drew another size: in a system-font Chrome field the ghost went
15.0, 15.06 for three presentations, then 15.0 (2026-09-11, one field of eleven). The scaled size
now takes the zoom step it lies within a percent of, as the pixel match does, and the field keeps
one size throughout. Where the system face stands in for another face (one the host names but this
Mac lacks, or one no family fits) the scaled size is that face's advance, not a size the host set,
and it is kept as the sample gives it.
…published

A Chromium editor publishes a keystroke's text before its caret. In Claude's Code composer
(2026-09-11) the first letter of a message was already in the field's value while the text-marker
caret still sat where the empty paragraph began, x 342 on a line starting at 342, in the empty
line's 22pt box. The suggestion for that letter arrived first and was drawn from it for nine
presentations, about 150ms: over the letter itself, and in the system face at 18.5pt, sized from the
22pt box, since no remembered face was filed under that caret height. The next snapshot had the
caret after the letter and the ghost in Anthropic Sans at its place.

CaretLagPolicy recognizes such a caret: at its line's leading edge while the text before it on the
line holds a glyph and is too short to have wrapped (a caret starts a visual line legitimately only
at a wrap). The overlay then waits for the next snapshot instead of drawing, for web fields that no
pixel read will place (a paragraph exposed as one run is measured from the host's pixels anyway).
Whitespace alone decides nothing, and right-to-left lines are not judged.

In the composer replica in Chrome, whose caret moves with the text, the first presentation
after each of nine first letters drew past the letter before the change (9 of 9) and after it
(9 of 9), with 0 held: the hold changes nothing where the caret is real. The composer's
own stale caret is confirmed from its session log; the next message typed there is the live check.
A caret with text after it on its line gets the card under it instead of an inline ghost. The
card's layout asked CGRect.isEmpty whether the caret was usable, and a rect with a zero side is
empty: Chromium's text-marker carets and many AppKit insertion points are zero points wide. Every
such card was anchored under the whole field instead: in Gmail's compose body 440pt below the
caret, at the bottom of the screen (the dev log of 2026-09-11: 13 cards at panel y 40 for a caret
at y 484). A caret with height is a line now; only the all-zero rect some hosts publish right after
focus falls back to the field.

The card's left edge also stood at the caret's trailing edge, so the suggestion's first letter was
the card's 10pt padding plus the caret's width past the insertion point. Its text now starts under
the insertion point, the padding reaching back past it; for right-to-left text the card's text ends
there.

Live (2026-09-11, Chrome at 100%, a page of twelve kinds of text field, every key guarded): every
card in the eight fields that raised one, text areas, inputs, plain, ProseMirror-, Lexical- and
chat-shaped contenteditables and an iframe's, sat one point under its caret's box with its text
starting at the caret, across about seventy presentations; before, Gmail's compose body had put
each one at the bottom of the screen.
A wrapped ghost row steps down by the host's line pitch, and a pitch that is a misread puts the row
on another line entirely. Three sources gave misreads, all found in the dev app's own log and
remembered faces on 2026-09-11:

- The ink analyzer took the pitch from every pair of line baselines. A line holding one capital has
  no letter body to find a baseline under, its first busy rows are the capital's top bar: an "E"
  typed onto a fresh line in Claude's composer read 25 rows below the line above for a 44-row pitch,
  the run's frame then held three 12.5pt line boxes, and ten ghosts were drawn at the start of a line
  that did not exist. A pair now gives a pitch only when it is at least 0.8 of the taller line's ink
  height, since two separate lines cannot be closer than that.
- With no pitch from the pixels or sibling runs, the locator took the whole run frame for the caret's
  line box, two lines tall after a wrap. The painted lines now share the frame evenly, which puts the
  caret on its line within a point or two, and that share is not reported as the pitch, so the rows
  take the one the host style measured before.
- The sibling-run pitch was the median distance between single-line runs, and those can be lines
  apart with a wrapped paragraph between: an Obsidian note whose only one-line paragraphs were the
  second and fourth gave 72pt for 24pt lines (163 presentations). Only distances between 0.95 and 2.2
  line boxes count now.

The overlay neither uses nor remembers a pitch outside 0.95 to 2.6 times the host's text size
(CSS line-height 1 to double spacing; the hosts measured sit between 1.17 and 1.6), and restoring
the remembered styles drops such pitches: the dev app's memory held Obsidian's 72 and Claude's 12.5.

Replayed through the analyzer, the 4,075 caret captures the dev app had dumped (Obsidian, Chrome and
Claude, 2026-09-09 to 11) change in exactly two reads, the lone "E" captures from Claude's composer,
which now give no pitch where they gave 12.5pt; lines, caret bars and every other pitch are the same.
A caret with text after it on its line gets the card, which needs only the caret's line and x. In a
paragraph the host exposes as one run (Claude's composer, Obsidian's CodeMirror) Accessibility has
neither: Claude answers the field's frame, and its card stood under the field's right edge, 400pt
from a caret moved back before a comma (the dev log of 2026-09-11: 144 presentations). The pixel
read placed only a caret at the end of its paragraph, where the caret is the end of the last inked
line.

The host broke the paragraph where each painted line's ink ends, so the text before the caret is
walked through the painted lines: while more of it remains than a line's ink holds, that line takes
the words whose advance in the ghost's face comes closest to its ink width; the line whose ink runs
on past what remains holds the caret, which is that remainder's advance past the line's start. A
word the host moved down whole takes a caret inside it along, and a line's ink that ends at no word
end refuses the read (the face or the text disagrees with the pixels). Such a read is cached and
carried forward apart from end-of-line reads, reports no ink width, and never feeds the advance fit,
whose captures must measure the host rather than the ghost's face. The read that starts while the
model generates now knows where the caret stands in its line too: left at the geometry's default, it
read a caret moved back into a paragraph as the paragraph's end (Obsidian: 975.9 for a caret at 948.1).

Live in Obsidian (2026-09-11, the scratch note, every key guarded): with the caret moved up onto a
wrapped paragraph's first line and back a word, the read put it at x 741.7 on line 1 of 2, where the
host's own caret bar stood at 741.5 to 742.5 (CoreText's advance of the text before it: 742.1), and
the card's first letter stood under it. Back a word on a paragraph's last line, the read that starts
while the model generates put the caret at 948.05 where the host's text puts it at 948.1 (before this
change: 975.9, the paragraph's end).
A text field or combo box lays its text out on one line (an HTML input, Chromium's address bar, an
NSTextField): past its right edge the text scrolls sideways. The overlay did not know the field's
role and wrapped a long ghost onto a second row at the field's left edge, under the field and over
the page (measured 2026-09-11 in a Chrome text input and a search input: panel y 531 for an input
whose bottom edge is at 544). The focused context now says whether its field is one of those, the
overlay geometry carries it, and there the ghost keeps to the caret's row and reveals the rest as it
is accepted, as it already does over trailing text.

Live (2026-09-11, Chrome, a page of twelve kinds of text field, every key guarded): the text input's and
the search input's accepted ghosts keep one row (before: two, the second under the input); text areas
and contenteditables still wrap.
A Menlo textarea in Chrome (2026-09-11) drew its ghost in the proportional system face scaled to
monospace widths, 22pt glyphs for 14.3pt text at 110% and 17.2 for 13 at 100%, until or unless the
pixel match named Menlo. Three rules sent it there:

- With no style reported, the caret box's face (the system face) was rescaled to every width sample,
  and a sample from a face that much wider grew it 15.5, 18.4, 19.3, 20.3, 21.5 and 22.0pt over
  twelve seconds of typing. A sample may now rescale the caret box's face by at most 15%; beyond
  that the box's size stands until something names the face.
- The monospaced system face reports its family as ".AppleSystemUIFontMonospaced", which the family
  lookup read as a dotted system name and turned into the proportional face, so a field whose first
  sample matched it failed every later sample in it. That name now resolves to the monospaced face.
- Typeface evidence switched to another family only when exactly one fitted every long sample.
  Menlo and Courier New fit the same samples (and the monospaced system face, 2.7% wider than Menlo,
  neither), so the field was undecidable for good and took the scaled proportional face. Several
  families fitting every sample now elect the earliest candidate; only none fitting is undecidable.

Live (Chrome at 100%, every key guarded): the Menlo textarea's ghost went system face 13 for its first
five presentations, the monospaced system face 13 for 38, then Menlo 13 matched from its pixels for
the rest (107), with no scaled face at all, and the accepted text's words stood within 0.2pt of the
ghost's.
… wraps

A wrapped ghost row steps down by the host's line pitch, and a Chromium editor answers no line
query for a paragraph's first line: until the text itself wraps there is no second line to measure,
and the row stepped by the caret box. In the composer replica in Chrome (2026-09-11) the first
wrap's second row sat 4pt above the host's next line (a 17pt caret box for 23.1pt lines), and in a
plain contenteditable 5.5pt; a browser page's style is remembered only while the app runs, so every
page paid that on its first wrap.

In a ProseMirror-style editor (a <p> per paragraph, no padding) the paragraph around the caret's
text is the line box: 21pt around a 17pt glyph box, 14px at line-height 1.5, starting where the text
does (read with a read-only probe). textMarkerCaretLine now also returns that element's frame when it
is not the field itself, and the probe takes it for the pitch of a line with no line above, in a
browser only, when it starts within a point of the line's first glyph, holds that glyph's box and is
at most 2.2 glyph boxes tall: a block padded beside its text (Claude's chat composer answered x 84
for text at 95) or a paragraph two lines tall is no line box. A plain contenteditable has no such
element and keeps the caret box. Only browsers take it: the probe reads it where the host answers a
text-marker caret, which Claude's own composer does only at its first character, and apps keep the
pitch they measure and remember.

Chromium rounds frames out to whole points: the box reads the exact 21 at 100% but 24 for the
23.1pt line at 110%. So it is marked as a paragraph's box (HostTextMetrics.linePitchIsFromParagraphBox)
and ranks below a pitch measured between two lines: the metrics cache keeps looking for one while
only the box is known and never trades one for a box read later, the overlay takes a remembered
pitch of the same host style over the box when the two agree within its rounding (1.5pt), and the
box itself is never remembered.

Measured live in the replica at 110% (2026-09-11), a first wrap with nothing remembered: the
ghost's second row sits on a baseline 2 device pixels (1pt) below the host's next line, the box's
rounding, where it sat 8 (4pt) above, and starts within half a pixel of the host's line start.
When a pixel match finishes while its ghost is on screen, the overlay re-renders the ghost in the
matched face. It did that by applying the fresh match to the session's own face, one step of the
chain a present runs (resolveFont), and skipped the next step: the size the field's own caret
advance measured (applyingHostAdvance), which a field that reports no size keeps from its fit or
from the face memory. Claude's Code composer reports none, and after a relaunch (2026-09-11) its
ghost drew the match's raw 15.48 for a frame after each of two matches two seconds apart, between
frames at the measured 15.34: the ghost's letters grew one percent and shrank back on the next
keystroke, a line's worth of suggestion running three points longer for that frame.

The callback now resolves the face through resolveFont, which reads the match the calibrator has
just recorded, so the ghost it re-renders is the one the next keystroke presents, whatever later
steps the chain grows.
Chrome's address bar names no face, reports no size, and answers no bounds query: its caret comes
from its pixels, and its ghost took the system face at the size the caret box gives, 15.5. The host
paints its text smaller. Replayed over the pixel reads of five queries typed there (2026-09-11,
spans of 65 to 234pt), the caret's own advance fits the system face at 14.48 to 14.58, so the ghost
ran 7% large: a query's worth of suggestion ended several points past where the host's own text of
it ends, in visibly bigger letters.

The advance fit that sizes a pixel-matched face in a web field that reports no size
(HostAdvanceFit) now also takes a single-line field's stand-in, and adopts at the 60pt span such a
field's short text reaches (a paragraph's line still needs 150). The pixel match that would name
the address bar's face never runs: its strip starts at the field's left edge, over the search
icon, and holds no line of text. A paragraph's stand-in stays out: a face no pixel has confirmed,
fitted to width, would read as the right one.

A stand-in sized that way is logged as host_advance_fitted (GhostFontResolver.Provenance): its face
is still a guess a pixel match may replace, its size is measured, and HostFaceMemory keeps it as
settled for the next field of the same style.
…ange starts

A wrapped ghost row starts at the caret line's left edge, read through text markers
(AXHelper.textMarkerCaretLine): the caret marker's line range and its first glyph. Chromium can end
such a range partway along the visual line, at an inline element's edge. In Gmail's compose body in
Chrome (2026-09-11) the caret line read x 393, then 804 at caret 95 and 743 at
caret 88, while the caret stayed on the same visual line (every presentation at caret top 235); the
metrics cache kept 743, and every wrapped ghost in that field, 61 presentations, started its second
row at x 743 in a body whose text starts at 393, also after the caret moved to the next line.

The character before a real line start lies on the line above; before a split line's start it lies
on the same line. The line query now walks back over line ranges on the same glyph row, up to 8
(AXHelper.isEarlierFragment: middle within half a glyph box of the line's first glyph, starting left
of it and ending at it within a glyph box, no taller than two glyph boxes), and takes the line's left
edge and first glyph from the first of them. The first range that is not on the caret's row is the
line above, which measures the pitch as before; a line that is not split costs no more queries.
Gmail writes its Smart Compose suggestion into the compose body right after the caret, with its
"tab" key hint on the line below, and Accessibility reads both as text after the caret. In a
compose body in Chrome (2026-09-11) the text after the caret read "lot of time\ntab", " and
running\ntab", and "the app\ntab" (then "he app\ntab" once its "t" was typed). Read as the user's own text,
it made a caret at the end of the line look mid-line: the inline ghost gave way to the card under
the caret, and Cotabby's suggestion competed with Gmail's for the same spot and the same Tab key.

That span is the host's own prediction, as the address bar's completion and macOS inline predictive
text are, and it now reaches the same hold (HostMarkedTextPolicy): while Gmail shows it, Cotabby
generates and paints nothing and keeps its session, and the ghost returns once Gmail commits or
drops its suggestion. HostMarkedTextPolicy.smartComposeSuggestionRange takes the shape only where
it was measured: on mail.google.com, after a collapsed caret, a suggestion of at most 160 units on
the caret's line and a line that is exactly "tab" after it; a signature below stays the user's.
…urve

Where Accessibility gives no caret, the pixels place it: InkCaretAnalyzer finds each line in a
capture and its baseline, and a single-line read's baseline places the ghost ahead of the
calibrator's (PixelCaretLocator, baseline_source "pixel"). The baseline was the row under the first
run of rows at least 35% as busy as the busiest, and a capital's top curve with the ascender tops
beside it can make that run on its own, above a dip where only stems remain. A photo of
Claude's Code composer (2026-09-11) shows the result: after "Should" the ghost "be running. If it
is not," was drawn well above the line. Its presentations logged a pixel baseline 5.5pt below the
top of the 19pt caret box. Replayed on the capture dumped for it, rows 11-12 (the S's top curve,
21 and 23 of a 60 peak) passed the threshold, 13-14 did not, and the letters ran from row 15 to
31: the true baseline lies 15.0pt down, and the ghost sat nine points high.

Over the 4,823 reads dumped in these sessions the first-run rule put 245 of 2,736 single-line
baselines a third of the way into the caret box instead of four fifths. The letter bodies are now
the first busy run unless a longer one spanning 30% of the line's rows follows it
(InkCaretAnalyzer.bodyRows): an x-height band is nearly half the line, a capital's bar a tenth.
Replayed over the same reads, no baseline is left in that cluster, 267 reads change, none of them
away from four fifths, and no multi-line read changes. A lone capital keeps its first run (an "F"'s
middle bar would give a pitch eleven rows short). The calibrator's two-row gap rule would also close
the dip, but it bridges down into a "g"'s bowl under the baseline and read Chrome's address bar
"is g" and "goo" 2.5pt low.

And a pixel baseline shallower than 45% of its box is refused (minimumBaselineDepthFraction), so
the ghost keeps the calibrated one: every sound read in the corpus lies half its box deep or more,
every misread a third or less.
The Lint workflow runs swiftlint --strict over Cotabby/, and the branch's additions tripped
line_length, implicit_optional_initialization, cyclomatic_complexity,
function_parameter_count, force_cast, identifier_name and large_tuple. Each is fixed without a
change in behavior:

- AX values are unwrapped through type-checked helpers (AXHelper.axElement(from:) and the
  existing axValue(from:)) instead of force casts.
- Tuples of three become small structs (HostTextMetricsProbe.MarkerLine), and long parameter
  lists take the values they travel with (HostTextMetricsProbe.MarkerBoxes,
  PixelCaretLocator.FrameLines, the selected caret, the host's line rect).
- Long functions hand a self-contained step to a helper: the pixel caret placement
  (OverlayController.pixelPlacedGeometry), the typeface verdict, the calibration's delivery,
  the reasons a readable field is blocked, a paragraph's word walk, and the ink profiles.

Comments and tests no longer name people or local test pages, and the tests' page origins are
neutral. AGENTS.md and ARCHITECTURE.md describe the CotabbyInference fork pin and the live
alignment check without pointing at a session's scratch files.

The unit suite passes (2,109 tests), and the 8,172 dumped captures replay byte-identically
through InkCaretAnalyzer and InkBaselineAnalyzer before and after.
The XcodeGen workflow regenerates the project and fails on any difference from the committed
one. The branch's project listed the same build files XcodeGen writes, in a different order;
this is xcodegen generate's output for project.yml, under the XcodeGen (2.45.4) that
regenerates main with no difference.
The pixel measurements behind ghost placement (the host's face, size and baseline, and the caret
where Accessibility has none) capture the area around the caret, so they run only with Screen
Recording granted; without it they are skipped and placement follows Accessibility.
@greptile-apps

greptile-apps Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review (151 files, 100 file limit).

Bypass the limit by tagging @greptile-apps to review.

@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 151 files, which is 1 over the limit of 150.

To get a review, reduce the PR to 150 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 03ca2232-17ca-4905-af48-c5d8e5190da7

📥 Commits

Reviewing files that changed from the base of the PR and between ac2699e and ed674fa.

📒 Files selected for processing (151)
  • AGENTS.md
  • ARCHITECTURE.md
  • Cotabby.xcodeproj/project.pbxproj
  • Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift
  • Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+HostMarkedText.swift
  • Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift
  • Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift
  • Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift
  • Cotabby/App/Core/CotabbyAppEnvironment.swift
  • Cotabby/Models/Focus/FocusModels.swift
  • Cotabby/Models/Runtime/LlamaRuntimeModels.swift
  • Cotabby/Models/Settings/CompletionRenderMode.swift
  • Cotabby/Models/Suggestion/Request/FocusedInputContext.swift
  • Cotabby/Models/Suggestion/Request/SuggestionConfiguration.swift
  • Cotabby/Models/Suggestion/Request/SuggestionRequest.swift
  • Cotabby/Models/Suggestion/Result/SuggestionResult.swift
  • Cotabby/Models/Suggestion/Session/ActiveSuggestionSession.swift
  • Cotabby/Models/Suggestion/Session/SuggestionPresentationModels.swift
  • Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift
  • Cotabby/Services/Focus/Caching/CaretAdvanceSampleStore.swift
  • Cotabby/Services/Focus/Caching/FieldStyleCache.swift
  • Cotabby/Services/Focus/Caching/HostTextMetricsCache.swift
  • Cotabby/Services/Focus/Chromium/ChromiumAccessibilityEnabler.swift
  • Cotabby/Services/Focus/FocusTracker.swift
  • Cotabby/Services/Focus/Resolution/AXTextGeometryResolver.swift
  • Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift
  • Cotabby/Services/Focus/Resolution/HostTextMetricsProbe.swift
  • Cotabby/Services/Input/InputMonitor.swift
  • Cotabby/Services/Presentation/FocusDebugOverlayController.swift
  • Cotabby/Services/Presentation/GhostTextPanelView.swift
  • Cotabby/Services/Presentation/HostBaselineCalibrator.swift
  • Cotabby/Services/Presentation/HostBundledFontRegistry.swift
  • Cotabby/Services/Presentation/HostZoomLadderResolver.swift
  • Cotabby/Services/Presentation/OverlayController.swift
  • Cotabby/Services/Presentation/PixelCaretLocator.swift
  • Cotabby/Services/Runtime/DebugForcedSuggestionEngine.swift
  • Cotabby/Services/Runtime/Llama/LlamaAbortTarget.swift
  • Cotabby/Services/Runtime/Llama/LlamaRuntimeCore.swift
  • Cotabby/Services/Runtime/Llama/LlamaSuggestionEngine.swift
  • Cotabby/Services/Spelling/CurrentWordSpellChecker.swift
  • Cotabby/Services/Suggestion/State/SuggestionInteractionState.swift
  • Cotabby/Support/Accessibility/AXHelper.swift
  • Cotabby/Support/Accessibility/BlockBreakAlignment.swift
  • Cotabby/Support/Accessibility/MailHeaderFieldDetector.swift
  • Cotabby/Support/Accessibility/WebContentFieldDetector.swift
  • Cotabby/Support/Context/SurfaceContextComposer.swift
  • Cotabby/Support/Focus/Applications/SystemUIFocusShadowPolicy.swift
  • Cotabby/Support/Focus/CaretAdvanceSampler.swift
  • Cotabby/Support/Input/HostMarkedTextPolicy.swift
  • Cotabby/Support/Presentation/Geometry/CaretGeometrySelector.swift
  • Cotabby/Support/Presentation/Geometry/GhostBaselinePolicy.swift
  • Cotabby/Support/Presentation/Geometry/GhostCaretRefinement.swift
  • Cotabby/Support/Presentation/Geometry/GhostSuggestionLayout.swift
  • Cotabby/Support/Presentation/Geometry/GhostTextLayout.swift
  • Cotabby/Support/Presentation/Geometry/GhostWrapBandPolicy.swift
  • Cotabby/Support/Presentation/Geometry/InkBaselineAnalyzer.swift
  • Cotabby/Support/Presentation/Geometry/InkCaretAnalyzer.swift
  • Cotabby/Support/Presentation/Geometry/MirrorOverlayLayout.swift
  • Cotabby/Support/Presentation/Policy/CaretLagPolicy.swift
  • Cotabby/Support/Presentation/Policy/CompletionRenderModePolicy.swift
  • Cotabby/Support/Presentation/Policy/SuggestionOverlayStabilityGate.swift
  • Cotabby/Support/Presentation/Style/GhostFontMetrics.swift
  • Cotabby/Support/Presentation/Style/GhostFontResolver.swift
  • Cotabby/Support/Presentation/Style/GhostFontSizeStabilizer.swift
  • Cotabby/Support/Presentation/Style/HostAdvanceFit.swift
  • Cotabby/Support/Presentation/Style/HostFaceMemory.swift
  • Cotabby/Support/Presentation/Style/HostZoomLadder.swift
  • Cotabby/Support/Presentation/Style/TypefaceEvidence.swift
  • Cotabby/Support/Presentation/Style/TypefaceMatcher.swift
  • Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift
  • Cotabby/Support/Prompting/ControlTokenMarkers.swift
  • Cotabby/Support/Prompting/SignOffCue.swift
  • Cotabby/Support/Runtime/DecodeStopPolicy.swift
  • Cotabby/Support/Settings/SuggestionSettingsStore.swift
  • Cotabby/Support/Spelling/TypoGate.swift
  • Cotabby/Support/Suggestion/Acceptance/InsertedTextAdvance.swift
  • Cotabby/Support/Suggestion/Output/CompletionContentPolicy.swift
  • Cotabby/Support/Suggestion/Output/GhostSpaceBoundary.swift
  • Cotabby/Support/Suggestion/Output/SuggestionLengthPolicy.swift
  • Cotabby/Support/Suggestion/Output/SuggestionTextNormalizer.swift
  • Cotabby/Support/Suggestion/Request/CaretTokenPosition.swift
  • Cotabby/Support/Suggestion/Request/SuggestionAvailabilityEvaluator.swift
  • Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift
  • Cotabby/Support/Suggestion/Request/WordBoundaryAnchorPolicy.swift
  • Cotabby/Support/Suggestion/Session/SpeculativeAcceptanceContext.swift
  • Cotabby/Support/Suggestion/Session/SuggestionSessionReconciler.swift
  • Cotabby/UI/Overlays/SuggestionOverlayViews.swift
  • CotabbyTests/App/Coordinators/Suggestion/SuggestionCaretLayoutRepairTests.swift
  • CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorInputTests.swift
  • CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorPredictionTests.swift
  • CotabbyTests/App/Coordinators/Suggestion/SuggestionStateHelperTests.swift
  • CotabbyTests/Evals/LlamaEvalScoring.swift
  • CotabbyTests/Evals/LlamaEvalScoringTests.swift
  • CotabbyTests/Evals/LlamaSuggestionEvalTests.swift
  • CotabbyTests/Fixtures/llama-recall-cases.json
  • CotabbyTests/Models/Focus/FocusModelsTests.swift
  • CotabbyTests/Models/Suggestion/ModelAndPresentationValueTests.swift
  • CotabbyTests/Services/Focus/Caching/HostTextMetricsCacheTests.swift
  • CotabbyTests/Services/Focus/Resolution/CaretRunPlacementTests.swift
  • CotabbyTests/Services/Focus/Resolution/ChromiumInlineAutocompleteTests.swift
  • CotabbyTests/Services/Focus/Resolution/FocusSnapshotResolverSelectionTests.swift
  • CotabbyTests/Services/Presentation/HostBaselineCalibratorTests.swift
  • CotabbyTests/Services/Presentation/HostBaselineCalibratorTypefaceTests.swift
  • CotabbyTests/Services/Presentation/HostBundledFontRegistryTests.swift
  • CotabbyTests/Services/Presentation/OverlayControllerTypefaceTests.swift
  • CotabbyTests/Services/Presentation/PixelCaretLocatorTests.swift
  • CotabbyTests/Services/Runtime/Llama/LlamaAbortTargetTests.swift
  • CotabbyTests/Support/Accessibility/BlockBreakAlignmentTests.swift
  • CotabbyTests/Support/Accessibility/MailHeaderFieldDetectorTests.swift
  • CotabbyTests/Support/Accessibility/WebContentFieldDetectorTests.swift
  • CotabbyTests/Support/Context/SurfaceContextComposerTests.swift
  • CotabbyTests/Support/Focus/Applications/SystemUIFocusShadowPolicyTests.swift
  • CotabbyTests/Support/Focus/CaretAdvanceSamplerTests.swift
  • CotabbyTests/Support/Input/HostMarkedTextPolicyTests.swift
  • CotabbyTests/Support/Presentation/Geometry/GhostBaselinePolicyTests.swift
  • CotabbyTests/Support/Presentation/Geometry/GhostCaretRefinementTests.swift
  • CotabbyTests/Support/Presentation/Geometry/GhostSuggestionLayoutTests.swift
  • CotabbyTests/Support/Presentation/Geometry/GhostTextLayoutTests.swift
  • CotabbyTests/Support/Presentation/Geometry/GhostTextPixelAlignmentTests.swift
  • CotabbyTests/Support/Presentation/Geometry/GhostWrapBandPolicyTests.swift
  • CotabbyTests/Support/Presentation/Geometry/InkBaselineAnalyzerTests.swift
  • CotabbyTests/Support/Presentation/Geometry/InkCaretAnalyzerTests.swift
  • CotabbyTests/Support/Presentation/Geometry/MirrorOverlayLayoutTests.swift
  • CotabbyTests/Support/Presentation/Policy/CaretLagPolicyTests.swift
  • CotabbyTests/Support/Presentation/Policy/CompletionRenderModePolicyTests.swift
  • CotabbyTests/Support/Presentation/Style/GhostFontMetricsTests.swift
  • CotabbyTests/Support/Presentation/Style/GhostFontResolverTests.swift
  • CotabbyTests/Support/Presentation/Style/GhostFontSizeStabilizerTests.swift
  • CotabbyTests/Support/Presentation/Style/HostAdvanceFitTests.swift
  • CotabbyTests/Support/Presentation/Style/HostFaceMemoryTests.swift
  • CotabbyTests/Support/Presentation/Style/HostZoomLadderTests.swift
  • CotabbyTests/Support/Presentation/Style/TypefaceEvidenceTests.swift
  • CotabbyTests/Support/Presentation/Style/TypefaceMatcherTests.swift
  • CotabbyTests/Support/Prompting/BaseCompletionPromptRendererTests.swift
  • CotabbyTests/Support/Prompting/ControlTokenMarkersTests.swift
  • CotabbyTests/Support/Prompting/SignOffCueTests.swift
  • CotabbyTests/Support/Runtime/DecodeStopPolicyTests.swift
  • CotabbyTests/Support/Spelling/TypoGateTests.swift
  • CotabbyTests/Support/Suggestion/Acceptance/SuggestionWordAcceptanceTests.swift
  • CotabbyTests/Support/Suggestion/Output/CompletionContentPolicyTests.swift
  • CotabbyTests/Support/Suggestion/Output/GhostSpaceBoundaryTests.swift
  • CotabbyTests/Support/Suggestion/Output/SuggestionLengthPolicyTests.swift
  • CotabbyTests/Support/Suggestion/Output/SuggestionTextNormalizerAnchorTests.swift
  • CotabbyTests/Support/Suggestion/Request/CaretTokenPositionTests.swift
  • CotabbyTests/Support/Suggestion/Request/SuggestionRequestFactoryTests.swift
  • CotabbyTests/Support/Suggestion/Request/WordBoundaryAnchorPolicyTests.swift
  • CotabbyTests/Support/Suggestion/Session/SpeculativeAcceptanceContextTests.swift
  • CotabbyTests/Support/Suggestion/Session/SuggestionSessionReconciliationTests.swift
  • CotabbyTests/TestSupport/CotabbyTestFixtures.swift
  • README.md
  • project.yml

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant