{"slug":"prompt-enhancer","category":"composer","job":"steer","added":"2026-11-07T23:13Z","title":"Prompt Enhancer","description":"An Enhance button that rewrites a short prompt into a specific one in place, highlighting what it added, with one-click undo.","url":"https://ai-patterns.app/patterns/composer/prompt-enhancer","markdown":"https://ai-patterns.app/patterns/composer/prompt-enhancer.md","json":"https://ai-patterns.app/api/patterns/composer/prompt-enhancer","seenIn":[],"spec":"# Prompt Enhancer\n\n## Summary\nAn \"Enhance\" button in the composer that rewrites a short or vague prompt into a more specific one, in place. The added details are highlighted so the person can see what changed, with Undo one click away. It closes the gap between what people type and what the model needs, without making them learn prompting.\n\n## When to use\n- Generative tools where detail drives quality — image, video, design, code generation — and people tend to type a few words.\n- First-run and casual users who don't know which details matter (style, audience, constraints).\n- Products with a known prompt structure, where the rewrite can add the slots people forget.\n\n## When not to use\n- Chat with a capable assistant that can ask for missing details itself — use Clarifying Questions instead of guessing on the user's behalf.\n- Short factual questions. \"What's the capital of Peru?\" has nothing to enhance.\n- Silently, on every send. Rewriting without showing it erodes trust when the output drifts from what was asked.\n\n## Anatomy\n- Composer: the prompt field with an Enhance button (sparkle and label) and the send button.\n- Working state: a shimmer across the field and an \"Enhancing…\" label; the text is read-only meanwhile.\n- Review state: the rewritten prompt with added words highlighted.\n- Review bar: \"Prompt enhanced — additions are highlighted\", Undo and Keep editing.\n- Error line: shown when the rewrite fails, stating that the text is unchanged.\n\n## Behavior\n- Enhance is disabled until there are a few words to work from (default 8 characters), with a tooltip saying why.\n- While enhancing, the field is read-only and the send button is disabled, so the rewrite can't clobber new typing.\n- The rewrite replaces the prompt in place. Additions fade in from a blur onto a light highlight.\n- Undo restores the exact original text and returns focus to the field. Keep editing (or clicking into the text, Enter or Escape) drops the highlights and puts the cursor at the end.\n- Sending works directly from the review state; the enhanced text is what gets sent.\n- On failure the original stays as typed and a short error explains it.\n\n## Content guidelines\n- Keep the user's intent and words; add specifics (style, audience, format, constraints), don't change the subject.\n- Rewrite in the user's language and voice, and keep it to a paragraph — an essay-length prompt is harder to edit than the original.\n- Label the button with a verb: \"Enhance\" or \"Improve prompt\". Avoid \"Magic\".\n\n## Accessibility\n- Enhance is a real button with a visible label; its disabled reason is available as a tooltip.\n- The field carries `aria-busy` while enhancing; the review bar and error sit in an `aria-live=\"polite\"` region so the outcome is announced.\n- The review text is focusable and exposed as a read-only textbox; highlights use `<mark>`, so they are announced as highlighted.\n- The shimmer and highlight animation are skipped when the user prefers reduced motion.\n\n## Related patterns\n- **Prompt Bar** — Use it as well; the Enhance button belongs in its toolbar.\n- **Prompt Template** — Use it instead when the missing details are known slots that people can fill in themselves.\n- **Clarifying Questions** — Use it instead when the assistant should ask rather than guess what the person meant.\n- **Refinement Controls** — Use it instead to adjust a result after it is generated, rather than the prompt before.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowUp, Sparkles, Undo2 } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface PromptEnhancerProps {\n  value: string;\n  onChange: (value: string) => void;\n  /** Returns the rewritten prompt. Reject to keep the original. */\n  onEnhance: (prompt: string) => Promise<string>;\n  onSubmit?: (prompt: string) => void;\n  placeholder?: string;\n  enhanceLabel?: string;\n  /** Prompts shorter than this many characters can't be enhanced. */\n  minLength?: number;\n  className?: string;\n}\n\ntype Phase = \"idle\" | \"enhancing\" | \"review\";\n\nexport function PromptEnhancer({\n  value,\n  onChange,\n  onEnhance,\n  onSubmit,\n  placeholder = \"Describe what you want…\",\n  enhanceLabel = \"Enhance\",\n  minLength = 8,\n  className,\n}: PromptEnhancerProps) {\n  const [phase, setPhase] = React.useState<Phase>(\"idle\");\n  const [original, setOriginal] = React.useState(\"\");\n  const [error, setError] = React.useState(false);\n  const textareaRef = React.useRef<HTMLTextAreaElement>(null);\n  const reduceMotion = useReducedMotion();\n\n  const canEnhance = value.trim().length >= minLength && phase !== \"enhancing\";\n\n  async function enhance() {\n    if (!canEnhance) return;\n    const before = value;\n    setOriginal(before);\n    setError(false);\n    setPhase(\"enhancing\");\n    try {\n      const next = await onEnhance(before);\n      onChange(next);\n      setPhase(\"review\");\n    } catch {\n      setError(true);\n      setPhase(\"idle\");\n    }\n  }\n\n  function undo() {\n    onChange(original);\n    setPhase(\"idle\");\n    requestAnimationFrame(() => textareaRef.current?.focus());\n  }\n\n  function keep() {\n    setPhase(\"idle\");\n    requestAnimationFrame(() => {\n      const el = textareaRef.current;\n      if (!el) return;\n      el.focus();\n      el.setSelectionRange(el.value.length, el.value.length);\n    });\n  }\n\n  const diff = phase === \"review\" ? diffWords(original, value) : null;\n\n  return (\n    <div className={cn(\"flex w-full flex-col gap-2\", className)}>\n      <div\n        className={cn(\n          \"relative overflow-hidden rounded-2xl border bg-card shadow-sm transition-colors\",\n          phase === \"enhancing\" && \"border-foreground/30\"\n        )}\n      >\n        {phase === \"enhancing\" && !reduceMotion && (\n          <motion.div\n            aria-hidden\n            className=\"pointer-events-none absolute inset-0 bg-gradient-to-r from-transparent via-foreground/[0.06] to-transparent\"\n            initial={{ x: \"-100%\" }}\n            animate={{ x: \"100%\" }}\n            transition={{ duration: 1.1, repeat: Infinity, ease: \"easeInOut\" }}\n          />\n        )}\n\n        {diff ? (\n          // Review: the rewritten prompt with additions highlighted. Clicking\n          // into it, or \"Keep\", goes back to plain editing.\n          <div\n            role=\"textbox\"\n            aria-readonly=\"true\"\n            aria-label=\"Enhanced prompt\"\n            tabIndex={0}\n            onClick={keep}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\" || e.key === \"Escape\") {\n                e.preventDefault();\n                keep();\n              }\n            }}\n            className=\"min-h-24 cursor-text px-4 pt-3 pb-12 text-sm leading-relaxed whitespace-pre-wrap outline-none\"\n          >\n            {diff.map((part, i) =>\n              part.added ? (\n                <motion.mark\n                  key={i}\n                  initial={reduceMotion ? false : { opacity: 0, filter: \"blur(4px)\" }}\n                  animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                  transition={{ duration: 0.45, delay: Math.min(i * 0.04, 0.6) }}\n                  className=\"rounded-[3px] bg-foreground/10 px-px text-foreground\"\n                >\n                  {part.text}\n                </motion.mark>\n              ) : (\n                <span key={i}>{part.text}</span>\n              )\n            )}\n          </div>\n        ) : (\n          <textarea\n            ref={textareaRef}\n            value={value}\n            onChange={(e) => {\n              setError(false);\n              onChange(e.target.value);\n            }}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\" && !e.shiftKey && onSubmit && value.trim()) {\n                e.preventDefault();\n                onSubmit(value);\n              }\n            }}\n            readOnly={phase === \"enhancing\"}\n            aria-busy={phase === \"enhancing\"}\n            placeholder={placeholder}\n            aria-label=\"Prompt\"\n            rows={3}\n            className=\"block min-h-24 w-full resize-none bg-transparent px-4 pt-3 pb-12 text-sm leading-relaxed outline-none placeholder:text-muted-foreground\"\n          />\n        )}\n\n        <div className=\"absolute inset-x-3 bottom-2.5 flex items-center justify-between gap-2\">\n          <button\n            type=\"button\"\n            onClick={enhance}\n            disabled={!canEnhance}\n            title={value.trim().length < minLength ? \"Write a few words first\" : \"Rewrite with more detail\"}\n            className=\"inline-flex items-center gap-1.5 rounded-full border bg-background px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50\"\n          >\n            <motion.span\n              animate={phase === \"enhancing\" && !reduceMotion ? { rotate: [0, 18, -12, 0], scale: [1, 1.15, 1] } : { rotate: 0, scale: 1 }}\n              transition={phase === \"enhancing\" ? { duration: 0.9, repeat: Infinity } : { duration: 0.2 }}\n              className=\"flex\"\n            >\n              <Sparkles className=\"size-3.5\" aria-hidden />\n            </motion.span>\n            {phase === \"enhancing\" ? \"Enhancing…\" : enhanceLabel}\n          </button>\n\n          {onSubmit && (\n            <button\n              type=\"button\"\n              aria-label=\"Send\"\n              disabled={!value.trim() || phase === \"enhancing\"}\n              onClick={() => onSubmit(value)}\n              className=\"flex size-8 items-center justify-center rounded-full bg-foreground text-background transition-opacity disabled:opacity-30\"\n            >\n              <ArrowUp className=\"size-4\" aria-hidden />\n            </button>\n          )}\n        </div>\n      </div>\n\n      <div aria-live=\"polite\" className=\"min-h-7\">\n        <AnimatePresence mode=\"wait\" initial={false}>\n          {phase === \"review\" && (\n            <motion.div\n              key=\"review\"\n              initial={{ opacity: 0, y: -4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: -4 }}\n              transition={{ duration: 0.15 }}\n              className=\"flex flex-wrap items-center gap-x-3 gap-y-1 px-1 text-xs\"\n            >\n              <span className=\"text-muted-foreground\">Prompt enhanced — additions are highlighted.</span>\n              <button\n                type=\"button\"\n                onClick={undo}\n                className=\"inline-flex items-center gap-1 font-medium text-foreground underline-offset-2 hover:underline\"\n              >\n                <Undo2 className=\"size-3.5\" aria-hidden />\n                Undo\n              </button>\n              <button type=\"button\" onClick={keep} className=\"font-medium text-foreground underline-offset-2 hover:underline\">\n                Keep editing\n              </button>\n            </motion.div>\n          )}\n          {error && (\n            <motion.p\n              key=\"error\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              className=\"px-1 text-xs text-destructive\"\n            >\n              Couldn&apos;t enhance this prompt. Your text is unchanged.\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n\n/** Word-level diff (longest common subsequence) marking words that are new in `after`. */\nfunction diffWords(before: string, after: string) {\n  const a = before.split(/(\\s+)/);\n  const b = after.split(/(\\s+)/);\n  const norm = (w: string) => w.toLowerCase().replace(/[^\\p{L}\\p{N}]/gu, \"\");\n  const dp = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));\n  for (let i = a.length - 1; i >= 0; i--) {\n    for (let j = b.length - 1; j >= 0; j--) {\n      dp[i][j] = norm(a[i]) === norm(b[j]) && a[i].trim() !== \"\" ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);\n    }\n  }\n  const parts: { text: string; added: boolean }[] = [];\n  const push = (text: string, added: boolean) => {\n    // Whitespace joins whichever run it sits in, so highlights read as phrases.\n    const last = parts[parts.length - 1];\n    if (last && (last.added === added || text.trim() === \"\")) last.text += text;\n    else parts.push({ text, added });\n  };\n  let i = 0;\n  let j = 0;\n  while (j < b.length) {\n    if (i < a.length && norm(a[i]) === norm(b[j]) && a[i].trim() !== \"\") {\n      push(b[j], false);\n      i++;\n      j++;\n    } else if (i < a.length && dp[i + 1][j] >= dp[i][j + 1]) {\n      i++;\n    } else {\n      push(b[j], b[j].trim() !== \"\");\n      j++;\n    }\n  }\n  return parts;\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}