UI

Prompt Bar

A compact prompt input with removable context chips, a toolbar slot for your model picker, and async send, stop, and retry states.

Props

Disabled
Maximum input height240px

Installation

Prompt Bar is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.

$npx shadcn@latest add @akoder/prompt-bar

The @akoder namespace ships with the shadcn CLI, so there is nothing to configure first. Install any other component the same way from the Components page.

Usage

import { PromptBar } from "@/components/ui/prompt-bar";
 
export function PromptInput() {
  return (
    <PromptBar
      contexts={[
        { id: "page", label: "Current page", description: "The page being edited" },
      ]}
      onSubmit={async ({ text, contexts }) => {
        // Supply this endpoint in your own app.
        const response = await fetch("/api/chat", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            text,
            contexts: contexts.map(({ id, label }) => ({ id, label })),
          }),
        });
        if (!response.ok) throw new Error("The prompt could not be sent. Try again.");
      }}
    />
  );
}

The component owns its pending state while it awaits onSubmit. Resolve the callback when your app has accepted the submission. Throw or reject to show an error and keep the draft. The component has no AI SDK dependency and does not make network requests itself. Use it inside a client component.

Custom slash commands and context

Supply your own slash commands through skills and your own mention choices through contextOptions. There are no built-in skills, file paths, or prompt templates. Both menus search the item's id, label, and description.

  • / selects one skill. Selecting another replaces it. The skill can carry instructions and typed data for any behavior your app supports.
  • @ calls onContextAdd with the complete context item. Update contexts to display the chip. Already selected IDs are hidden from the menu.
  • Selecting either replaces the typed command fragment with a chip at the caret. Chips stay inline with the surrounding text. The plain text and selected objects arrive separately in onSubmit.
  • data is app-owned and passed through unchanged. Use it for text content, file IDs, page references, or other metadata. A file label alone does not load its contents.

This example assembles a usable prompt in the consumer. Its context data is typed, so the same payload is available in onContextAdd and onSubmit without a cast:

"use client";
 
import { useState } from "react";
import {
  PromptBar,
  type PromptBarContext,
  type PromptBarSkill,
} from "@/components/ui/prompt-bar";
 
type FileContext = { path: string; content: string };
type SkillData = { mode: "review" | "rewrite" };
 
const skills: PromptBarSkill<SkillData>[] = [
  {
    id: "review",
    label: "Code review",
    description: "Check correctness and accessibility",
    instructions: "Review the supplied code. Explain actionable issues and fixes.",
    data: { mode: "review" },
  },
  {
    id: "rewrite",
    label: "Rewrite",
    instructions: "Rewrite the supplied code to satisfy the request. Explain the changes.",
    data: { mode: "rewrite" },
  },
];
 
export function ReviewInput({
  files,
  send,
}: {
  files: FileContext[];
  send: (prompt: string, mode?: SkillData["mode"]) => Promise<void>;
}) {
  const [contexts, setContexts] = useState<PromptBarContext<FileContext>[]>([]);
  const contextOptions: PromptBarContext<FileContext>[] = files.map((file) => ({
    id: file.path,
    label: file.path,
    description: "Include this file's contents",
    data: file,
  }));
 
  return (
    <PromptBar<FileContext, SkillData>
      skills={skills}
      contextOptions={contextOptions}
      contexts={contexts}
      onContextAdd={(item) => setContexts((current) =>
        current.some((context) => context.id === item.id) ? current : [...current, item]
      )}
      onContextRemove={(id) => setContexts((current) =>
        current.filter((item) => item.id !== id)
      )}
      onSubmit={async ({ text, skill, contexts: selected, attachments }) => {
        // This example supports text attachments. Restrict and validate as needed.
        const attachedText = await Promise.all(attachments.map(async (file) =>
          `File: ${file.name}\n${await file.text()}`
        ));
        const prompt = [
          skill?.instructions,
          text,
          ...selected.map((item) => item.data
            ? `File: ${item.data.path}\n${item.data.content}`
            : undefined
          ),
          ...attachedText,
        ].filter(Boolean).join("\n\n");
 
        await send(prompt, skill?.data?.mode);
      }}
      accept=".txt,.md,.ts,.tsx"
    />
  );
}

Prompt assembly, file loading, upload, tool execution, and model requests belong to your app. The prompt bar does not append instructions or data to text automatically. For remote context, pass an ID in data, resolve it in your submit handler or server, and throw if it cannot be loaded. Validate permissions, contents, and file limits on your server; accept is only a picker hint. Keep secrets out of client-side options.

Control the selected skill

By default, the prompt bar owns skill selection. Use defaultSkill for an initial selection, or pass skill and onSkillChange to control it:

const [skill, setSkill] = useState<PromptBarSkill<SkillData> | null>(null);
 
<PromptBar<FileContext, SkillData>
  skills={skills}
  skill={skill}
  onSkillChange={setSkill}
  onSubmit={sendSubmission}
/>

In this fragment, sendSubmission is your callback accepting PromptBarSubmission<FileContext, SkillData>. onSkillChange receives the selected item, or null when its chip is removed or a submission succeeds. Failures and Stop preserve the selection. Keep controlled and uncontrolled modes consistent for the component's lifetime. In controlled mode, your app must update skill when the callback fires.

With Model Picker

Install Model Picker separately with npx shadcn add @akoder/model-picker, then compose it through the toolbar slot:

