AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Loader2, RotateCcw, Sparkles, TriangleAlert, Undo2, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Keyframes
 *
 * Shipped with the component through a React 19 hoisted <style> — no Tailwind
 * config edit, and duplicate instances dedupe by href.
 * -------------------------------------------------------------------------- */
const KEYFRAMES = `@keyframes pe-sweep{from{transform:translateX(-100%)}to{transform:translateX(300%)}}@keyframes pe-rise{from{opacity:0;transform:translateY(0.25rem)}to{opacity:1;transform:translateY(0)}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/prompt-enhancer.json

Prompt

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: onEnhance is 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.

On This Page