Inline Completion
Ghost text for a textarea — a caret-aligned mirror paints the model's continuation in muted type, Tab accepts it, Cmd/Ctrl+Right takes one word, Esc dismisses.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/inline-completion.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "InlineCompletion" kit: Copilot-style ghost
text for a plain <textarea>. No editor framework — a textarea can only paint its
own value, so the continuation lives in a mirror element laid over the field.
Ship three pieces so a field that already has an owner can get completions
without giving up its value, its autosize or its Enter-to-send:
useInlineCompletion(options) — the engine (requests, state machine, keys)
<InlineCompletionGhost /> — the mirror that paints the ghost
<InlineCompletion /> — both, wrapped around a <textarea>
Contract
- useInlineCompletion({
textareaRef, // RefObject<HTMLTextAreaElement | null>
onRequestCompletion?, // (ctx) => string | null | Promise<...>
suggestion?, status?, // controlled mode (see below)
debounceMs = 350,
minPrefix = 2, // chars before the caret before asking
acceptOnTab = true,
onAccept?, onDismiss?, onRequestError?, onStatusChange?,
}) => {
status, suggestion, text, caret,
accept, acceptWord, dismiss, refresh, sync,
ghostProps, textareaProps, announcement,
}
- status: "idle" | "pending" | "suggesting" | "error".
- Request context: { value, caret, prefix, suffix, signal }. prefix/suffix are
the two halves around the caret; signal aborts when the request is superseded.
- Accept context: { mode: "all" | "word", remaining, value, caret }.
- <InlineCompletion> extends TextareaHTMLAttributes plus every hook option minus
textareaRef, plus hint (corner key chips, null removes), errorLabel,
announce ("polite" | "off"), containerClassName. forwardRef goes to the
textarea; className styles the textarea, not the wrapper.
- Controlled mode: passing `suggestion` (even "") turns the request engine off —
the caller paints whatever it wants and owns invalidation. `status` can be
passed on its own to drive the pending shimmer from an external loader.
Behavior — the request
- Requests are scheduled on `input` only, after `debounceMs` of quiet. Caret
moves and focus changes never fire one; they only invalidate.
- Skip the request when: the field is disabled/readOnly, the selection is a
range (no caret to hang a continuation off), caret < minPrefix, or the caret
sits at the position Escape last dismissed.
- Race guard: keep a monotonic request id and an AbortController. Every new
request, dismissal, blur or unmount bumps the id and aborts the previous
signal; a resolution whose id is stale is dropped without touching state.
- Staleness is checked against the LIVE DOM, not against React state: after the
await, re-read value + caret and re-anchor (below). Anything that cannot be
re-anchored is discarded — a continuation painted at the wrong caret is worse
than no continuation.
- Errors that are not aborts set status "error" and call onRequestError; the
draft is never touched and the next keystroke schedules a fresh request.
Behavior — keeping a suggestion alive while typing (the part everyone skips)
- Typing the characters the model already proposed is the most common thing a
user does with ghost text. Re-requesting on each of them makes the suggestion
blink. Instead re-anchor: if the new value equals the old one with `typed`
inserted at the anchor, and the ghost starts with `typed`, shave `typed` off
the ghost and move its anchor to the new caret. No request, no flicker.
- The same routine runs on a late answer, so a suggestion the user has already
started typing still lands (shortened) instead of being thrown away.
- Typing the ghost out completely clears it and schedules the next request.
- Everything else — a different character, a caret move, a range selection, a
pointerdown, a blur — drops the ghost. Only Escape arms the one-shot lock, so
walking away and coming back suggests again, while an explicit dismissal
stays dismissed until the value changes.
- IME: compositionstart clears the ghost and blocks requests, compositionend
resumes. Never hide the field's glyphs mid-composition, or the underlined
candidate text becomes invisible.
Behavior — accepting
- Tab accepts the whole suggestion; Cmd/Ctrl+ArrowRight accepts leading spaces
plus the next word (or a lone newline), leaves the rest on screen re-anchored
after the inserted text, and can be pressed repeatedly.
- Write with document.execCommand("insertText") and fall back to the
HTMLTextAreaElement value setter + a synthetic `input` event. execCommand is
deprecated but it is the only way to edit a field and keep the browser's undo
stack: Ctrl+Z takes the accepted words back out instead of wiping the draft.
The fallback's synthetic event is what React's change tracker listens for, so
a controlled host still sees an onChange.
- The accept sets a "self edit" marker before writing, because insertText
dispatches `input` synchronously and the handler must not treat the component's
own write as the user typing.
- A controlled host may echo the value back and move the caret to the end;
restore it once in a rAF, guarded on the value being unchanged, and cancel
that frame on unmount.
- Accepting does not chain another request — the next keystroke does.
Behavior — key claim
- Attach keydown in the CAPTURE phase on the element itself, and claim
(preventDefault + stopPropagation) ONLY while a suggestion is visible: the
host composer's Enter-to-send never sees the Tab that accepted a completion,
a surrounding dialog never sees the Escape that dismissed one, and with no
suggestion on screen Tab remains the keyboard user's way out of the field.
- Ignore keys while event.isComposing || keyCode === 229.
- Shift/Alt/Meta+Tab are never claimed.
Rendering — the mirror
- The overlay is an aria-hidden, pointer-events-none, select-none div that
copies the field's computed color, font, letter-spacing, line-height,
padding, border widths, text-align, direction, white-space, overflow-wrap,
word-break and tab-size, then positions itself at the field's offsetTop /
offsetLeft / offsetWidth / offsetHeight inside the shared positioned wrapper.
Copying beats a duplicated class list: the overlay follows whatever font or
density the consumer puts on the field.
- Set the mirror to box-sizing: border-box with a transparent border colour, and
add the vertical scrollbar's width to its padding-right (offsetWidth -
clientWidth - borders) — otherwise the two boxes wrap at different columns the
moment the field scrolls. Mirror scrollTop/scrollLeft on every content change
and on the field's scroll event.
- While the ghost is painting, hide the field's glyphs with
-webkit-text-fill-color: transparent (NOT color: transparent — that would take
the caret with it and poison the copied colour) and let the mirror paint
value.slice(0, caret) + <span class="text-muted-foreground">{ghost}</span> +
value.slice(caret) + a zero-width space (a value ending in a newline needs one
more character before the last line is laid out). That is what lets a
suggestion in the MIDDLE of the value push the tail sideways instead of
printing on top of it. Restore the fill colour whenever nothing is painted, so
an idle field keeps native selection, spellcheck and IME rendering.
- Pending marker: render only the text before the caret, transparent, followed
by an EMPTY inline <span class="relative"> holding an absolutely positioned
bar. An empty inline contributes no character to the line breaker, so the
marker can never be pushed onto a line of its own the way an inline-block can,
and it adds no width, so waiting never reflows the draft.
- Only render mirror content while there is a ghost or a pending marker; an
empty overlay is invisible and cheap.
Rendering & styling
- Semantic tokens only: text-muted-foreground for the ghost, bg-muted plus a
from-transparent / via-foreground/40 / to-transparent sweep for the shimmer,
border-input + focus-visible:ring-ring/50 on the field, bg-background/85 +
border for the corner chip, text-destructive for the error note. No hex, no
rgb(), no oklch(). Merge every className through cn().
- Two keyframes ship in a React 19 hoisted <style href precedence>: a 140ms
opacity fade for a new ghost and a looping translateX sweep for the shimmer.
Both are motion-reduce:[animation:none]; under reduced motion the ghost
appears instantly and the shimmer bar stays a static muted block, so the
pending state is still legible.
- The ghost span carries no React key, so it survives the shave: the fade plays
once per suggestion, not once per keystroke.
- Accessibility: aria-autocomplete="inline" on the field, an sr-only
role="status" aria-live="polite" region that gets "Suggestion: … Press Tab to
accept." once per new suggestion (announce="off" when a surrounding component
already owns a live region), the mirror aria-hidden, the key-hint chip
aria-hidden (it repeats what the announcement says), the error note a
role="status".
- Clean up everything: debounce timeout, rAF, AbortController, ResizeObserver,
the scroll listener, all eight field listeners, and the inline fill-colour
override.
Customization levers
- Timing: debounceMs (350) trades model calls for responsiveness — 150 for a
local model, 600+ for a paid API. minPrefix (2) stops requests on an almost
empty field.
- Bindings: acceptOnTab={false} frees Tab entirely and leaves accepting to your
own shortcut through accept() / acceptWord(); refresh() is the "suggest now"
entry point for a button or Ctrl+Space.
- Trigger policy: requests fire on input only. To suggest on caret moves too,
call refresh() from your own selection handler; to chain completions, call
refresh() inside onAccept.
- Ghost look: swap text-muted-foreground for an underlined or italic treatment,
or add a background wash — the ghost is one span. hint={null} drops the corner
chips, or pass your own nodes for a different key legend.
- Pending affordance: replace the shimmer bar with a spinner, a dot, or nothing
at all (status is on the wrapper as data-status, and onStatusChange lets a
toolbar own the indicator instead).
- Controlled mode: pass suggestion + status from react-query / your own stream
and the component becomes pure paint + key handling; onAccept gives you the
remaining text so a word-by-word accept just feeds it back.
- Field shape: className styles the textarea (rows, font-mono, density),
containerClassName the wrapper. Everything the mirror needs is read from the
computed style, so no second class list to keep in sync.Concepts
- Caret mirror — a second element copies the field's computed typography, padding and border, so text wraps at the same columns; splicing the suggestion into that copy is what puts ghost text at the caret without an editor framework, and hiding only the field's glyph fill keeps the caret, the selection and the IME candidates where the browser drew them.
- Re-anchoring — a suggestion is a promise about one exact
(value, caret)pair; when the user types the characters the model already proposed, the ghost is shaved and moved forward instead of being re-requested, which is the difference between a suggestion that flickers on every keystroke and one that feels like it is being typed with you. - Stale-answer drop — the model answers a caret that may have moved; a monotonic request id plus an abort signal makes every superseded answer unusable, and the survivors are validated against the live DOM rather than against React state, which can be a render behind.
- Partial accept — Cmd/Ctrl+Right takes leading whitespace plus one word (or a lone newline) and leaves the remainder anchored after the inserted text, so a user can walk into a long suggestion word by word and stop the moment it stops being right.
- Capture-phase key claim — Tab, Cmd/Ctrl+Right and Escape are intercepted on the element in the capture phase and only while a suggestion is visible, so the host's Enter-to-send, the dialog's Escape and the keyboard user's Tab-out all keep working when nothing is being offered.
- One-shot dismiss lock — Escape records the caret position it was pressed at and refuses to suggest there again until the value changes; a caret move or a blur, by contrast, drops the ghost without arming anything, so coming back and typing offers a completion once more.
Agent Graph
A workflow DAG drawn in pure SVG from nodes and edges alone — layered auto-layout with crossing reduction, typed node shapes, status tints, flowing active wires, loop-back routing and four data states.
File Change List
The files an agent touched — middle-truncated paths, A/M/D/R marks, churn bars scaled across the whole run, one-shot Accept/Revert per file plus a bulk bar, and a slot for the inline diff.