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.

Open in

Installation

npx shadcn@latest add miskibin/chat-components/chat-input

Also 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.

Idle. Send something.

@-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.

then paste it below

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 onQueue is 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 fires onTextChange.
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, and onQueueEdit(id) asks the host to put it back into the composer (via setDraft).
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 writes insert (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 $name mention 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 in payload.skills. A skill marked userInvocable: false is 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 /name over 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. skills is the list of $ mentions the text carries, which stay in text as well.
ChatSkill{ name: string; description?: string; scope?: "project" | "user"; userInvocable?: boolean }
One row of the $ menu. scope is 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 ref resolves to. getDraft().text is 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