Conversation
…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.
|
Too many files changed for review (151 files, 100 file limit). Bypass the limit by tagging |
|
Important Review skippedToo 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (151)
You can disable this status message by setting the 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. Comment |
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:
GhostTextLayout,GhostFontResolverandGhostBaselinePolicylay the ghost out, andHostTextMetricsProbefinds 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).CompletionRenderModePolicy), and a single-line field keeps the ghost to one row. Chromium paragraphs are read fromAXValuerather than the run-together range text (BlockBreakAlignment), and marker lines from glyph boxes.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).WordBoundaryAnchorPolicywith the engine'ssetRequiredPrefix). 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).test_reportRecallSuite), a per-run model override for the eval suites, and developer tooling (forced suggestions, key tracing, a focus-overlay switch, capture dumps).Validation
Live pixel alignment on the final build, with the harness described above (every keystroke guarded against a focus change or a system prompt):
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
project.ymlresolvesCotabbyInferencefrom thefeat/required-prefixbranch ofMason363/cotabbyinference(upstreammainplus that one commit). Once it merges, a follow-up commit here points it back atFuJacob/cotabbyinferencemainand regenerates the project.cotabbyHostFaceMemory, keeps the settled face and line pitch per app style across launches; browser page origins are never written. Opt-out switches:cotabbyContinuationPrefetchDisabledandcotabbyChromiumEnhancedAccessibilityDisabled(below). Developer switches, off by default:cotabbyDebugForcedSuggestion(read only under-cotabby-debug),cotabbyDebugFocusOverlayHidden, andcotabbyDumpCalibrationStrips(writes captures under~/Library/Logs).AXEnhancedUserInterface(the VoiceOver signal) so Chrome computes character bounds and font attributes; Chrome rejectsAXManualAccessibility, the Electron flag. Some window managers misplace windows of apps with that flag set, socotabbyChromiumEnhancedAccessibilityDisabledturns it off, and placement then uses caret markers with font matching by size only.