Skip to content
ComposerRich prompt inputMenu

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.

Mention files and agents, run slash commands, fill prompt templates, paste long text as an attachment.

Installation

$ pnpm dlx shadcn@latest add https://chatcn.dev/r/rich-prompt-input.json

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

tsx
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

ts
{
  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}).

Templates with variables: Tab moves between them, typing fills the selected one.

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.

Long pastes become a text attachment instead of flooding the editor.

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.

Mentions from a search endpoint, with a searching row and stable results.

States

Empty, streaming, disabled, minimal, long draft and right to left.

Buttons that open the menus

tsx
function MentionButton() {
  const rich = useRichPromptInput()
  return (
    <PromptInputButton tooltip="Mention a file, agent or person" onClick={() => rich.openMenu("@")}>
      <AtSignIcon />
    </PromptInputButton>
  )
}

Props

PropTypeNotes
defaultValuestring | RichPromptJSONPlain text with {{variables}}, or value.json from a draft
onValueChange(value: RichPromptValue) => voidEvery edit
onSubmit(value: RichPromptValue) => voidEnter, Cmd/Ctrl+Enter or the send button
mentionsMentionSourceTurns on the @ menu. An array, or a function of the query that returns items or a promise
recentMentionsstring[]Ids shown first while the query is empty
commandsSlashCommand[]Turns on the / menu
recentCommandsstring[]Command names shown first
onCommandRun(command) => voidAfter a command is inserted, expanded or run (immediate)
onMentionSelect(item) => voidAfter a mention is inserted, for frecency
requireVariablesbooleanDefault true. Enter selects the first unfilled variable instead of sending
menuSide"auto" | "top" | "bottom"Default auto. Side of the caret the menus open on
editorRefRef<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.

PartNotes
RichPromptInputEditorplaceholder, aria-label (default "Message"), mentionEmptyHint
useRichPromptInput()The editor handle from inside the composer, for custom tools
`SlashCommand` fieldNotes
name, descriptionWithout the slash
category, kindGroup heading; kind: "skill" groups under Skills
argumentHintGhost text after insertion, <pr-number> [--strict]
templateExpands into text with {{variables}} instead of a chip
immediateRuns on pick (for example /clear) and calls onCommandRun
shortcut, icon, disabled, disabledReasonShown in the row

Keyboard

KeyAction
EnterSend (adds a line on touch screens, where submitOn is auto)
Shift+Enter, Alt+EnterNew line
Cmd/Ctrl+EnterSend in every mode
@, /Open the mention or command menu at the caret
ArrowUp, ArrowDown, Ctrl+P, Ctrl+NMove in an open menu, looping
Enter, TabPick the active row
EscClose the menu (it stays closed for that trigger); stop a streaming response
Tab, Shift+TabNext or previous variable; leaves the editor when there are none
Backspace, DeleteRemove the chip next to the caret as one unit
Cmd/Ctrl+Z, Shift+Cmd/Ctrl+ZUndo, redo

Accessibility

  • The editor is a labelled multiline textbox with aria-autocomplete="list". While a menu is open it carries aria-expanded, aria-controls and aria-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 SendButton morph from PromptInput. Nothing else moves.