import { useState } from "react";
import { ModelPicker, defaultModelProviders } from "@/components/ui/model-picker";
import {
  PromptBar,
  type PromptBarContext,
  type PromptBarSubmission,
} from "@/components/ui/prompt-bar";
 
export function ContextualInput({
  send,
}: {
  send: (message: PromptBarSubmission & { model: string }) => Promise<void>;
}) {
  const [model, setModel] = useState(defaultModelProviders[0].models[0].id);
  const [contexts, setContexts] = useState<PromptBarContext[]>([
    { id: "page", label: "Current page" },
    { id: "component", label: "model-picker.tsx" },
  ]);
 
  return (
    <PromptBar
      contexts={contexts}
      onContextRemove={(id) =>
        setContexts((current) => current.filter((context) => context.id !== id))
      }
      toolbar={
        <ModelPicker
          providers={defaultModelProviders}
          value={model}
          onValueChange={setModel}
          closeOnSelect
        />
      }
      onSubmit={(message) => send({ ...message, model })}
    />
  );
}

Provider catalogs, model selection and context data stay in the consumer. Toolbar buttons must use type="button" unless they deliberately submit the form. Do not put another form inside the toolbar.

Props

PropTypeDefaultNotes
onSubmit Required(submission: PromptBarSubmission) => void | Promise<void>-Receives trimmed text and a snapshot of the current contexts. Resolving clears the submitted draft; rejecting preserves it and shows an error.
valuestringUncontrolledControlled text. Supply onValueChange to update it. Keep the controlled mode consistent for the life of the component.
defaultValuestring""Initial uncontrolled draft.
onValueChange(value: string) => voidNoneCalled when text changes, including successful submission clearing it.
contextsreadonly PromptBarContext[][]Context chips. Give each item a stable, unique id.
contextOptionsreadonly PromptBarContext[][]Consumer-defined choices for the @ menu.
onContextAdd(context: PromptBarContext) => voidNoneEnables the @ menu. Receives the full item; update contexts to include it.
onContextRemove(id: string) => voidNoneShows removal buttons. Update your contexts when called. Without it, chips are read-only.
skillsreadonly PromptBarSkill[][]Consumer-defined choices for the / menu.
skillPromptBarSkill | nullUncontrolledControlled selection. Pair with onSkillChange. null means no selection.
defaultSkillPromptBarSkill | nullnullInitial uncontrolled selection. Later changes do not replace it.
onSkillChange(skill: PromptBarSkill | null) => voidNoneFires on selection, removal, and successful submission clearing.
toolbarReactNodeNoneOptional controls below the input, such as Model Picker.
onStop() => voidNoneSynchronous cancellation callback, such as aborting the consumer's AbortController. Shows Stop while busy. Late completions of the stopped submission do not clear the draft.
submittingbooleanfalseAdditional consumer-owned busy state. The consumer must reset it after stopping.
errorstringNoneAdditional consumer-owned error message. Clear it in the consumer when resolved.
disabledbooleanfalseDisables input, context removal, toolbar controls and sending.
placeholderstring"Ask anything..."Empty input hint.
labelstring"Your prompt"Accessible prompt input label, visually hidden.
maxHeightnumber240Textarea height ceiling in pixels. Values below 96 clamp to 96; non-finite values use 240.
modelLabelstringNoneSelected model's name in context details.
contextUsagePromptBarUsageNoneProvider-reported usedTokens, maxTokens, and optional cacheHitRate (0 to 100). Omit when unavailable.
acceptstringNoneNative file-picker hint. Does not validate or read contents.
maxFilesnumber5Maximum number of attachments. Finite values clamp to an integer of at least 1.
maxFileSizenumber10485760Per-file size limit in bytes (10 MB). Finite values clamp to at least 1.
classNamestringNoneAdditional form layout classes.

Context item

FieldTypeNotes
idstringStable, unique identifier supplied to onContextRemove.
labelstringVisible chip label, truncated when necessary.
descriptionstringOptional menu and context-details text. Also searchable.
iconReactNodeOptional chip and menu icon.
dataTDataOptional consumer-defined payload. Preserved in selection callbacks and submissions.

Skill item

PromptBarSkill<TData> includes all context-item fields plus optional instructions: string. Instructions describe how your app should handle the prompt; they are passed through, not executed. IDs must be stable and unique within each menu.

Submission

PromptBarSubmission<TContextData, TSkillData> contains:

FieldTypeNotes
textstringTrimmed input, without selected command fragments.
contextsreadonly PromptBarContext<TContextData>[]Snapshot of selected context items, including their payloads.
skillPromptBarSkill<TSkillData> | nullSelected slash command, including instructions and payload.
attachmentsreadonly File[]Actual browser files. Read or upload them in the consumer.

The generic parameters default to unknown. Supply payload types on the component, PromptBarProps, and PromptBarSubmission when you want typed access. Arrays are copied for submission, but nested data is not deep-cloned. Treat selected items and their payloads as immutable. Do not JSON-serialize the entire submission: icons are React nodes and attachments are browser files. Select the fields your endpoint accepts, and upload files with FormData or your app's uploader.

