17 / Agent UI
Prompt Input
The composer. Grows with the message, sends on Enter, keeps Shift+Enter for a new line, and turns into a stop button while a reply streams.
Enter sends. Shift+Enter starts a new line.
"use client"
import * as React from "react"
import { PromptInput } from "mischief-ui/prompt-input"
export function PromptInputDemo() {
const [sent, setSent] = React.useState<string | null>(null)
const [status, setStatus] = React.useState<"ready" | "streaming">("ready")
React.useEffect(() => {
if (status !== "streaming") return
const timer = window.setTimeout(() => setStatus("ready"), 2600)
return () => window.clearTimeout(timer)
}, [status])
return (
<div className="grid w-full max-w-xl gap-3">
<PromptInput
status={status}
onSubmit={(value) => {
setSent(value)
setStatus("streaming")
}}
onStop={() => setStatus("ready")}
/>
<p className="text-muted-foreground text-center text-xs">
{status === "streaming"
? "Replying… press stop to cancel."
: sent
? `Sent: ${sent}`
: "Enter sends. Shift+Enter starts a new line."}
</p>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/prompt-inputimport { PromptInput } from "mischief-ui/prompt-input"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { ArrowUp, Square } from "lucide-react"import { cn } from "@/lib/utils" export type PromptInputStatus = "ready" | "streaming" export type PromptInputProps = Omit< React.HTMLAttributes<HTMLFormElement>, "onSubmit" | "children"> & { value?: string defaultValue?: stringUsage
export function Composer() {
return (
<PromptInput
status={isStreaming ? "streaming" : "ready"}
onSubmit={send}
onStop={stop}
/>
)
}Sending and not sending
Enter submits and Shift+Enter starts a new line, which is what people expect from a message box and the opposite of what a plain textarea does. Submission is skipped when the field is empty or holds only whitespace, so a stray Enter never sends an empty turn.
The box grows with what is typed and stops at maxRows, scrolling after that rather than pushing the rest of the page away.
Submitting, then stopping
status decides which control is offered. While an answer is being generated the send control becomes a stop control, so the same place in the layout always holds the thing you currently want -- and there is never a send button that quietly does nothing.
<PromptInput
status={streaming ? "streaming" : "ready"}
onSubmit={send}
onStop={abort}
attachments={<FileThumbnail file={pending} />}
/>attachments and actions are slots either side of the control, for what is going with the message and for what changes how it is sent -- a model picker, a tool toggle -- so the composer stays yours to arrange.
API
value, defaultValue, onValueChangestring, string, (value: string) => voidThe text, controlled or uncontrolled.onSubmit(value: string) => voidRuns with the trimmed message.status"ready" | "streaming"Swaps the send button for a stop button.onStop() => voidRuns when the stop button is pressed.maxRowsnumberHow far the field grows before it scrolls. Defaults to 8.attachments, actionsReactNodeSlots above the field and beside the send button.Accessibility
The field has a real label and the send and stop buttons have accessible names rather than only icons. Enter sends and Shift+Enter starts a new line, but Enter is left alone while an input method editor has a candidate open, so composing text in Japanese or Chinese does not send the message early. Sending is refused when the field holds only whitespace.