{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"prompt-editor","type":"registry:ui","title":"Prompt Editor","description":"A prompt template editor with {{variables}} highlighted as you type, a rendered preview, and a line diff against the previous version.","author":"Scrim UI (https://scrimui.dev)","categories":["prompt-input"],"docs":"https://scrimui.dev/components/prompt-editor","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/prompt-editor/prompt-editor.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * A prompt template editor: {{variables}} highlighted as you type, a rendered\n * preview, and a line diff against a previous version.\n *\n * The highlighting is a transparent textarea over a highlighted <pre>. The\n * trick is well known; the traps are in keeping the two layers pixel-identical:\n *\n *  1. ANY TYPOGRAPHIC DRIFT DESYNCS THE LAYERS. Font, size, line height,\n *     padding and wrapping must match exactly — a textarea's default font is\n *     not the pre's, so both get the same classes, and the shared values live\n *     in one constant so they cannot drift apart in two places.\n *  2. THE TEXTAREA SCROLLS, THE PRE DOES NOT. Without scroll sync the\n *     highlights stay behind while the text moves. The pre's scrollTop is\n *     mirrored from the textarea's onScroll — an event handler, so touching\n *     the ref there is safe.\n *  3. A TRAILING NEWLINE COLLAPSES IN THE PRE BUT NOT THE TEXTAREA. The pre\n *     renders one line short and the caret appears to float. A zero-width\n *     space is appended to the mirror text so both layers keep the last line.\n *  4. UNRESOLVED VARIABLES MUST LOOK DIFFERENT FROM KNOWN ONES. A typo in a\n *     {{name}} is silent in plain text and loud in preview. Known variables\n *     get one tint, anything else matching {{...}} gets a warning tint, and\n *     `renderTemplate` leaves unknown names untouched rather than blanking\n *     them — \"{{naem}}\" in the output beats an empty hole.\n */\n\n/* ------------------------------------------------------------------ */\n/* Template rendering                                                  */\n/* ------------------------------------------------------------------ */\n\nconst VARIABLE_PATTERN = /\\{\\{\\s*([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\}\\}/g;\n\n/** The variable names a template references, in order of first appearance. */\nexport function templateVariables(template: string): string[] {\n  const names: string[] = [];\n  for (const match of template.matchAll(VARIABLE_PATTERN)) {\n    if (!names.includes(match[1])) names.push(match[1]);\n  }\n  return names;\n}\n\n/**\n * Substitute {{name}} from `values`. A name with no value is left as-is —\n * \"{{naem}}\" in the output is a visible mistake; an empty string where a name\n * should be is an invisible one.\n */\nexport function renderTemplate(template: string, values: Record<string, string>): string {\n  return template.replace(VARIABLE_PATTERN, (raw, name: string) =>\n    Object.prototype.hasOwnProperty.call(values, name) ? values[name] : raw,\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Line diff (for compareWith)                                         */\n/* ------------------------------------------------------------------ */\n\ntype DiffLine = { text: string; side: \"same\" | \"removed\" | \"added\" };\n\n/** LCS over lines. Prompts are short; the quadratic table is never felt. */\nfunction diffLines(before: string, after: string): DiffLine[] {\n  const a = before.replace(/\\n$/, \"\").split(\"\\n\");\n  const b = after.replace(/\\n$/, \"\").split(\"\\n\");\n\n  const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array(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      table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);\n    }\n  }\n\n  const lines: DiffLine[] = [];\n  let i = 0;\n  let j = 0;\n  while (i < a.length && j < b.length) {\n    if (a[i] === b[j]) {\n      lines.push({ text: a[i], side: \"same\" });\n      i++;\n      j++;\n    } else if (table[i + 1][j] >= table[i][j + 1]) {\n      lines.push({ text: a[i++], side: \"removed\" });\n    } else {\n      lines.push({ text: b[j++], side: \"added\" });\n    }\n  }\n  while (i < a.length) lines.push({ text: a[i++], side: \"removed\" });\n  while (j < b.length) lines.push({ text: b[j++], side: \"added\" });\n  return lines;\n}\n\n/* ------------------------------------------------------------------ */\n/* Highlight layer                                                     */\n/* ------------------------------------------------------------------ */\n\ntype Token = { text: string; variable: string | null; known: boolean };\n\nfunction tokenize(template: string, known: string[] | undefined): Token[] {\n  const tokens: Token[] = [];\n  let last = 0;\n  for (const match of template.matchAll(VARIABLE_PATTERN)) {\n    if (match.index > last) tokens.push({ text: template.slice(last, match.index), variable: null, known: false });\n    tokens.push({\n      text: match[0],\n      variable: match[1],\n      known: known === undefined || known.includes(match[1]),\n    });\n    last = match.index + match[0].length;\n  }\n  if (last < template.length) tokens.push({ text: template.slice(last), variable: null, known: false });\n  return tokens;\n}\n\n/* Shared by both layers — the whole trick fails if these drift. */\nconst LAYER_CLASSES =\n  \"m-0 whitespace-pre-wrap break-words p-3 font-mono text-[13px] leading-6\";\n\n/* ------------------------------------------------------------------ */\n/* Component                                                           */\n/* ------------------------------------------------------------------ */\n\nexport type PromptEditorProps = {\n  value: string;\n  onChange: (value: string) => void;\n  /** Known variable names. Any {{other}} gets a warning tint. Omit to accept everything. */\n  variables?: string[];\n  placeholder?: string;\n  rows?: number;\n  /** Sample values. When provided, a Write / Preview toggle appears. */\n  previewValues?: Record<string, string>;\n  /** A previous version of the template; renders a line diff below the editor. */\n  compareWith?: string;\n  className?: string;\n};\n\nexport function PromptEditor({\n  value,\n  onChange,\n  variables,\n  placeholder = \"Write the prompt… {{variables}} are highlighted as you type.\",\n  rows = 8,\n  previewValues,\n  compareWith,\n  className = \"\",\n}: PromptEditorProps) {\n  const [tab, setTab] = React.useState<\"write\" | \"preview\">(\"write\");\n  const mirrorRef = React.useRef<HTMLPreElement>(null);\n\n  const tokens = tokenize(value, variables);\n  const used = templateVariables(value);\n  const unknown = variables ? used.filter((n) => !variables.includes(n)) : [];\n\n  const diff = React.useMemo(\n    () => (compareWith !== undefined && compareWith !== value ? diffLines(compareWith, value) : null),\n    [compareWith, value],\n  );\n\n  return (\n    <div className={`overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900 ${className}`}>\n      {previewValues !== undefined && (\n        <div className=\"flex items-center gap-1 border-b border-zinc-100 px-2 py-1.5 dark:border-zinc-800\">\n          {([\"write\", \"preview\"] as const).map((t) => (\n            <button\n              key={t}\n              type=\"button\"\n              onClick={() => setTab(t)}\n              aria-pressed={tab === t}\n              className={`h-7 rounded-lg px-2.5 text-[12px] font-medium capitalize transition-colors ${\n                tab === t\n                  ? \"bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900\"\n                  : \"text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n              }`}\n            >\n              {t}\n            </button>\n          ))}\n          <span className=\"ml-auto text-[11px] text-zinc-400 dark:text-zinc-500\">\n            {used.length} {used.length === 1 ? \"variable\" : \"variables\"} · {value.length} chars\n          </span>\n        </div>\n      )}\n\n      {tab === \"preview\" && previewValues !== undefined ? (\n        <div\n          className={`${LAYER_CLASSES} text-zinc-800 dark:text-zinc-200`}\n          style={{ minHeight: `${rows * 1.5 + 1.6}rem` }}\n        >\n          {renderTemplate(value, previewValues) || <span className=\"text-zinc-400 dark:text-zinc-500\">Nothing to preview.</span>}\n        </div>\n      ) : (\n        <div className=\"relative\">\n          {/* Highlight mirror. aria-hidden: the textarea carries the text. */}\n          <pre ref={mirrorRef} aria-hidden className={`${LAYER_CLASSES} pointer-events-none absolute inset-0 overflow-hidden text-zinc-800 dark:text-zinc-200`}>\n            {tokens.map((token, i) =>\n              token.variable === null ? (\n                <React.Fragment key={i}>{token.text}</React.Fragment>\n              ) : (\n                <span\n                  key={i}\n                  className={\n                    token.known\n                      ? \"rounded-[3px] bg-emerald-100 text-emerald-900 dark:bg-emerald-950 dark:text-emerald-300\"\n                      : \"rounded-[3px] bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-300\"\n                  }\n                >\n                  {token.text}\n                </span>\n              ),\n            )}\n            {/* A trailing newline collapses in the pre but not the textarea —\n                the zero-width space keeps the last line in both layers. */}\n            {\"\\u200B\"}\n          </pre>\n          <textarea\n            value={value}\n            onChange={(e) => onChange(e.target.value)}\n            onScroll={(e) => {\n              const mirror = mirrorRef.current;\n              if (mirror) {\n                mirror.scrollTop = e.currentTarget.scrollTop;\n                mirror.scrollLeft = e.currentTarget.scrollLeft;\n              }\n            }}\n            placeholder={placeholder}\n            rows={rows}\n            spellCheck={false}\n            className={`${LAYER_CLASSES} relative block w-full resize-y bg-transparent text-transparent caret-zinc-900 outline-none placeholder:text-zinc-400 dark:caret-zinc-100 dark:placeholder:text-zinc-500`}\n          />\n        </div>\n      )}\n\n      {unknown.length > 0 && tab === \"write\" && (\n        <p className=\"border-t border-amber-100 bg-amber-50 px-3 py-1.5 text-[12px] text-amber-800 dark:border-amber-950 dark:bg-amber-950/40 dark:text-amber-300\">\n          {unknown.length === 1 ? \"Unknown variable\" : \"Unknown variables\"}: {unknown.map((n) => `{{${n}}}`).join(\", \")} — not in the variables list, left as-is in preview.\n        </p>\n      )}\n\n      {diff !== null && (\n        <div className=\"border-t border-zinc-100 dark:border-zinc-800\">\n          <p className=\"px-3 pt-2 text-[11px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500\">\n            Against the previous version\n          </p>\n          <div className=\"py-1.5 font-mono text-[12px] leading-5\">\n            {diff.map((line, i) =>\n              line.side === \"same\" ? (\n                <div key={i} className=\"truncate px-3 text-zinc-400 dark:text-zinc-500\">\n                  {\"  \"}\n                  {line.text}\n                </div>\n              ) : (\n                <div\n                  key={i}\n                  className={`truncate px-3 ${\n                    line.side === \"removed\"\n                      ? \"bg-red-50 text-red-800 dark:bg-red-950/40 dark:text-red-300\"\n                      : \"bg-emerald-50 text-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200\"\n                  }`}\n                >\n                  {line.side === \"removed\" ? \"− \" : \"+ \"}\n                  {line.text}\n                </div>\n              ),\n            )}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/prompt-editor.tsx"}]}