Chat Input
Composer with auto-growing textarea, attachments (button, paste, drag-and-drop), $-skills and a slash menu for commands, @-mentions, a queue for messages typed mid-turn, and Enter-to-send.
Installation
npx shadcn@latest add miskibin/chat-components/chat-inputAlso installs file-icon.
Usage
"use client"
import { ChatInput } from "@/components/ui/chat-input"
export function Composer() {
return (
<ChatInput
placeholder="Ask anything"
skills={[{ name: "summarize", description: "Condense long text" }]}
slashCommands={[
{ name: "help", description: "Show commands" },
{ name: "compact", description: "Summarize so far", mustStartMessage: true },
]}
onSend={({ text, files, skills }) => {
// send to your API
}}
/>
)
}Examples
Recall what was already sent
Press ArrowUp in the empty composer to walk back through the conversation’s own prompts, ArrowDown to come forward again, and once more past the newest to clear it. It is the shell’s behaviour, including HISTCONTROL=ignoredups: re-sending the same prompt twice leaves one entry. Type anything first and the arrows go back to moving the caret, so a draft can never be lost to a stray key.
- Why is the first retry so slow?
- Show me lib/retry/backoff.ts
- Halve BASE_MS and run the tests
Stop a streaming answer
isGenerating swaps send for stop and locks the textarea; onStop is where you abort the request. Send something below, then hit stop.
Queue while a turn streams
With onQueue the textarea stays live during a turn: Enter queues the message, the list above the surface shows what is waiting, and the host sends the next entry when the turn settles. Click a queued row to edit it.
@-mention files
Type @ and a few letters. The mentions provider answers with whatever the host can name — here a static list of paths behind a short delay — and the pick replaces the token in place.
Skills on $, commands on /
$ names a skill inside the sentence you are already writing, ranked by name and then by description, so $mgd still finds migrate-database. The pick writes $name into the draft and the chip above the textarea is a reading of that text, not a second copy of it — delete either and both go. Prices are left alone: $20, $20k and $1e6 open nothing. The two mustStartMessage commands here leave the / menu as soon as the slash is not the first character.
Long pastes collapse to a chip
A paste over 800 characters or three lines is held out of the textarea as [Pasted text #1 +40 lines] and shown as a chip. Delete the chip or the token to drop it; sending expands every token still present.
Drive it from outside
The ref handle inserts at the caret, replaces the draft, and reads it back — enough for “quote this into the composer”, a per-chat draft store, or a stash you park with ⌘S (onStash) and restore later.
Add pickers to the toolbar
The tools slot sits next to the attach button. On narrow screens its controls use 36px touch targets and wrap before the send action, which stays reachable. Pass side="top" so the menus open away from the composer.
Custom composer buttons
chatInputButtonVariants is exported so your own controls inherit the exact height, radius, and focus ring of the built-in attach and send buttons.
Restyle the surface
className merges last through cn(), and the inner card is reachable through its data-slot — so width, radius, and colors are all editable without forking the file.
Notes
API reference
ChatInput
onSend*(payload: ChatInputPayload) => void- Fired on Enter or the send button with
{ text, files, skills }. The composer clears itself afterwards. onTextChange(text: string) => void- Fired as the draft changes, including the clear after a send. The composer keeps owning the text — this is a read-only tap for things that track the draft, such as sizing a
ContextMeter. It is held in a ref, so passing an unstable closure does not re-render the composer. onStop() => void- Called by the stop button while generating.
isGeneratingboolean = false- Swaps send for stop and disables the textarea while a response streams.
placeholderstring = "Ask anything"- Textarea placeholder. While a turn streams and
onQueueis set it reads “Queue a message…” instead, unless you pass your own. refReact.Ref<ChatInputHandle>- Imperative access —
focus,getDraft,setDraft,insertText— for a host that drives the composer from outside: quoting a selection into it, restoring a saved draft, putting a queued message back. Every change made through it firesonTextChange. defaultValuestring = ""- Initial draft text. The composer owns the value from then on.
onQueue(payload: ChatInputPayload) => void- Lets the user keep typing while a turn streams: Enter (and the send button, now labelled “queue”) hands the message to the host instead of locking the textarea. The host owns the queue and sends the next entry when the turn ends.
queueChatInputQueuedMessage[] = []- Queued messages, listed above the surface with a remove button;
onQueueRemove(id)drops one, andonQueueEdit(id)asks the host to put it back into the composer (viasetDraft). mentions(query: string) => Promise<ChatInputMentionItem[]> | ChatInputMentionItem[]- Resolves the
@token at the caret into a menu — files, people, whatever the host can name. Debounced, stale answers dropped; picking an item writesinsert(default@label) into the draft. onStash(payload: ChatInputPayload) => void- ⌘S / Ctrl+S in the textarea hands the draft — text, files, skills — to the host and clears the composer, so a half-written prompt can be parked and restored later through the handle.
historyreadonly { id: string; text: string }[] = []- Prompts already sent in this conversation, oldest first — your own user messages, so the composer never has to know what a transcript is. With it, ArrowUp at the start of an untouched composer recalls the previous prompt and ArrowDown walks back down; one step past the newest empties it again. Blank sends are skipped and consecutive duplicates collapse. Keep the array stable.
toolsReact.ReactNode- Rendered in the toolbar next to the attach button — model and mode pickers belong here.
skillsChatSkill[] = []- What
$offers, ranked by name first and then by description, and what the/menu lists above the commands. Picking one writes a$namemention into the draft and shows it as a chip; the mention is the value, so deleting either takes the other with it. Every mention that names one of these comes back inpayload.skills. A skill markeduserInvocable: falseis left out of both menus. slashCommandsChatSlashCommand[] = []- Commands in the
/menu, which opens on a/that starts the text or follows a space. Picking one writes/nameover that token. commandsMustStartMessageboolean = false- Default for
ChatSlashCommand.mustStartMessage: a command an agent expands only at the head of a message — anywhere else it arrives as literal text — is offered only while the/sits at offset 0. Skills and the host’s own commands stay on the menu everywhere. disabledboolean = false- Disables the textarea and every toolbar control.
maxHeightnumber = 200- Textarea height cap in pixels; past it the textarea scrolls.
classNamestring- Merged last onto the outer wrapper. It is capped at
max-w-3xl— the same measure as the message column — so the composer and the conversation share one edge.
Types
ChatInputPayload{ text: string; files: File[]; skills: string[] }- What onSend receives.
skillsis the list of$mentions the text carries, which stay intextas well. ChatSkill{ name: string; description?: string; scope?: "project" | "user"; userInvocable?: boolean }- One row of the
$menu.scopeis shown beside the description, so a project skill and a personal one of the same name read apart. ChatSlashCommand{ name: string; description?: string; argHint?: string; mustStartMessage?: boolean }- One row of the / menu.
ChatInputHandle{ focus(); getDraft(): ChatInputDraft; setDraft(draft: Partial<ChatInputDraft>); insertText(text) }- What
refresolves to.getDraft().textis the expanded text — pasted blocks put back in place of their placeholders. ChatInputDraft{ text: string; files: File[]; skills: string[] }- What the composer holds right now.
ChatInputMentionItem{ id: string; label: string; description?: string; insert?: string }- One row of the @ menu.
ChatInputQueuedMessage{ id: string; text: string; fileCount?: number }- One row of the host-owned queue.
Data slots
Every part below is addressable from a parent, so you can restyle internals without forking the component.
chat-inputchat-input-surfacechat-input-textareachat-input-toolbarchat-input-sendchat-input-stopchat-input-chipschat-input-chipchat-input-slash-menuchat-input-slash-groupchat-input-skill-menuchat-input-mention-menuchat-input-queuechat-input-queue-item