Ghost text A completion offered after the caret in a lighter weight, accepted a word at a time. The usable contrast window is narrow: too light to read on one side, indistinguishable from committed text on the other. Tokens this needs: --card, --card-foreground, --foreground, --muted-foreground, --border, --input, --ring ──────────────────────────────────────────────────────────────────────── // GhostText.tsx ──────────────────────────────────────────────────────────────────────── import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import { cn } from "@/lib/utils"; /** * A completion after the caret, in the place the viewer's next words would go. * * The textarea is real and sits on top; underneath it a mirror repeats the * committed text invisibly so the suggestion starts exactly where the caret * is. The suggestion is muted and italic, because the muted grey alone has * a narrow window between unreadable and indistinguishable, and the slant * carries the provisional state when the grey is at either edge of it. */ type Props = { value: string; onChange: (value: string) => void; /** Asked once the typing has paused, never per keystroke. Return null for * no suggestion. */ suggest: (value: string) => string | null | Promise; /** How long a pause is. Shorter gets more suggestions and worse ones. */ pauseMs?: number; /** A suggestion to show before the first pause. Mostly for a server render. */ initialSuggestion?: string | null; placeholder?: string; className?: string; }; /** The first word of the suggestion and the space after it. */ const firstWord = (s: string) => s.match(/^\s*\S+\s?/)?.[0] ?? s; export function GhostText({ value, onChange, suggest, pauseMs = 400, initialSuggestion = null, placeholder, className }: Props) { const [suggestion, setSuggestion] = useState(initialSuggestion); // The value a dismissed suggestion belonged to. The next pause on the same // text stays quiet rather than offering back the thing that was just refused. const dismissedFor = useRef(null); useEffect(() => { if (dismissedFor.current === value) return; const t = setTimeout(async () => setSuggestion(await suggest(value)), pauseMs); return () => clearTimeout(t); }, [value, pauseMs, suggest]); const accept = (text: string) => { onChange(value + text); const rest = suggestion!.slice(text.length); setSuggestion(rest.length ? rest : null); }; const onKeyDown = (e: KeyboardEvent) => { if (!suggestion) return; if (e.key === "Tab") { e.preventDefault(); accept(firstWord(suggestion)); } else if (e.key === "ArrowRight" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); accept(suggestion); } else if (e.key === "Escape") { e.preventDefault(); dismissedFor.current = value; setSuggestion(null); } // Any other key: the suggestion stays until the pause timer replaces it, // so it is not dismissed by the keystroke that was already in flight. }; const editor = "whitespace-pre-wrap break-words px-3 py-2 text-sm leading-relaxed"; return (