System prompt editor
A textarea editor for system prompts with `{{variable}}` highlighting, a live token count against a budget, optional line numbers, version history with restore, unsaved changes and Cmd/Ctrl+S to save.
On this page
Installation
Usage
import { SystemPromptEditor } from "@/components/ui/chatcn/system-prompt-editor"
<SystemPromptEditor
value={saved}
onSave={(next) => savePrompt(next)}
variables={["customer_name", "plan", "language"]}
versions={versions}
tokenBudget={2000}
/>value is the saved prompt. The editor keeps its own draft, shows Unsaved changes with Discard, and saves with the button or Cmd/Ctrl+S. When value changes from outside, a clean draft follows it and a dirty one is kept.
Pass a real tokenizer as countTokens when you have one, for example js-tiktoken or your provider's count endpoint behind a debounce. The default is four characters per token, shown with ≈.
import { getEncoding } from "js-tiktoken"
const enc = getEncoding("o200k_base")
<SystemPromptEditor countTokens={(text) => enc.encode(text).length} tokenBudget={4000} />Examples
Line numbers
Numbers count lines you wrote, not lines on screen: a wrapped line keeps one number and the gutter grows with it.
Token budget
States
How the highlighting works
A plain textarea with transparent text sits exactly over a highlighted copy of its contents. Both share one scroll container, font, line height, padding and wrapping, and no highlighted run changes weight or spacing, so every glyph lines up. The caret, selection, undo, find, spellcheck and IME composition all stay the browser's own. There is no contenteditable and no editor dependency.
| Text | Treatment |
|---|---|
{{name}} in variables | Blue tint, braces dimmed |
{{name}} not in variables | Amber tint and a wavy underline. The header counts them |
<rules>, </context> | XML-style tags recede to gray |
| Everything else | Plain |
Props
| Prop | Type | Notes |
|---|---|---|
value, defaultValue | string | The saved prompt |
onDraftChange | (draft) => void | Every edit |
onSave | (content) => void | Promise | Shows Save. Throw to show the error |
versions | PromptVersion[] | Newest first: { id, content, savedAt, author?, label? } |
onRestore | (version) => void | Restoring loads the version as an unsaved draft |
countTokens | (text) => number | Defaults to a characters / 4 estimate |
tokenBudget | number | Amber from 80%, red past it with the overage |
lineNumbers | boolean | |
variables | string[] | Known names. Unset treats every placeholder as known |
title, placeholder | ReactNode, string | Defaults to "System prompt" |
maxHeight, minHeight | number | string | Defaults to 360 and 160. Longer prompts scroll inside |
readOnly, disabled | boolean |
Keyboard
| Key | Action |
|---|---|
Cmd+S, Ctrl+S | Save, anywhere inside the editor. Ignored while an IME is composing |
Tab | Leaves the editor, so keyboard users are never trapped. Indent with spaces |
ArrowDown, ArrowUp, Enter | In History: move and restore |
Accessibility
- The title labels the textarea; the highlighted copy is
aria-hidden, so screen readers read the prompt once. - The token budget is a
meterwith the full count as its text. Save state is announced through a polite live region. - Unknown variables carry an underline as well as color, and the header says how many there are.
- History is a menu: arrow keys, typeahead and Escape behave as in every menu in the kit.
- Prompts in right-to-left languages lay out right to left, with the gutter on the start side.
Motion
- Typing never animates. The Saved check crossfades in with the 150 ms blur swap and fades back after 2 s.
- The History menu opens from its trigger in 180 ms and skips the animation when opened with the keyboard.