Prompt Enhancer
An "improve my prompt" affordance for a prompt field — a shimmering request state, a word-level diff of the rewrite, and a one-shot Accept with undo.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/prompt-enhancer.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PromptEnhancer" component: an
"improve my prompt" affordance that wraps a prompt field, calls an injected
async rewriter, and shows the result as a word-level diff the author has to
accept. Dependencies: lucide-react, a shadcn Button, a cn() class merger. The
word diff is implemented in the file — no diff library.
Contract
- forwardRef<PromptEnhancerHandle, PromptEnhancerProps>; the rest of the props
spread onto the root <div>. The ref is an imperative handle, not a DOM node:
{ readonly textarea, focus(), enhance(): boolean, dismiss() } so a toolbar,
slash command or shortcut elsewhere can drive the same run.
- onEnhance: (prompt: string, ctx: { signal: AbortSignal }) => string |
Promise<string> — the only required prop. It receives the current draft and a
signal that fires on Cancel AND on unmount.
- value? / defaultValue? / onValueChange?(next, { cause }) with
cause = "input" | "accept" | "undo". Controlled when `value` is passed;
otherwise the component owns the draft.
- onAccept?({ previous, suggestion }), onKeep?(draft) — telemetry hooks;
onAccept fires exactly once per suggestion.
- name?, placeholder?, rows? (4), minChars? (12), maxAttempts? (3),
diff?: "inline" | "added-only" | "off" ("inline"),
announce?: "status" | "full" | "off" ("status"), disabled?,
enhanceLabel / cancelLabel / acceptLabel / keepLabel / retryLabel.
- Clamp every numeric knob (Math.max(1, Math.floor(x)), non-finite falls back to
the default): a 0 or NaN cap would either lock the button forever or hand out
an unlimited budget.
Behavior — the run
- One state machine: idle → requesting → suggested → (accepted | kept) → idle,
plus an error branch that is retried from the same budget.
- Starting a run: refuse (with a readable reason in the status line, never a
dead button) when the trimmed draft is shorter than minChars or the per-draft
budget is spent. Otherwise mint an AbortController + a monotonic runId, pin
`baseline` to the exact string handed to onEnhance, clear any previous
suggestion/undo/notice, and switch the phase.
- Wrap the call as `new Promise(resolve => resolve(onEnhance(...)))`, not
Promise.resolve(onEnhance(...)): a synchronous throw inside the enhancer has
to become a rejection, otherwise it escapes and pins the field at
"requesting" forever.
- Both settle paths start with `if (controller.signal.aborted || runIdRef !==
runId) return`. That one guard covers cancel, supersede and unmount — no
state update can outlive the component.
- An empty (or whitespace-only) resolution is an error, not a suggestion:
"The rewrite came back empty."
- Cancel aborts the signal, bumps the runId so a response already on the wire
cannot resurrect the panel, and leaves the draft byte-for-byte untouched.
- The field stays EDITABLE while the run is in flight; the overlay is
pointer-events-none. If the draft moved by the time the answer lands, the
suggestion is kept and flagged stale — the diff still compares against
`baseline` (what the model actually saw) and the panel says so — instead of
being silently dropped or silently re-diffed against text the model never
read.
- Retry budget: maxAttempts runs per draft VERSION. Typing refills it (but not
while its own run is in flight, which would hand out a free attempt), and
cancelling refunds the attempt — the budget counts answers, not button
presses. Show "N of M rewrites left" in the panel footer.
Behavior — accept / keep / undo
- Accept is one-shot, locked on the suggestion's id in a ref, not on state:
two clicks landing in the same task share one render's closure, so a state
guard would let the second one through and push a duplicate undo entry and a
second onAccept.
- Accepting writes the suggestion through the same commit path as typing
(cause: "accept"), stores { previous, next } as a single undo entry, resets
the attempt budget and clears the panel.
- Undo restores `previous` (cause: "undo"). It is dropped as soon as the author
types again — an undo that reverts a draft the user has since edited is a
data-loss bug wearing a friendly label.
- "Keep mine" doubles as the cancel action while requesting; Escape in the
field or in the panel does the same, and only calls preventDefault +
stopPropagation when there IS something of ours to close, so an Escape inside
a dialog still reaches the dialog.
- Focus handoff: every control that disappears on click hands focus somewhere
first. Accept/Keep → the field (caret at the end after accept); Undo → the
field, always, because the button is removed by its own click; Retry → the
trigger button, which has just become Cancel. Do the move in a layout effect
keyed on a tick, not in the handler: the node that should receive focus (and
the value the caret belongs behind) only exists after the panel is gone.
- The trigger is ONE button in both phases (Improve ⇄ Cancel). Swapping two
<button>s remounts the node and drops keyboard focus at the exact moment the
run starts.
Behavior — the word diff
- Diff unit = one word plus the whitespace that followed it. A line diff says
nothing here ("the whole prompt is one changed line"), and keeping the
whitespace OUTSIDE the <ins>/<del> box stops an underline from smearing
across the gap between two words.
- Match words on a normalised key: lower-cased, leading/trailing punctuation
stripped (fall back to the raw word when that leaves nothing). "outage" →
"Outage," is not a change worth highlighting. Render EQUAL runs from the
suggestion side, so what is read is exactly what Accept will apply.
- Trim the common head and tail first, then run an LCS over the middle only;
every trimmed word is a row/column never allocated. Fill a suffix LCS table
(Int32Array, cell(i,j) over aMid[i..]/bMid[j..]) so the emit loop walks
forward and the runs come out in reading order. Ties go to insertions, so a
replacement reads "new words, then the old ones struck through".
- Cap the matrix (~160k cells). Past that, report the changed middle as one
delete + one insert instead of letting a pasted document freeze the tab.
- Merge adjacent runs of the same kind, and expose { runs, added, removed,
beforeWords, afterWords } — the counts are the panel header AND the
announcement.
- Zero inserts and zero deletes (including a whitespace-only reflow) is its own
outcome: render the suggestion plain, say "No changes suggested", and hide
Accept — there is nothing to apply.
Rendering & styling
- Semantic tokens only: bg-background, bg-card, bg-muted, bg-primary/10,
via-primary/15, text-foreground, text-muted-foreground, text-primary,
text-destructive, border-input, border, ring-ring. No hex, no rgb(), no
oklch().
- Shell: rounded border + focus-within:ring-3 ring-ring/50, a textarea, and a
footer strip with the word count on the left and the trigger on the right.
- Requesting overlay: an aria-hidden span over the field only (not the footer)
= a bg-background/30 veil plus a one-third-wide gradient bar
(from-transparent via-primary/15 to-transparent) swept by keyframes
translateX(-100%) → translateX(300%). Ship the keyframes in a React 19
hoisted <style href precedence> so instances dedupe and no Tailwind config is
touched. The sweep is motion-reduce:hidden and the spinner is
motion-reduce:animate-none — under reduced motion the veil and the status
line still say "busy".
- Marks: <ins> and <del> are real elements (semantics first), styled
bg-primary/10 + underline decoration-primary and bg-muted +
text-muted-foreground line-through. Never colour-only.
- The diff body is whitespace-pre-wrap + wrap-anywhere so newlines survive and
an unbreakable token cannot widen the card; it scrolls at max-h-80.
- One live region, not three: a single status line under the field carries the
phase ("Rewriting your prompt…"), the counts ("12 words added, 4 removed"),
the stale note and every refusal/error. An explicit notice always wins over
the phase text, otherwise a refusal raised while the panel is open would be
swallowed by the panel's own summary. announce="full" adds the clean
suggestion as sr-only text (a marked-up diff read straight through is a
jumble); announce="off" renders the same sentence as inert text for forms
that already own a region.
- Blocked controls use aria-disabled + a guard in the handler, never the native
attribute: the button keeps focus and clicking it explains the refusal.
`disabled` puts the textarea in readOnly + aria-disabled so the draft is
still selectable and copyable.
Customization levers
- Field: swap the <textarea> for your own autosizing editor — the rest of the
component only needs `text` and a commit function.
- diff mode: "inline" for review, "added-only" when the rewrite is long and the
deletions are noise, "off" when the enhancer returns a full replacement.
- Word matching: replace normalizeWord with the identity function if casing and
punctuation are meaningful in your prompts (code, regexes, exact-match
strings).
- Budget: maxAttempts={Infinity} removes the cap and the footer counter; lower
minChars for short image prompts, raise it for system prompts.
- Marks: give <ins> a background-only treatment, or a left border, if the
underline fights your type; the two class strings are the only place to
change.
- Motion: the 1.5s sweep is the only decorative animation — drop the gradient
bar for a plain veil, or slow it down for a calmer "thinking" feel.
- Density: the shell's px-3 py-2.5, the panel's gap-3 p-3 and text-sm are the
spacing knobs; the whole panel is one JSX branch you can restructure (move
the counts into the footer, add a Copy button next to Accept).
- Wiring: keep onAccept/onKeep for telemetry (accept rate per model is the
metric that tells you whether the enhancer is worth its tokens).Concepts
- Injected enhancer — the component owns the run, not the rewriting:
onEnhanceis a function the host passes in, so the same UI sits on top of a server action, a local model or a canned rule set, and the abort signal it receives is the only cancellation channel needed. - Baseline pinning — a suggestion remembers the exact draft it was computed from, so typing while the request is in flight can never silently change what the diff is comparing; the answer comes back flagged stale instead of quietly lying about what changed.
- One-shot apply — accepting is locked on the suggestion's id in a ref rather than on state, because several clicks can land in one task and share a single render's closure; the lock is what makes "applied exactly once, with exactly one undo entry" true rather than likely.
- Word-level emphasis — the diff unit is a word plus its trailing whitespace, matched case- and punctuation-insensitively, so the marks land on meaning instead of on re-capitalisation, and the whitespace stays outside the mark so underlines stop at the word.
- Refuse out loud — every blocked control stays focusable and clickable and answers with the reason (too short, budget spent) in the same live region that carries the phase; a greyed-out button that does nothing teaches the user nothing.
- Focus handoff — Accept, Keep mine, Undo and Retry all destroy the element the user is standing on, so each one names its successor (the field, the caret at the end, the trigger that just became Cancel) before the DOM moves.
Prompt Variables
An inline template filler — every {{variable}} slot becomes a chip-sized text, select or number editor inside the sentence, repeats mirror the first one live, and the preview is byte-identical to what gets sent.
Persona Picker
Choose the assistant persona a turn runs under — seeded avatar cards or a compact combobox, capability tags, the system-prompt fragment each persona quietly injects, unavailable rows that say why, and four data states.