Behavior

  • Enter sends; Shift+Enter creates a new line. Enter during IME composition does not send.
  • Submission requires non-whitespace text or an attachment. A skill or context chip alone does not enable sending. A synchronous lock prevents duplicate sends before React renders the pending state.
  • The inline input grows with its text and container width, then scrolls at maxHeight. Tiptap manages editing, selection, and text undo. The registry installs its React, ProseMirror, and StarterKit dependencies.
  • Input is read-only while submitting, so the submitted draft stays stable. If a consumer replaces controlled text while a request is pending, successful completion does not clear the replacement.
  • A failure keeps the text and context. Editing or retrying clears internally caught errors. The consumer owns explicit error props.
  • Stop preserves the draft. The consumer must actually cancel its work in onStop; the prompt bar ignores that request's later result.
  • Skills use a Zap icon by default. Supply an item's icon to override it.
  • Inline chips wrap with the text and long labels truncate. Remove buttons appear on hover or keyboard focus and remain visible on touch devices.
  • Replacing a controlled value replaces the text and puts retained context, skill, and attachment chips at the start of the new draft.
  • Screen readers receive the label, keyboard hint, error and submission status. Icons do not add duplicate accessible names.

Demo

The preview uses fixture skills, context items, model names, usage measurements, and a sample response. It makes no AI request and does not load the file named in its context menu. Type / or @ to inspect selection, or send and immediately choose Stop response to inspect cancellation.

The installed component contains no analytics. This site's demo tracks only a successful demo submission marker, never prompt text or context content.

Manual installation

Rather not use the CLI? Everything Prompt Bar needs is below. Install its dependencies, then copy the file into the matching path in your project.

Dependencies

$npm install lucide-react motion @tiptap/react@3.31.4 @tiptap/pm@3.31.4 @tiptap/starter-kit@3.31.4

shadcn primitives

$npx shadcn@latest add badge command field hover-card input-group popover progress separator

Source

components/ui/prompt-bar.tsx522 lines
"use client";

import { ArrowUp, AtSign, FileText, LoaderCircle, Paperclip, Square, X, Zap } from "lucide-react";
import { EditorContent, Node, NodeViewWrapper, ReactNodeViewRenderer, useEditor, type Editor, type JSONContent, type NodeViewProps } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { EditorState } from "@tiptap/pm/state";
import { motion, useReducedMotion } from "motion/react";
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react";

import { Badge } from "@/components/ui/badge";
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command";
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { InputGroup, InputGroupButton } from "@/components/ui/input-group";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";

export type PromptBarContext<TData = unknown> = {
  id: string;
  label: string;
  description?: string;
  icon?: ReactNode;
  /** App-owned content, references, or metadata, passed through unchanged. */
  data?: TData;
};

export type PromptBarSkill<TData = unknown> = PromptBarContext<TData> & {
  /** Instructions for your app to apply when building its prompt. */
  instructions?: string;
};

export type PromptBarUsage = {
  usedTokens: number;
  maxTokens: number;
  /** A percentage from 0 to 100, supplied by your provider. */
  cacheHitRate?: number;
};

export type PromptBarSubmission<TContextData = unknown, TSkillData = unknown> = {
  text: string;
  contexts: readonly PromptBarContext<TContextData>[];
  skill: PromptBarSkill<TSkillData> | null;
  attachments: readonly File[];
};

export type PromptBarProps<TContextData = unknown, TSkillData = unknown> = {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  contexts?: readonly PromptBarContext<TContextData>[];
  contextOptions?: readonly PromptBarContext<TContextData>[];
  onContextAdd?: (context: PromptBarContext<TContextData>) => void;
  onContextRemove?: (id: string) => void;
  skills?: readonly PromptBarSkill<TSkillData>[];
  /** Controlled selection. Supply onSkillChange to update it. */
  skill?: PromptBarSkill<TSkillData> | null;
  defaultSkill?: PromptBarSkill<TSkillData> | null;
  onSkillChange?: (skill: PromptBarSkill<TSkillData> | null) => void;
  toolbar?: ReactNode;
  /** The selected model's display name, shown in the context summary. */
  modelLabel?: string;
  /** Provider-reported usage. Omit when measurements are unavailable. */
  contextUsage?: PromptBarUsage;
  onSubmit: (submission: PromptBarSubmission<TContextData, TSkillData>) => void | Promise<void>;
  /** A synchronous cancellation callback, for example AbortController.abort. */
  onStop?: () => void;
  submitting?: boolean;
  error?: string;
  disabled?: boolean;
  placeholder?: string;
  label?: string;
  maxHeight?: number;
  /** Passed to the native file picker. Validate file contents on your server. */
  accept?: string;
  maxFiles?: number;
  /** Per-file limit in bytes. */
  maxFileSize?: number;
  className?: string;
};

type Trigger = { kind: "/" | "@"; query: string; start: number; end: number };

type PromptChip = { key: string; label: string; icon: ReactNode; remove?: () => void; restore: () => void };
const ChipContext = createContext<{ chips: PromptChip[]; disabled: boolean }>({ chips: [], disabled: false });

function PromptChipView({ node }: NodeViewProps) {
  const { chips, disabled } = useContext(ChipContext);
  const chip = chips.find((item) => item.key === node.attrs.key);
  return (
    <NodeViewWrapper as="span" contentEditable={false} className="inline-block max-w-full align-baseline">
      <Badge variant="secondary" className={cn("group/chip relative mx-0.5 h-6 max-w-full min-w-0 gap-1.5 rounded-md px-1.5 align-baseline font-normal", chip?.remove && "pr-6")}>
        <span aria-hidden className="shrink-0 [&_svg]:size-3">{chip?.icon || <AtSign />}</span>
        <span className="max-w-52 truncate">{chip?.label || node.attrs.label}</span>
        {chip?.remove && <InputGroupButton size="icon-xs" disabled={disabled} aria-label={`Remove ${chip.label}`} onMouseDown={(event) => event.preventDefault()} onClick={chip.remove} className="absolute right-0 top-0 size-6 cursor-pointer bg-secondary opacity-0 transition-opacity group-hover/chip:opacity-100 group-focus-within/chip:opacity-100 [@media(hover:none)]:opacity-100"><X aria-hidden /></InputGroupButton>}
      </Badge>
    </NodeViewWrapper>
  );
}

