-

Prompt Editor

PreviousNext

A prompt composition surface with variable insertion, copy, run actions, and lightweight prompt stats.

Installation

pnpm
pnpm dlx shadcn@latest add https://ui.zgi.ai/r/prompt-editor.json
npm
pnpm dlx shadcn@latest add https://ui.zgi.ai/r/prompt-editor.json
yarn
yarn dlx shadcn@latest add https://ui.zgi.ai/r/prompt-editor.json

Component

components/ui/prompt-editor.tsx
"use client"

import * as React from "react"
import {
  BracesIcon,
  CheckIcon,
  CopyIcon,
  PlayIcon,
  WandSparklesIcon,
} from "lucide-react"

import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"

export interface PromptEditorVariable {
  label: string
  value: string
}

export interface PromptEditorProps
  extends Omit<
    React.ComponentProps<"textarea">,
    "onChange" | "title" | "value"
  > {
  value: string
  onValueChange?: (value: string) => void
  title?: React.ReactNode
  description?: React.ReactNode
  variables?: PromptEditorVariable[]
  onRun?: () => void
  onImprove?: () => void
  showStats?: boolean
  editorClassName?: string
}

function countWords(value: string) {
  return value.trim() ? value.trim().split(/\s+/).length : 0
}

export function PromptEditor({
  value,
  onValueChange,
  title = "Prompt",
  description,
  variables = [],
  onRun,
  onImprove,
  showStats = true,
  disabled,
  readOnly,
  className,
  editorClassName,
  ...props
}: PromptEditorProps) {
  const [copied, setCopied] = React.useState(false)
  const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
  const wordCount = countWords(value)
  const estimatedTokens = Math.ceil(wordCount * 1.35)

  function insertVariable(variable: PromptEditorVariable) {
    const textarea = textareaRef.current
    const start = textarea?.selectionStart ?? value.length
    const end = textarea?.selectionEnd ?? value.length
    const next = `${value.slice(0, start)}${variable.value}${value.slice(end)}`

    onValueChange?.(next)
    window.requestAnimationFrame(() => {
      textarea?.focus()
      const cursor = start + variable.value.length
      textarea?.setSelectionRange(cursor, cursor)
    })
  }

  async function copyPrompt() {
    await navigator.clipboard.writeText(value)
    setCopied(true)
    window.setTimeout(() => setCopied(false), 1200)
  }

  return (
    <div
      className={cn(
        "border-border bg-card text-card-foreground overflow-hidden rounded-lg border shadow-sm",
        className
      )}
    >
      <div className="border-border flex items-start justify-between gap-3 border-b p-3">
        <div className="min-w-0">
          <div className="flex min-w-0 items-center gap-2">
            <WandSparklesIcon className="text-muted-foreground size-4 shrink-0" />
            <p className="truncate text-sm font-medium">{title}</p>
          </div>
          {description ? (
            <p className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-5">
              {description}
            </p>
          ) : null}
        </div>
        <div className="flex shrink-0 items-center gap-1">
          {onImprove ? (
            <Button
              type="button"
              variant="ghost"
              size="icon"
              className="size-8 rounded-md"
              disabled={disabled || readOnly}
              onClick={onImprove}
              aria-label="Improve prompt"
            >
              <WandSparklesIcon className="size-4" />
            </Button>
          ) : null}
          <Button
            type="button"
            variant="ghost"
            size="icon"
            className="size-8 rounded-md"
            onClick={copyPrompt}
            aria-label="Copy prompt"
          >
            {copied ? (
              <CheckIcon className="size-4" />
            ) : (
              <CopyIcon className="size-4" />
            )}
          </Button>
          {onRun ? (
            <Button
              type="button"
              size="icon"
              className="size-8 rounded-md"
              disabled={disabled}
              onClick={onRun}
              aria-label="Run prompt"
            >
              <PlayIcon className="size-4" />
            </Button>
          ) : null}
        </div>
      </div>
      {variables.length ? (
        <div className="border-border flex flex-wrap gap-1.5 border-b p-3">
          {variables.map((variable) => (
            <Button
              key={`${variable.label}-${variable.value}`}
              type="button"
              variant="outline"
              size="sm"
              className="h-7 rounded-md px-2 text-xs"
              disabled={disabled || readOnly}
              onClick={() => insertVariable(variable)}
            >
              <BracesIcon className="size-3.5" />
              {variable.label}
            </Button>
          ))}
        </div>
      ) : null}
      <Textarea
        ref={textareaRef}
        value={value}
        onChange={(event) => onValueChange?.(event.target.value)}
        disabled={disabled}
        readOnly={readOnly}
        spellCheck={false}
        className={cn(
          "min-h-56 resize-y rounded-none border-0 bg-transparent font-mono text-sm shadow-none focus-visible:ring-0",
          editorClassName
        )}
        {...props}
      />
      {showStats ? (
        <div className="border-border flex flex-wrap items-center gap-2 border-t px-3 py-2">
          <Badge variant="secondary" className="rounded-sm">
            {wordCount} words
          </Badge>
          <Badge variant="secondary" className="rounded-sm">
            ~{estimatedTokens} tokens
          </Badge>
          <span className="text-muted-foreground ml-auto text-xs">
            {value.length} characters
          </span>
        </div>
      ) : null}
    </div>
  )
}

Usage

import { PromptEditor } from "@/components/ui/prompt-editor"
 
export function Example() {
  return (
    <PromptEditor
      value="Use {{conversation.intent}} to route the user."
      onValueChange={(value) => console.log(value)}
      variables={[{ label: "Intent", value: "{{conversation.intent}}" }]}
    />
  )
}

Props

PropTypeDefault
valuestringrequired
onValueChange(value: string) => void-
titleReactNode"Prompt"
descriptionReactNode-
variablesPromptEditorVariable[][]
onRun() => void-
onImprove() => void-
showStatsbooleantrue