AI

System Prompt Editor

A system-prompt editing surface: an autosizing monospace field with placeholder highlighting painted behind the caret, a live token estimate against a budget, presets that insert without destroying work, and an unsaved bar that owns Save and Discard.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Braces,
  ChevronsDownUp,
  ChevronsUpDown,
  FileText,
  LoaderCircle,
  Lock,
  Pencil,
  RotateCcw,
  Save,
  Sparkles,

Installation

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

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "SystemPromptEditor" component: the panel
where a team edits the standing instructions of one assistant. Dependencies:
lucide-react icons, a shadcn Button and a shadcn DropdownMenu. No editor
library — the editable surface is a plain <textarea>.

Contract
- forwardRef<HTMLTextAreaElement>, extends TextareaHTMLAttributes minus value /
  defaultValue / onChange / rows. `className` styles the card; the rest of the
  spread lands on the textarea.
- Two values, never one:
  - value?: string — the SAVED prompt (the backend's copy). defaultValue?:
    string seeds an uncontrolled editor.
  - draft?: string + onDraftChange?(draft) — what is in the box. Omit to let
    the component own it.
  - dirty = draft !== value. That single comparison drives the whole bar.
- onSave?(draft), onDiscard?(discarded), status?: "idle" | "saving" | "error",
  errorMessage?: string.
- collapsed? / defaultCollapsed? / onCollapsedChange?, collapsible? (default
  true), previewLines? (default 3).
- presets?: { id, label, description?, body, group? }[] with onPresetInsert?.
- tokenBudget?: number (soft), countTokens?: (source) => number (default: the
  built-in estimator), knownVariables?: string[].
- minRows? (8), maxRows? (20), label? ("System prompt"), hint?, placeholder?,
  disabled?, readOnly?, spellCheck? (default false — a prompt is technical
  text and red squiggles under every identifier are noise).
- Also export the pure helpers: splitPromptSegments(source),
  extractPromptVariables(source), estimateTokens(source).

Behavior — saved vs draft
- Uncontrolled: Save commits the draft to the internal baseline, so the bar
  disappears on its own — which means the Save button that was just pressed
  unmounts, so focus goes back to the textarea before it does, the same handover
  Discard makes. Controlled: Save only calls onSave and waits for a new `value`,
  which is exactly what makes "saving" observable — the bar (and the focus on
  it) survives until the parent answers, so it must NOT be stolen there.
- When the baseline changes underneath: a CLEAN editor follows it (a colleague's
  edit shows up), a DIRTY one keeps the user's text and raises a drift notice
  ("the saved copy changed elsewhere — saving overwrites it"). Never overwrite
  unsaved work to stay in sync. Do this by comparing against the previous
  baseline during render — no effect, no flash of the wrong text.
- Save is one-shot: a ref lock set before onSave prevents a double click from
  firing twice, and it is released when the draft, the baseline or `status`
  changes. status="saving" additionally disables both buttons.
- status="error" keeps every character, shows one role="alert" line, and leaves
  the bar exactly where it was. A failed save must never clear the box.

Behavior — discard, collapse
- Discard arms before it fires: the first press turns the button destructive and
  reads "Discard changes?", the second one resets the draft to the baseline. The
  armed state is stored as the DRAFT STRING it was armed against, not a boolean,
  so typing one character disarms it with no effect and no stale confirmation
  can ever fire against text written after arming. It also expires on a 4s timer
  (cleared on unmount).
- Collapsing while dirty is refused, not silently allowed: flash "Save or
  discard first" on the bar, move focus to Save, clear the hint on a 2.6s timer.
  Hiding unsaved work behind a summary is how work gets lost. Escape requests
  the same collapse (and therefore the same refusal).
- The collapsed view renders the DRAFT, not the stale saved copy: first
  `previewLines` lines with the same highlighting, "+N more lines", a token /
  line / variable count, an "Unsaved" pill when dirty, and one Edit button that
  expands AND puts the caret at the end. Empty prompt gets its own branch: "No
  system prompt set — the model answers on provider defaults" plus an "Add
  instructions" button. readOnly turns Edit into Expand.

Behavior — placeholders and tokens
- A variable is a CLOSED double-brace pair around an identifier
  ([A-Za-z_][\w.-]*), optional inner spaces. A half-typed opener stays plain
  text: highlighting it the moment the closing braces land and dropping it again
  on the next keystroke reads as flicker.
- Cut the source into alternating prose / placeholder runs once and reuse that
  list for the mirror, the collapsed preview and the variable list (distinct
  names, first-appearance order).
- knownVariables is the runtime's contract: any placeholder outside it gets a
  destructive wavy underline plus a "never supplied: x, y" footer line. Omit the
  prop and every placeholder counts as known.
- The default token estimate ships no vocabulary: a word-ish run costs one token
  per ~4 characters, every other non-space character costs one (which lands CJK
  near one token per glyph), whitespace is free. Render it as "~1,204 / 4,000
  tokens" with a fixed en-US formatter so server and client agree. Over budget
  is SOFT: the counter turns destructive, the field goes aria-invalid, nothing
  is truncated and Save is not blocked.

Behavior — presets
- The menu never destroys work. A blank prompt is replaced outright; anything
  else keeps its text and takes the preset AT THE CARET, separated by one blank
  line (add "\n\n", or "\n" if the text already ends in a newline). A field the
  user never focused takes the preset at the END, not at offset 0.
- Apply the preset in the menu's onCloseAutoFocus, not in onSelect: remember the
  chosen preset in a ref, preventDefault the auto-focus, then focus the textarea
  and insert. Doing it in onSelect fights the menu's own focus restoration.
- Insert through document.execCommand("insertText") so the browser's native undo
  stack survives — Cmd/Ctrl Z after a preset puts the old text back. If it is
  refused, fall back to a state update and restore the caret in a layout effect
  that only fires when the field's value matches what the edit produced.

Behavior — the field itself
- Autosize on every draft change: height:auto, then clamp scrollHeight between
  minRows and maxRows converted to pixels through the computed line-height and
  box; switch overflow-y to auto only at the ceiling. DOM writes only, in a
  layout effect, so the box is right before paint.
- Cmd/Ctrl S and Cmd/Ctrl Enter save (preventDefault so the browser's Save Page
  dialog stays shut, stopPropagation so an app-level handler does not save the
  same thing twice).
- A polite sr-only role="status" region derived from props — never a timer —
  announces "Unsaved changes", "Saving system prompt", the error, or the refusal
  to collapse.

Rendering & styling — the highlight mirror
- The editable layer is a plain textarea, never a contenteditable: IME
  composition, spellcheck, undo, autofill and every caret shortcut keep working
  for free. Colour comes from a mirror <div> painted BEHIND it.
- The mirror is absolutely positioned inset-0, aria-hidden, pointer-events-none,
  select-none, overflow-hidden, whitespace-pre-wrap + break-words, and shares
  ONE constant of typography and padding with the textarea
  ("px-3 py-2.5 font-mono text-[13px] leading-6"). The textarea sits on top with
  position:relative and a transparent background, so its own glyphs are what the
  reader sees and the mirror only contributes backgrounds.
- The mirror's text is transparent and each placeholder is a <mark> with
  bg-primary/15 (bg-destructive/15 + a wavy destructive underline when
  unknown) — <mark> carries a UA background AND colour, so override both.
- Two alignment traps: (1) append a trailing "\n" to the mirror, because a final
  newline has no line box in a div but does in a textarea; (2) add the scrollbar
  gutter (offsetWidth - clientWidth - borders) to the mirror's padding-right,
  because where scrollbars take layout width the textarea loses those pixels the
  moment it scrolls and the mirror would wrap one word later.
- Sync mirror.scrollTop / scrollLeft from the textarea's onScroll and again in
  the resize layout effect.
- Semantic tokens only: bg-card, bg-muted/40, bg-muted/60, border, text-
  foreground, text-muted-foreground, text-primary, bg-primary/10, bg-primary/15,
  text-destructive, bg-destructive/10, ring-ring, focus-visible rings from the
  Button primitive. No hex, no rgb(), no oklch().
- Accessibility: a real <label htmlFor> on the field; aria-describedby to the
  meta footer; aria-invalid over budget or on error; aria-expanded +
  aria-controls on the collapse control; icons aria-hidden; the only animation
  is the saving spinner, which is motion-reduce:animate-none and always paired
  with the word "Saving…".

Customization levers
- Density and typography: SURFACE is the one constant both layers read — change
  the font, size, line-height or padding there and they move together. Never
  restyle only one layer.
- Which sub-blocks exist: pass presets={[]} to drop the menu, collapsible={false}
  to pin the editor open, tokenBudget={undefined} for a bare counter,
  knownVariables={undefined} to accept every placeholder.
- Placeholder syntax: swap the regex for ${...}, %name% or your own dialect —
  splitPromptSegments is the only place that knows.
- Token truth: pass countTokens={t => enc.encode(t).length} to use the real
  tokenizer, or a cost function to show dollars instead of tokens.
- Save policy: make the budget hard by disabling Save while over budget; make
  Discard immediate by dropping the arm step; swap the arm-then-confirm for an
  AlertDialog if your users need a modal.
- Autosize: minRows / maxRows are the only height knobs; maxRows={Infinity}-ish
  (a large number) turns off internal scrolling and lets the page grow.
- Collapsed summary: previewLines controls how much shows; render a diff against
  the saved copy there instead of the plain first lines if your product has
  prompt versioning.

Concepts

  • Baseline vs draft — the component holds two strings, not one: the saved copy your backend owns and the text in the box. Everything the bar does is a reaction to draft !== baseline, which is why "unsaved" never needs its own flag to fall out of sync.
  • Mirror layer — highlighting sits in a second element painted behind a transparent-background textarea with identical typography, padding, wrapping, scroll offset and scrollbar gutter; the caret, IME and undo stay native because nothing about the editable element changed.
  • Insert on close — a preset is remembered in a ref when the menu item is chosen and applied in onCloseAutoFocus, after the menu has released focus, through execCommand so one Cmd/Ctrl Z takes it back out.
  • Arm-then-confirm — Discard stores the draft string it was armed against rather than a boolean, so typing a single character disarms it automatically and a stale confirmation can never throw away text written after the arming click.
  • One-shot lock — the Save press sets a ref before calling out, so a double click produces one request; the lock releases only when the draft, the baseline or the status actually changes.
  • Blocked collapse — folding the editor while it is dirty would hide work behind a three-line summary, so the request is refused, the bar flashes "Save or discard first" and the keyboard is moved onto the button that resolves it.

On This Page