const PromptChipNode = Node.create({
  name: "promptChip", group: "inline", inline: true, atom: true, selectable: false,
  addAttributes: () => ({ key: { default: "" }, label: { default: "" } }),
  // Chips can only be created through this component, never by pasted HTML.
  parseHTML: () => [],
  renderHTML: ({ node }) => ["span", { "data-prompt-chip": node.attrs.key }, node.attrs.label],
  renderText: ({ node }) => node.attrs.label,
  addNodeView: () => ReactNodeViewRenderer(PromptChipView),
});

function promptText(editor: Editor) {
  return editor.state.doc.textBetween(0, editor.state.doc.content.size, "\n", (node) => node.type.name === "hardBreak" ? "\n" : "");
}

function textDocument(text: string): JSONContent {
  return { type: "doc", content: text.split("\n").map((line) => ({ type: "paragraph", content: line ? [{ type: "text", text: line }] : [] })) };
}

export function PromptBar<TContextData = unknown, TSkillData = unknown>({
  value, defaultValue = "", onValueChange, contexts = [], contextOptions = [],
  onContextAdd, onContextRemove, skills = [], skill: controlledSkill, defaultSkill = null,
  onSkillChange, toolbar, modelLabel, contextUsage, onSubmit, onStop,
  submitting = false, error, disabled = false,
  placeholder = "Ask anything...", label = "Your prompt", maxHeight = 240,
  accept, maxFiles = 5, maxFileSize = 10 * 1024 * 1024, className,
}: PromptBarProps<TContextData, TSkillData>) {
  const id = useId();
  const reducedMotion = useReducedMotion();
  const formRef = useRef<HTMLFormElement>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLDivElement>(null);
  const optionRefs = useRef(new Map<string, HTMLDivElement>());
  const inFlight = useRef(false);
  const requestId = useRef(0);
  const composing = useRef(false);
  const syncingEditor = useRef(false);
  const chipHistory = useRef(new Map<string, PromptChip>());
  const [draft, setDraft] = useState(defaultValue);
  const [expanded, setExpanded] = useState(Boolean(defaultValue || value || contexts.length));
  const [pending, setPending] = useState(false);
  const [submitError, setSubmitError] = useState<string>();
  const [status, setStatus] = useState("");
  const [attachments, setAttachments] = useState<File[]>([]);
  const [selectedSkill, setSelectedSkill] = useState<PromptBarSkill<TSkillData> | null>(defaultSkill);
  const skill = controlledSkill === undefined ? selectedSkill : controlledSkill;
  const [trigger, setTrigger] = useState<Trigger | null>(null);
  const [selectedId, setSelectedId] = useState("");
  const [contextOpen, setContextOpen] = useState(false);
  const text = value ?? draft;
  const busy = pending || submitting;
  const canSubmit = Boolean(text.trim() || attachments.length);
  const message = error || submitError;
  const isExpanded = expanded || Boolean(text || contexts.length || attachments.length || skill);
  const heightLimit = Number.isFinite(maxHeight) ? Math.max(96, maxHeight) : 240;
  const fileLimit = Number.isFinite(maxFiles) ? Math.max(1, Math.floor(maxFiles)) : 5;
  const sizeLimit = Number.isFinite(maxFileSize) ? Math.max(1, maxFileSize) : 10 * 1024 * 1024;
  const options = (trigger?.kind === "/"
    ? skills.map((option) => ({ ...option, select: () => updateSkill(option) }))
    : contextOptions.filter((option) => !contexts.some((context) => context.id === option.id))
      .map((option) => ({ ...option, select: () => onContextAdd?.(option) }))
  ).filter((option) => `${option.id} ${option.label} ${option.description || ""}`.toLowerCase().includes(trigger?.query.toLowerCase() || ""));
  const activeId = options.some((option) => option.id === selectedId) ? selectedId : options[0]?.id;
  const menuOpen = Boolean(trigger) && !disabled && !busy;
  if (contextUsage && (
    !Number.isFinite(contextUsage.usedTokens) || contextUsage.usedTokens < 0 ||
    !Number.isFinite(contextUsage.maxTokens) || contextUsage.maxTokens <= 0 ||
    (contextUsage.cacheHitRate !== undefined && (!Number.isFinite(contextUsage.cacheHitRate) || contextUsage.cacheHitRate < 0 || contextUsage.cacheHitRate > 100))
  )) throw new RangeError("PromptBar contextUsage requires non-negative usedTokens, positive maxTokens, and a cacheHitRate between 0 and 100.");
  const usagePercent = contextUsage ? contextUsage.usedTokens / contextUsage.maxTokens * 100 : undefined;
  const compactTokens = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });
  const contextCount = contexts.length + attachments.length + (skill ? 1 : 0);
  const contextSummary = [
    `${contexts.length} context ${contexts.length === 1 ? "item" : "items"}`,
    skill ? `${skill.label} skill` : "No skill selected",
    `${attachments.length} ${attachments.length === 1 ? "attachment" : "attachments"}`,
    modelLabel,
    contextUsage ? `${contextUsage.usedTokens.toLocaleString("en-US")} of ${contextUsage.maxTokens.toLocaleString("en-US")} context tokens used, ${usagePercent?.toFixed(1)} percent` : "Context usage unavailable",
    contextUsage?.cacheHitRate !== undefined ? `Average cache hit rate ${contextUsage.cacheHitRate.toFixed(1)} percent` : null,
  ].filter(Boolean).join(". ");

  const updateSkill = useCallback((next: PromptBarSkill<TSkillData> | null) => {
    if (controlledSkill === undefined) setSelectedSkill(next);
    onSkillChange?.(next);
  }, [controlledSkill, onSkillChange]);
  const chips: PromptChip[] = useMemo(() => [
    ...(skill ? [{ key: `skill-${skill.id}`, label: skill.label, icon: skill.icon || <Zap />, remove: () => updateSkill(null), restore: () => updateSkill(skill) }] : []),
    ...contexts.map((context) => ({ key: `context-${context.id}`, label: context.label, icon: context.icon || <AtSign />, remove: onContextRemove ? () => onContextRemove(context.id) : undefined, restore: () => onContextAdd?.(context) })),
    ...attachments.map((file) => ({ key: `file-${file.name}-${file.size}-${file.lastModified}`, label: file.name, icon: <FileText />, remove: () => setAttachments((current) => current.filter((item) => item !== file)), restore: () => setAttachments((current) => current.includes(file) ? current : [...current, file]) })),
  ], [skill, updateSkill, contexts, onContextRemove, onContextAdd, attachments]);
  const editor = useEditor({
    immediatelyRender: false,
    extensions: [StarterKit.configure({
      blockquote: false, bold: false, bulletList: false, code: false, codeBlock: false,
      dropcursor: false, gapcursor: false, heading: false, horizontalRule: false,
      italic: false, link: false, listItem: false, listKeymap: false, orderedList: false,
      strike: false, trailingNode: false, underline: false,
    }), PromptChipNode],
    content: textDocument(text),
    onFocus: () => setExpanded(true),
    onUpdate: ({ editor: current }) => {
      if (syncingEditor.current) return;
      const present = new Set<string>();
      current.state.doc.descendants((node) => { if (node.type.name === "promptChip") present.add(node.attrs.key); });
      for (const chip of chips) if (!present.has(chip.key)) chip.remove?.();
      for (const key of present) if (!chips.some((chip) => chip.key === key)) chipHistory.current.get(key)?.restore();
      updateText(promptText(current));
      findTrigger(current);
    },
    onSelectionUpdate: ({ editor: current }) => findTrigger(current),
    editorProps: {
      attributes: {
        id, "data-slot": "input-group-control", class: "whitespace-pre-wrap break-words outline-none [&_p]:m-0",
        role: "combobox", "aria-label": label, "aria-multiline": "true",
        "aria-autocomplete": "list", "aria-haspopup": "listbox",
        "aria-expanded": String(menuOpen), "aria-invalid": String(Boolean(message)),
        "aria-disabled": String(disabled), "aria-readonly": String(busy),
        "aria-describedby": `${id}-hint${message ? ` ${id}-error` : ""}`,
      },
      handleKeyDown: (_view, event) => handleEditorKey(event),
      handlePaste: (view, event) => {
        event.preventDefault();
        const pasted = event.clipboardData?.getData("text/plain") || "";
        if (pasted) view.dispatch(view.state.tr.insertText(pasted.replace(/\r\n?/g, "\n")));
        return true;
      },
    },
  });

  // Keep app-owned selections in the document without rebuilding it while typing.
  // Existing chips retain their positions; newly supplied chips use the caret.
  useEffect(() => {
    if (!editor) return;
    for (const chip of chips) chipHistory.current.set(chip.key, chip);
    if (editor.isEditable !== (!disabled && !busy)) editor.setEditable(!disabled && !busy, false);
    const transaction = editor.state.tr.setMeta("addToHistory", false);
    const present = new Set<string>();
    editor.state.doc.descendants((node, position) => {
      if (node.type.name !== "promptChip") return;
      if (chips.some((chip) => chip.key === node.attrs.key) && !present.has(node.attrs.key)) present.add(node.attrs.key);
      else transaction.delete(transaction.mapping.map(position), transaction.mapping.map(position + node.nodeSize));
    });
    let position = transaction.mapping.map(editor.state.selection.from);
    for (const chip of chips) {
      if (present.has(chip.key)) continue;
      transaction.insert(position, editor.schema.nodes.promptChip.create({ key: chip.key, label: chip.label }));
      position += 1;
    }
    if (transaction.docChanged) {
      syncingEditor.current = true;
      editor.view.dispatch(transaction);
      syncingEditor.current = false;
    }
  }, [editor, chips, disabled, busy]);

  useEffect(() => {
    if (editor && value !== undefined && promptText(editor) !== value) {
      // A consumer replacing the controlled value replaces the text, retaining
      // selected metadata inline at the start of the new draft.
      const document = textDocument(value);
      document.content![0].content!.unshift(...chips.map((chip) => ({ type: "promptChip", attrs: { key: chip.key, label: chip.label } })));
      editor.commands.setContent(document, { emitUpdate: false });
    }
    // Only a changed controlled value replaces text. Ordinary editor transactions
    // must not reset the document or caret while onValueChange is being delivered.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [editor, value]);

  // cmdk owns the generated list and option IDs. Connect the editor to those
  // actual nodes while keeping typing focus out of the non-modal popover.
  useEffect(() => {
    const frame = requestAnimationFrame(() => {
      const input = editor?.view.dom;
      const option = activeId ? optionRefs.current.get(activeId) : undefined;
      if (menuOpen && listRef.current) input?.setAttribute("aria-controls", listRef.current.id);
      else input?.removeAttribute("aria-controls");
      if (menuOpen && option) {
        input?.setAttribute("aria-activedescendant", option.id);
        option.scrollIntoView({ block: "nearest" });
      } else input?.removeAttribute("aria-activedescendant");
    });
    return () => cancelAnimationFrame(frame);
  }, [editor, menuOpen, activeId]);

  useEffect(() => () => { requestId.current += 1; }, []);

  function updateText(next: string) {
    if (value === undefined) setDraft(next);
    onValueChange?.(next);
    setSubmitError(undefined);
    setStatus("");
  }

  function findTrigger(current: Editor) {
    const { $from, empty } = current.state.selection;
    const before = $from.parent.textBetween(0, $from.parentOffset, "", "\uFFFC");
    const match = empty && before.match(/(?:^|[\s\uFFFC])([/@])([^\s/@\uFFFC]*)$/);
    if (!match || (match[1] === "/" ? !skills.length : !onContextAdd)) {
      setTrigger(null);
      return;
    }
    setTrigger({ kind: match[1] as "/" | "@", query: match[2], start: $from.pos - match[2].length - 1, end: $from.pos });
  }

  function choose(option: (typeof options)[number]) {
    if (!trigger || !editor) return;
    const key = `${trigger.kind === "/" ? "skill" : "context"}-${option.id}`;
    syncingEditor.current = true;
    editor.chain().focus().insertContentAt({ from: trigger.start, to: trigger.end }, { type: "promptChip", attrs: { key, label: option.label } }).run();
    syncingEditor.current = false;
    updateText(promptText(editor));
    option.select();
    setTrigger(null);
  }

  function handleEditorKey(event: KeyboardEvent) {
    if (event.target instanceof HTMLElement && event.target.closest("button")) return false;
    if (event.isComposing || composing.current || event.keyCode === 229) return false;
    if (menuOpen) {
      if (event.key === "Escape") { setTrigger(null); return true; }
      if ((event.key === "ArrowDown" || event.key === "ArrowUp") && options.length) {
        const index = options.findIndex((option) => option.id === activeId);
        setSelectedId(options[(index + (event.key === "ArrowDown" ? 1 : -1) + options.length) % options.length].id);
        return true;
      }
      if ((event.key === "Enter" && !event.shiftKey) || event.key === "Tab") {
        const option = options.find((item) => item.id === activeId);
        if (option) choose(option);
        return Boolean(option) || event.key === "Enter";
      }
    }
    if (event.key === "Escape" && !text && !chips.length) { setExpanded(false); editor?.commands.blur(); return true; }
    if (event.key === "Enter" && !event.shiftKey) { formRef.current?.requestSubmit(); return true; }
    return false;
  }

  function attach(files: FileList | null) {
    if (!files?.length) return;
    const next = [...attachments];
    for (const file of Array.from(files)) {
      if (file.size > sizeLimit) {
        setSubmitError(`${file.name} exceeds the ${Math.round(sizeLimit / 1024 / 1024 * 10) / 10} MB limit.`);
        return;
      }
      if (!next.some((item) => item.name === file.name && item.size === file.size && item.lastModified === file.lastModified)) next.push(file);
    }
    if (next.length > fileLimit) {
      setSubmitError(`Attach up to ${fileLimit} files at a time.`);
      return;
    }
    setAttachments(next);
    setSubmitError(undefined);
    setExpanded(true);
    editor?.commands.focus();
  }

  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (disabled || busy || inFlight.current || !canSubmit) return;
    const currentRequest = ++requestId.current;
    inFlight.current = true;
    setPending(true);
    setTrigger(null);
    setSubmitError(undefined);
    setStatus("");
    try {
      await onSubmit({ text: text.trim(), contexts: [...contexts], skill, attachments: [...attachments] });
      if (currentRequest !== requestId.current) return;
      if (editor && promptText(editor) === text) {
        // A sent draft starts a new editing history. Undo must not resurrect
        // files or commands from an already submitted prompt.
        editor.view.updateState(EditorState.create({
          schema: editor.schema, doc: editor.schema.nodeFromJSON(textDocument("")),
          plugins: editor.state.plugins,
        }));
        chipHistory.current.clear();
        updateText("");
      }
      setAttachments([]);
      updateSkill(null);
      setStatus("Prompt sent.");
    } catch (cause) {
      if (currentRequest !== requestId.current) return;
      setSubmitError(cause instanceof Error && cause.message ? cause.message : "The prompt could not be sent. Try again.");
    } finally {
      if (currentRequest === requestId.current) {
        inFlight.current = false;
        setPending(false);
      }
    }
  }

  function stop() {
    try {
      onStop?.();
      requestId.current += 1;
      inFlight.current = false;
      setPending(false);
      setStatus("Stopped. Your draft is kept.");
      editor?.commands.focus();
    } catch (cause) {
      setSubmitError(cause instanceof Error ? cause.message : "Could not stop the request.");
    }
  }

  return (
    <ChipContext.Provider value={{ chips, disabled: disabled || busy }}>
    <form ref={formRef} onSubmit={submit} className={cn("w-full min-w-0", className)}>
      <FieldGroup>
      <Field data-invalid={Boolean(message)} data-disabled={disabled} className="gap-2">
        <FieldLabel htmlFor={id} className="sr-only">{label}</FieldLabel>
        <input ref={fileInputRef} type="file" accept={accept} multiple tabIndex={-1} className="hidden" aria-label="Choose attachments" disabled={disabled || busy} onChange={(event) => {
          attach(event.target.files);
          event.target.value = "";
        }} />
        <Popover open={menuOpen} onOpenChange={(open) => { if (!open) setTrigger(null); }}>
          <PopoverAnchor asChild>
            <motion.div layout={!reducedMotion} transition={{ duration: reducedMotion ? 0 : 0.22, ease: [0.22, 1, 0.36, 1] }}>
              <InputGroup aria-busy={busy} data-disabled={disabled} className={cn("block h-auto rounded-xl border-0 bg-transparent! ring-1 ring-inset ring-border has-disabled:opacity-100 data-[disabled=true]:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot][aria-invalid=true]]:ring-1", isExpanded && "pb-12")}>
                <motion.div layout="position">
                  <div className="relative">
                  {!text && !chips.length && <span aria-hidden className={cn("pointer-events-none absolute left-3.5 text-sm text-muted-foreground", isExpanded ? "top-3.5 leading-6" : "top-1/2 -translate-y-1/2 leading-5")}>{placeholder}</span>}
                  <EditorContent
                    editor={editor}
                    className={cn("overflow-y-auto px-3.5 py-3.5 text-sm", isExpanded ? "leading-6 [&>.tiptap]:min-h-17" : "pr-30 overflow-hidden leading-5 [&>.tiptap]:min-h-5")}
                    style={{ maxHeight: heightLimit }}
                    onCompositionStart={() => { composing.current = true; }}
                    onCompositionEnd={() => { composing.current = false; }}
                  />
                  </div>
                </motion.div>
                <motion.div layout="position" className={cn("pointer-events-none absolute right-2.5 left-2.5 flex items-center gap-1.5", isExpanded ? "bottom-2" : "top-1/2 -translate-y-1/2")}>
                    <motion.fieldset disabled={disabled || busy || !isExpanded} inert={!isExpanded} aria-hidden={!isExpanded} aria-label="Prompt options" initial={false} animate={{ opacity: isExpanded ? 1 : 0 }} transition={{ duration: reducedMotion ? 0 : 0.18 }} className={cn("mr-auto min-w-0 max-w-full [&>button]:max-w-full", isExpanded && "pointer-events-auto")}>
                      {toolbar}
                    </motion.fieldset>
                  <div className="pointer-events-auto ml-auto flex shrink-0 items-center gap-0.5">
                    <InputGroupButton size="icon-sm" disabled={disabled || busy} aria-label="Attach files" title="Attach files" onClick={() => fileInputRef.current?.click()}><Paperclip aria-hidden /></InputGroupButton>
                    <HoverCard open={contextOpen && !menuOpen} onOpenChange={setContextOpen} openDelay={150} closeDelay={100}>
                      <HoverCardTrigger asChild>
                        <InputGroupButton size="icon-sm" aria-label="Context details" aria-describedby={`${id}-context-summary`} aria-expanded={contextOpen && !menuOpen} aria-controls={contextOpen && !menuOpen ? `${id}-context-card` : undefined} onClick={() => setContextOpen((open) => !open)} className="relative rounded-full">
                          <svg aria-hidden viewBox="0 0 32 32" className="size-7! -rotate-90" fill="none">
                            <circle cx="16" cy="16" r="11" stroke="currentColor" strokeWidth="2" className="text-border" />
                            {usagePercent !== undefined && usagePercent > 0 && <circle cx="16" cy="16" r="11" pathLength="100" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeDasharray={`${Math.min(100, usagePercent)} 100`} className="text-primary" />}
                          </svg>
                        </InputGroupButton>
                      </HoverCardTrigger>
                      <HoverCardContent id={`${id}-context-card`} role="region" aria-labelledby={`${id}-context-title`} side="top" align="end" sideOffset={8} collisionPadding={12} className="max-h-[var(--radix-hover-card-content-available-height)] w-80 max-w-[calc(100vw-1.5rem)] gap-3 overflow-y-auto motion-reduce:animate-none">
                        <div className="flex flex-col gap-3">
                          <div className="flex flex-wrap items-center justify-between gap-2">
                            <span id={`${id}-context-title`} className="font-medium">Context window</span>
                            <span className="font-mono text-xs tabular-nums text-muted-foreground">{contextUsage ? `${compactTokens.format(contextUsage.usedTokens)}/${compactTokens.format(contextUsage.maxTokens)} (${usagePercent?.toFixed(1)}%)` : "Usage unavailable"}</span>
                          </div>
                          {usagePercent !== undefined && <Progress value={Math.min(100, usagePercent)} aria-label="Context window usage" aria-valuetext={`${usagePercent.toFixed(1)}% used`} className="h-2.5" />}
                          <div className="flex items-center justify-between gap-3 text-xs">
                            <span className="text-muted-foreground">Average cache hit rate</span>
                            <span className="font-mono tabular-nums">{contextUsage?.cacheHitRate !== undefined ? `${contextUsage.cacheHitRate.toFixed(1)}%` : "Unavailable"}</span>
                          </div>
                        </div>
                        <Separator />
                        <div className="flex items-center justify-between gap-3">
                          <span className="text-xs font-medium">Included context</span>
                          <span className="font-mono text-xs text-muted-foreground">{contextCount} {contextCount === 1 ? "item" : "items"}</span>
                        </div>
                        <div className="flex flex-col gap-2">
                          {[
                            { label: "Context", items: contexts, icon: <AtSign />, empty: "No context added" },
                            { label: "Skill", items: skill ? [skill] : [], icon: <Zap />, empty: "No skill selected" },
                            { label: "Attachments", items: attachments.map((file, index) => ({ id: String(index), label: file.name, description: `${Math.max(1, Math.ceil(file.size / 1024)).toLocaleString()} KB` })), icon: <Paperclip />, empty: "No files attached" },
                          ].map((group) => (
                            <div key={group.label} className="flex items-start gap-2.5">
                              <span aria-hidden className="pt-0.5 text-muted-foreground [&_svg]:size-3.5">{group.icon}</span>
                              <div className="min-w-0 flex-1">
                                <p className="text-xs font-medium">{group.label}</p>
                                {group.items.length ? <ul className="mt-1 flex flex-col gap-1.5">{group.items.map((item) => <li key={item.id} className="text-xs"><span className="block break-words">{item.label}</span>{item.description && <span className="block text-muted-foreground">{item.description}</span>}</li>)}</ul> : <p className="text-xs text-muted-foreground">{group.empty}</p>}
                              </div>
                            </div>
                          ))}
                        </div>
                        <Separator />
                        <div className="flex flex-col gap-2 text-xs">
                          {modelLabel && <div className="flex items-start justify-between gap-3"><span className="text-muted-foreground">Model</span><span className="text-right">{modelLabel}</span></div>}
                          <span className="font-medium">Tools</span>
                          {skills.length > 0 && <div className="flex items-center justify-between gap-3"><span className="text-muted-foreground">{skills.length} skills available</span><kbd className="font-mono">/</kbd></div>}
                          {onContextAdd && <div className="flex items-center justify-between gap-3"><span className="text-muted-foreground">Add context</span><kbd className="font-mono">@</kbd></div>}
                          <p className="text-muted-foreground">Attach up to {fileLimit} files, {Math.round(sizeLimit / 1024 / 1024 * 10) / 10} MB each.</p>
                        </div>
                      </HoverCardContent>
                    </HoverCard>
                    <InputGroupButton key={busy ? "stop" : "send"} type={busy && onStop ? "button" : "submit"} variant={busy || canSubmit ? "default" : "ghost"} size="icon-sm" disabled={disabled || (busy ? !onStop : !canSubmit)} aria-label={busy ? (onStop ? "Stop response" : "Sending prompt") : "Send prompt"} onClick={() => { if (busy && onStop) stop(); }} className="ml-1 cursor-pointer rounded-lg">
                      {busy ? (onStop ? <Square aria-hidden /> : <LoaderCircle aria-hidden className="animate-spin motion-reduce:animate-none" />) : <ArrowUp aria-hidden />}
                    </InputGroupButton>
                  </div>
                </motion.div>
              </InputGroup>
            </motion.div>
          </PopoverAnchor>
          <PopoverContent side="top" align="start" sideOffset={8} className="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-2rem)] p-0" onOpenAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()} onInteractOutside={(event) => { if (event.target instanceof globalThis.Node && editor?.view.dom.contains(event.target)) event.preventDefault(); }}>
            <Command shouldFilter={false} value={activeId || ""} onValueChange={setSelectedId}>
              <CommandList ref={listRef} aria-label={trigger?.kind === "/" ? "Skills" : "Context"}>
                <CommandEmpty>No {trigger?.kind === "/" ? "skills" : "context"} found.</CommandEmpty>
                <CommandGroup heading={trigger?.kind === "/" ? "Skills" : "Add context"}>
                  {options.map((option) => (
                    <CommandItem key={option.id} value={option.id} ref={(node) => { if (node) optionRefs.current.set(option.id, node); else optionRefs.current.delete(option.id); }} onSelect={() => choose(option)} onMouseDown={(event) => event.preventDefault()} className="cursor-pointer gap-2.5 py-2">
                      <span aria-hidden className="shrink-0 [&_svg]:size-4">{option.icon || (trigger?.kind === "/" ? <Zap /> : <FileText />)}</span>
                      <span className="min-w-0"><span className="block truncate">{option.label}</span>{option.description && <span className="block text-xs text-muted-foreground">{option.description}</span>}</span>
                    </CommandItem>
                  ))}
                </CommandGroup>
              </CommandList>
            </Command>
          </PopoverContent>
        </Popover>
        <p id={`${id}-hint`} className="sr-only">Type / for skills or @ for context. Use arrow keys and Enter to choose. Enter to send, Shift+Enter for a new line. Escape closes suggestions or collapses an empty prompt.</p>
        <p id={`${id}-context-summary`} className="sr-only">{contextSummary}</p>
        {message && <FieldError id={`${id}-error`} className="text-xs">{message}</FieldError>}
        <p role="status" aria-live="polite" className="sr-only">{busy ? "Sending prompt..." : status}</p>
      </Field>
      </FieldGroup>
    </form>
    </ChipContext.Provider>
  );
}