Rich prompt input
The composer with a rich editor: inline chips for @ mentions and / commands, prompt variables filled with Tab, long pastes as attachments, and a value serialized to text, mentions and commands.
On this page
Installation
Built on TipTap 3 with only what a prompt needs: paragraphs, text and three inline atoms (mention, command, variable). No StarterKit, no marks, no extra extensions; @tiptap/core and @tiptap/pm come along as peers of @tiptap/react (install them yourself if your package manager does not add peers). Undo and redo come from a small snapshot history instead of the full ProseMirror plugin.
Usage
import {
PromptInputActions,
PromptInputSubmit,
PromptInputToolbar,
} from "@/components/ui/chatcn/prompt-input"
import {
RichPromptInput,
RichPromptInputEditor,
} from "@/components/ui/chatcn/rich-prompt-input"
<RichPromptInput
mentions={files}
commands={commands}
onSubmit={({ text, mentions, commands }) => send(text, { mentions, commands })}
status={status}
onStop={stop}
>
<RichPromptInputEditor placeholder="Ask anything, @ to mention, / for commands…" />
<PromptInputToolbar>
<PromptInputActions>
<PromptInputSubmit />
</PromptInputActions>
</PromptInputToolbar>
</RichPromptInput>RichPromptInput renders a PromptInput underneath, so every slot and control from it works unchanged: PromptInputHeader, PromptInputAttachments, PromptInputToolbar, PromptInputAttachButton, PromptInputButton and PromptInputSubmit. Only the text area changes: use RichPromptInputEditor in place of PromptInputTextarea.
The submitted value
{
text: "Ask @agent:reviewer to compare @src/server/runs.ts with @src/lib/pagination.ts",
mentions: [
{ id: "a-reviewer", kind: "agent", label: "Reviewer", value: "agent:reviewer" },
{ id: "f-runs", kind: "file", label: "runs.ts", value: "src/server/runs.ts" },
{ id: "f-pagination", kind: "file", label: "pagination.ts", value: "src/lib/pagination.ts" },
],
commands: [], // [{ name: "review", args: "412" }] for "/review 412"
variables: [], // names still waiting for a value
segments: [...], // ordered text and chips, to render the sent message
json: {...}, // the editor document, for drafts
}text is model-ready: mentions become @value (the path for files), commands /name, unfilled variables {{name}}. Render the sent message from segments with InlineChip so it matches the editor. Save json and pass it back as defaultValue to restore a draft.
Examples
Prompt variables
Write {{name}} in defaultValue, a command's template, or insertTemplate. The first variable is selected after insertion; typing replaces it, Tab and Shift+Tab move between the rest. With unfilled variables, Enter selects the first one instead of sending (turn off with requireVariables={false}).
Paste as attachment
Set pasteTextAsFileAt and onFiles, exactly as on PromptInput: a paste that long becomes Pasted text.txt. Images pasted or dropped become files too.
Async mentions
Pass a function to mentions to search on the server. It gets the query and returns items or a promise. The latest request wins, earlier results stay until it lands, and a searching row shows meanwhile. Results are shown in your order, with the matched characters still emphasized.
States
Buttons that open the menus
function MentionButton() {
const rich = useRichPromptInput()
return (
<PromptInputButton tooltip="Mention a file, agent or person" onClick={() => rich.openMenu("@")}>
<AtSignIcon />
</PromptInputButton>
)
}Props
| Prop | Type | Notes |
|---|---|---|
defaultValue | string | RichPromptJSON | Plain text with {{variables}}, or value.json from a draft |
onValueChange | (value: RichPromptValue) => void | Every edit |
onSubmit | (value: RichPromptValue) => void | Enter, Cmd/Ctrl+Enter or the send button |
mentions | MentionSource | Turns on the @ menu. An array, or a function of the query that returns items or a promise |
recentMentions | string[] | Ids shown first while the query is empty |
commands | SlashCommand[] | Turns on the / menu |
recentCommands | string[] | Command names shown first |
onCommandRun | (command) => void | After a command is inserted, expanded or run (immediate) |
onMentionSelect | (item) => void | After a mention is inserted, for frecency |
requireVariables | boolean | Default true. Enter selects the first unfilled variable instead of sending |
menuSide | "auto" | "top" | "bottom" | Default auto. Side of the caret the menus open on |
editorRef | Ref<RichPromptInputHandle> | focus, clear, setContent, insertText, insertMention, insertTemplate, openMenu, getValue, editor |
Everything else comes from PromptInput and behaves the same: status, onStop, disabled, variant, submitOn, clearOnSubmit, onFiles, onFilesRejected, accept, maxFileSize, maxFiles, pasteTextAsFileAt and hasContent.
| Part | Notes |
|---|---|
RichPromptInputEditor | placeholder, aria-label (default "Message"), mentionEmptyHint |
useRichPromptInput() | The editor handle from inside the composer, for custom tools |
| `SlashCommand` field | Notes |
|---|---|
name, description | Without the slash |
category, kind | Group heading; kind: "skill" groups under Skills |
argumentHint | Ghost text after insertion, <pr-number> [--strict] |
template | Expands into text with {{variables}} instead of a chip |
immediate | Runs on pick (for example /clear) and calls onCommandRun |
shortcut, icon, disabled, disabledReason | Shown in the row |
Keyboard
| Key | Action |
|---|---|
Enter | Send (adds a line on touch screens, where submitOn is auto) |
Shift+Enter, Alt+Enter | New line |
Cmd/Ctrl+Enter | Send in every mode |
@, / | Open the mention or command menu at the caret |
ArrowUp, ArrowDown, Ctrl+P, Ctrl+N | Move in an open menu, looping |
Enter, Tab | Pick the active row |
Esc | Close the menu (it stays closed for that trigger); stop a streaming response |
Tab, Shift+Tab | Next or previous variable; leaves the editor when there are none |
Backspace, Delete | Remove the chip next to the caret as one unit |
Cmd/Ctrl+Z, Shift+Cmd/Ctrl+Z | Undo, redo |
Accessibility
- The editor is a labelled multiline
textboxwitharia-autocomplete="list". While a menu is open it carriesaria-expanded,aria-controlsandaria-activedescendant, so screen readers follow the active option without focus leaving the text. - Chips read as their kind and name ("File: runs.ts", "Command: /review", "Variable: symptom").
- Enter never sends while an IME is composing, including the Safari confirm keystroke.
- Blocked sends with unfilled variables are announced in a polite live region with what to do next.
- Type is at least 16 px on touch screens so iOS never zooms in.
Motion
- Menus open from typing and are used all day, so they appear, filter and close without animation. The active row moves instantly.
- The menu picks a side of the caret once per opening and keeps it, so it never jumps across the text while results shrink.
- Send to Stop is the
SendButtonmorph fromPromptInput. Nothing else moves.