Prompt
A field for writing to an assistant, with its files, model and tools in one card.
'use client';
import {
BookTextIcon,
BrainIcon,
CalendarIcon,
FolderIcon,
GlobeIcon,
PlusIcon,
PlugIcon,
ScrollTextIcon,
SparklesIcon,
TicketIcon,
TriangleAlertIcon,
ZapIcon,
} from 'lucide-react';
import { Menu } from '@/components/panelui/menu';
import { Prompt, type PromptMessage, type PromptModelItem, type PromptState } from '@/components/panelui/prompt';
import { useEffect, useRef, useState } from 'react';
const MODELS: PromptModelItem[] = [
{ value: 'lumen', label: 'Lumen 4', icon: <SparklesIcon />, description: 'Balanced, for most questions' },
{ value: 'lumen-mini', label: 'Lumen 4 Mini', icon: <ZapIcon />, description: 'Quick answers to small asks' },
{ value: 'lumen-deep', label: 'Lumen 4 Deep', icon: <BrainIcon />, description: 'Slower, for hard problems' },
];
const SKILLS = ['Weekly report', 'Release notes', 'Meeting brief', 'Code review'];
export default function PromptAsk() {
const [status, setStatus] = useState<PromptState>('idle');
const [search, setSearch] = useState(true);
const [calendar, setCalendar] = useState(true);
const [drive, setDrive] = useState(false);
const [skill, setSkill] = useState<string | null>(null);
const [listening, setListening] = useState(false);
const [sent, setSent] = useState<string | null>(null);
const timers = useRef<number[]>([]);
const clear = () => {
timers.current.forEach(window.clearTimeout);
timers.current = [];
};
useEffect(() => clear, []);
// Stands in for a model: a moment to read the question, then a few seconds writing.
const send = ({ text, files }: PromptMessage) => {
clear();
setListening(false);
setSent(text || `${files.length} file${files.length === 1 ? '' : 's'}`);
setStatus('submitted');
timers.current.push(window.setTimeout(() => setStatus('streaming'), 1200));
timers.current.push(window.setTimeout(() => setStatus('idle'), 4200));
};
return (
<div className="flex w-full max-w-xl flex-col gap-3">
<Prompt
onSubmit={send}
status={status}
onStop={() => {
clear();
setStatus('idle');
}}
>
<Prompt.Field>
<Prompt.Attachments />
<Prompt.Textarea placeholder="Ask about your notes, or attach a file" />
<Prompt.Footer>
<Prompt.Tools>
<Prompt.Menu>
<Prompt.AttachItem />
<Menu.Separator />
<Menu.Sub>
<Menu.SubTrigger>
<BookTextIcon />
Skills
</Menu.SubTrigger>
<Menu.SubContent>
{SKILLS.map((name) => (
<Menu.Item key={name} onClick={() => setSkill(name)}>
<ScrollTextIcon />
{name}
</Menu.Item>
))}
<Menu.Separator />
<Menu.Item>
<PlusIcon />
Browse skills
</Menu.Item>
</Menu.SubContent>
</Menu.Sub>
<Menu.Sub>
<Menu.SubTrigger
end={
drive ? null : (
<Menu.Badge>
<TriangleAlertIcon />1
</Menu.Badge>
)
}
>
<PlugIcon />
Connections
</Menu.SubTrigger>
<Menu.SubContent>
<Menu.CheckboxItem checked={calendar} onCheckedChange={setCalendar}>
<CalendarIcon />
Calendar
</Menu.CheckboxItem>
<Menu.CheckboxItem checked={drive} onCheckedChange={setDrive}>
<FolderIcon />
Shared drive
</Menu.CheckboxItem>
<Menu.Item disabled>
<TicketIcon />
Issue tracker
</Menu.Item>
</Menu.SubContent>
</Menu.Sub>
</Prompt.Menu>
{/* A mode that stays in the footer and turns blue while it is on. */}
<Prompt.Tool label="Search" showLabel pressed={search} onPressedChange={setSearch}>
<GlobeIcon />
</Prompt.Tool>
{/* A skill chosen in the menu, pressed again to put it away. */}
{skill ? (
<Prompt.Tool label={skill} showLabel pressed onPressedChange={() => setSkill(null)} title={`Remove ${skill}`}>
<ScrollTextIcon />
</Prompt.Tool>
) : null}
<Prompt.Model items={MODELS} variant="ghost" />
</Prompt.Tools>
<Prompt.Submit onVoice={() => setListening((on) => !on)} listening={listening} />
</Prompt.Footer>
</Prompt.Field>
<Prompt.Status icon={<SparklesIcon />}>
{status === 'submitted' ? 'Reading your question' : 'Writing the answer'}
</Prompt.Status>
</Prompt>
<p className="px-4 text-xs text-muted-foreground" aria-live="polite">
{listening ? 'Listening…' : sent ? `Sent: ${sent}` : 'Nothing sent yet.'}
</p>
</div>
);
}Use it for the field a person writes to an assistant in: a question, an instruction, a follow-up. It holds the text, the files attached to it, the model it goes to, a menu of what else can be added, and the button that sends it.
It does not talk to a model. onSubmit is handed the text and the files, and status is how you tell it what the answer is doing: submitted and streaming turn the send button into a stop button and open the status band, and error marks the field.
The field is a card, and what is said about it sits in bands the card overlaps: a banner above for something to do first, a status band below for the answer on its way, and an inset strip below for where the message is going. Each opens and closes with its open prop.
For one question asked from anywhere on a page, with the answer listed under it, use Command Dock.
Installation
npx shadcn@latest add https://panelui.dev/r/web/prompt.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/prompt.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/prompt.jsonbunx shadcn@latest add https://panelui.dev/r/web/prompt.jsonThe source is copied into components/panelui/, where you can edit it.
npm install panelui-webpnpm add panelui-webyarn add panelui-webbun add panelui-webImport from panelui-web/prompt instead of @/components/panelui/prompt. Tailwind needs one line to read the package's classes, which is on the installation page.
Usage
import { Prompt } from "@/components/panelui/prompt"<Prompt onSubmit={send} status={status} onStop={stop}>
<Prompt.Field>
<Prompt.Attachments />
<Prompt.Textarea placeholder="Ask anything" />
<Prompt.Footer>
<Prompt.Tools>
<Prompt.Menu>
<Prompt.AttachItem />
<Menu.Separator />
<Menu.CheckboxItem checked={search} onCheckedChange={setSearch}>
<GlobeIcon />
Search the web
</Menu.CheckboxItem>
</Prompt.Menu>
<Prompt.Model items={models} />
</Prompt.Tools>
<Prompt.Submit />
</Prompt.Footer>
</Prompt.Field>
<Prompt.Status>Writing the answer</Prompt.Status>
</Prompt>Composition
<Prompt>
<Prompt.Banner>
<Prompt.BannerAction />
</Prompt.Banner>
<Prompt.Tasks>
<Prompt.Task />
</Prompt.Tasks>
<Prompt.Commands />
<Prompt.Field>
<Prompt.Attachments />
<Prompt.Textarea />
<Prompt.Footer>
<Prompt.Tools>
<Prompt.Menu>
<Prompt.AttachItem />
</Prompt.Menu>
<Prompt.Attach />
<Prompt.Tool />
<Prompt.Model />
</Prompt.Tools>
<Prompt.Submit />
</Prompt.Footer>
<Prompt.Recorder />
</Prompt.Field>
<Prompt.Status />
<Prompt.Context>
<Prompt.ContextItem />
</Prompt.Context>
<Prompt.Hint />
</Prompt>Prompt.Banner— A band above the field for something to do first, such as connecting an account. It folds under the field whenopenis false.Prompt.Tasks— A band above the field with an agent's tasks. One line holds a ring that fills as they are done, thetitleand a count; pressed, it opens into the list, which scrolls pastmaxHeight.Prompt.Task— One task,pending,active,doneorerror. An active task's words are lit while it runs, and a done one is checked and struck through.Prompt.Commands— A band above the field listingitemswhile the text is the trigger and a word. Chosen with the keys or a press.Prompt.BannerAction— The banner's button, at its end. It takes everything a Button does,loadingincluded.Prompt.Field— The card that holds the text, the files and the footer. Files dropped on it are attached.layout="inline"puts its parts in one row, for a one-line composer.Prompt.Attachments— The attached files: images as thumbnails, anything else as a chip with its name and size, each with a button to remove it. It takes no room while there are none.Prompt.Textarea— Where the message is written. It grows from two lines to ten. Enter sends, Shift+Enter starts a new line, and a pasted image is attached.Prompt.Recorder— Takes the place of the text and the footer while voice is recorded: thetranscriptso far, the sound as a wave scrolling to the left, the time, and buttons to throw the recording away or keep it.Prompt.Footer— The row under the text, with the tools at its start and the submit button at its end.Prompt.Tools— A row of tools. It wraps onto a second line when the footer is narrow.Prompt.Menu— One round button with a plus that opens a Menu of what can be added to the message: files, skills, connections, modes. Its children areMenuitems, check items and submenus. The plus turns to a cross while it is open.width="field"opens it as wide as the field.Prompt.AttachItem— The menu item that opens the file picker, with⌘Uat its end. ⌘U or Ctrl+U in the field opens the picker too.Prompt.Tool— A round button holding an icon.labelis what a screen reader and the tooltip call it, andshowLabelwrites it beside the icon. WithpressedandonPressedChangeit switches on and off, and while on it is drawn in the info colour.Prompt.Attach— A tool that opens the file picker, for a prompt with no menu.Prompt.Model— Chooses the model.itemsare{ value, label, icon, description }, and the button shows the chosen one's icon and label.variant="ghost"has no edge.Prompt.Submit— Sends the message. Whilestatusissubmittedorstreamingit stops the answer instead, with a ring turning round it. WithonVoice, it starts voice while the field is empty.Prompt.Status— A band under the field saying what the answer is doing. It opens while the prompt is busy unlessopensays otherwise.Prompt.Context— A strip under the field, inset from its edges, for where the message goes: a machine, a branch, a ticket.Prompt.ContextItem— One fact in the strip, with an icon before it.onSelectmakes it a button.Prompt.Hint— A quiet line under the prompt. Without children it says that Shift+Enter starts a new line.
Examples
A banner to act on
A band above the field asks for a calendar to be connected. Prompt.BannerAction shows a spinner with loading, and when the connection is made the banner's open is set to false and it folds under the card. Prompt.Hint sits under the field.
'use client';
import { BrainIcon, CalendarIcon, CheckIcon, GlobeIcon, LightbulbIcon, SparklesIcon, ZapIcon } from 'lucide-react';
import { Menu } from '@/components/panelui/menu';
import { Prompt, type PromptModelItem } from '@/components/panelui/prompt';
import { useState } from 'react';
const MODELS: PromptModelItem[] = [
{ value: 'lumen', label: 'Lumen 4', icon: <SparklesIcon />, description: 'Balanced, for most questions' },
{ value: 'lumen-mini', label: 'Lumen 4 Mini', icon: <ZapIcon />, description: 'Quick answers to small asks' },
{ value: 'lumen-deep', label: 'Lumen 4 Deep', icon: <BrainIcon />, description: 'Slower, for hard problems' },
];
export default function PromptBanner() {
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [search, setSearch] = useState(true);
const [think, setThink] = useState(false);
const connect = () => {
setConnecting(true);
window.setTimeout(() => {
setConnecting(false);
setConnected(true);
}, 1100);
};
return (
<div className="flex w-full max-w-xl flex-col gap-3">
<Prompt defaultValue="Move Thursday's design review to Friday at ten, and let everyone on it know">
<Prompt.Banner open={!connected}>
<CalendarIcon />
<span className="truncate">Connect your calendar to book and move meetings</span>
<Prompt.BannerAction loading={connecting} onClick={connect}>
Connect
</Prompt.BannerAction>
</Prompt.Banner>
<Prompt.Field>
<Prompt.Textarea placeholder="Ask anything" />
<Prompt.Footer>
<Prompt.Tools>
<Prompt.Menu>
<Prompt.AttachItem />
<Menu.Separator />
<Menu.CheckboxItem checked={search} onCheckedChange={setSearch}>
<GlobeIcon />
Search the web
</Menu.CheckboxItem>
<Menu.CheckboxItem checked={think} onCheckedChange={setThink}>
<LightbulbIcon />
Think longer
</Menu.CheckboxItem>
</Prompt.Menu>
{search ? (
<Prompt.Tool label="Search" showLabel pressed onPressedChange={setSearch}>
<GlobeIcon />
</Prompt.Tool>
) : null}
{think ? (
<Prompt.Tool label="Think" showLabel pressed onPressedChange={setThink}>
<LightbulbIcon />
</Prompt.Tool>
) : null}
<Prompt.Model items={MODELS} variant="ghost" />
</Prompt.Tools>
<Prompt.Submit />
</Prompt.Footer>
</Prompt.Field>
<Prompt.Hint />
</Prompt>
<p className="px-4 text-xs text-muted-foreground" aria-live="polite">
{connected ? (
<span className="inline-flex items-center gap-1.5">
<CheckIcon className="size-3.5" /> Calendar connected.
</span>
) : (
'Press Connect to see the banner fold away.'
)}
</p>
</div>
);
}An agent's tasks
Prompt.Tasks is a band above the field with what an agent is working through. Its line shows a ring that fills as tasks finish and a count; pressed, it opens into the list, which scrolls past maxHeight and keeps the task in progress in view. Each Prompt.Task is pending, active, done or error. Stopping pauses the run here, and sending carries it on.
'use client';
import { BrainIcon, SparklesIcon, ZapIcon } from 'lucide-react';
import { Prompt, type PromptModelItem, type PromptTaskStatus } from '@/components/panelui/prompt';
import { useEffect, useState } from 'react';
const MODELS: PromptModelItem[] = [
{ value: 'lumen', label: 'Lumen 4', icon: <SparklesIcon />, description: 'Balanced, for most questions' },
{ value: 'lumen-mini', label: 'Lumen 4 Mini', icon: <ZapIcon />, description: 'Quick answers to small asks' },
{ value: 'lumen-deep', label: 'Lumen 4 Deep', icon: <BrainIcon />, description: 'Slower, for hard problems' },
];
const TASKS = [
'Read the open pull requests',
'Group the changes by area',
'Draft the release notes',
'Check every link in the notes',
'Bump the version to 2.4.0',
'Run the test suite',
'Tag the release and open a draft',
];
export default function PromptAgent() {
// How many tasks are done; the next one is in progress.
const [done, setDone] = useState(2);
const [running, setRunning] = useState(true);
useEffect(() => {
if (!running || done >= TASKS.length) return;
const timer = window.setTimeout(() => setDone((count) => count + 1), 1800);
return () => window.clearTimeout(timer);
}, [done, running]);
const finished = done >= TASKS.length;
const statusOf = (index: number): PromptTaskStatus =>
index < done ? 'done' : index === done && running ? 'active' : 'pending';
return (
<div className="w-full max-w-xl">
<Prompt
status={running && !finished ? 'streaming' : 'idle'}
onStop={() => setRunning(false)}
onSubmit={() => {
if (finished) setDone(0);
setRunning(true);
}}
>
<Prompt.Tasks
defaultExpanded
title={finished ? 'Release 2.4.0 is ready for review' : running ? 'Preparing release 2.4.0' : 'Paused'}
>
{TASKS.map((task, index) => (
<Prompt.Task key={task} status={statusOf(index)}>
{task}
</Prompt.Task>
))}
</Prompt.Tasks>
<Prompt.Field>
<Prompt.Textarea placeholder={running && !finished ? 'Steer the agent while it works' : 'Send anything to carry on'} />
<Prompt.Footer>
<Prompt.Tools>
<Prompt.Menu>
<Prompt.AttachItem />
</Prompt.Menu>
<Prompt.Model items={MODELS} defaultValue="lumen-deep" variant="ghost" />
</Prompt.Tools>
<Prompt.Submit />
</Prompt.Footer>
</Prompt.Field>
</Prompt>
</div>
);
}While the answer is written
With status at streaming, the status band is open and its words are lit by a moving sweep, and the send button has become a stop button with a ring turning round it. Pressing it calls onStop.
'use client';
import { BrainIcon, SparklesIcon, ZapIcon } from 'lucide-react';
import { Prompt, type PromptState, type PromptModelItem } from '@/components/panelui/prompt';
import { useEffect, useRef, useState } from 'react';
const MODELS: PromptModelItem[] = [
{ value: 'lumen', label: 'Lumen 4', icon: <SparklesIcon />, description: 'Balanced, for most questions' },
{ value: 'lumen-mini', label: 'Lumen 4 Mini', icon: <ZapIcon />, description: 'Quick answers to small asks' },
{ value: 'lumen-deep', label: 'Lumen 4 Deep', icon: <BrainIcon />, description: 'Slower, for hard problems' },
];
export default function PromptThinking() {
// Busy from the start, so the page opens on the state this example is about.
const [status, setStatus] = useState<PromptState>('streaming');
const timer = useRef<number | undefined>(undefined);
const run = () => {
window.clearTimeout(timer.current);
setStatus('submitted');
timer.current = window.setTimeout(() => setStatus('streaming'), 1000);
};
useEffect(() => () => window.clearTimeout(timer.current), []);
return (
<div className="flex w-full max-w-xl flex-col gap-3">
<Prompt status={status} onStop={() => setStatus('idle')} onSubmit={run}>
<Prompt.Field>
<Prompt.Textarea placeholder="Hang on, the answer is still being written" />
<Prompt.Footer>
<Prompt.Tools>
<Prompt.Attach />
<Prompt.Model items={MODELS} defaultValue="lumen-deep" />
</Prompt.Tools>
<Prompt.Submit />
</Prompt.Footer>
</Prompt.Field>
<Prompt.Status icon={<SparklesIcon />}>
{status === 'submitted' ? 'Reading the question' : 'Thinking about the trade-offs'}
</Prompt.Status>
</Prompt>
<p className="px-4 text-xs text-muted-foreground" aria-live="polite">
{status === 'idle' ? 'Stopped. Send anything to start again.' : 'Press the stop button to end the answer.'}
</p>
</div>
);
}Working in a project
A ghost model picker, the menu holding what a coding agent can be given, and an inset context strip under the field naming the machine, the pull request and the branch. The branch is a Select set in the strip.
'use client';
import { AtSignIcon, BrainIcon, GitBranchIcon, GitPullRequestIcon, LaptopIcon, SparklesIcon, TerminalIcon, ZapIcon } from 'lucide-react';
import { Menu } from '@/components/panelui/menu';
import { Prompt, type PromptModelItem } from '@/components/panelui/prompt';
import { Select } from '@/components/panelui/select';
const MODELS: PromptModelItem[] = [
{ value: 'lumen', label: 'Lumen 4', icon: <SparklesIcon />, description: 'Balanced, for most questions' },
{ value: 'lumen-mini', label: 'Lumen 4 Mini', icon: <ZapIcon />, description: 'Quick answers to small asks' },
{ value: 'lumen-deep', label: 'Lumen 4 Deep', icon: <BrainIcon />, description: 'Slower, for hard problems' },
];
const BRANCHES = [
{ value: 'feat/prompt', label: 'feat/prompt-footer-tools' },
{ value: 'fix/planet', label: 'fix/planet-stacking' },
{ value: 'main', label: 'main' },
];
export default function PromptWorkspace() {
return (
<div className="w-full max-w-xl">
<Prompt>
<Prompt.Field>
<Prompt.Textarea placeholder="Ask for a change, follow up, or attach a screenshot" />
<Prompt.Footer>
<Prompt.Tools>
<Prompt.Menu>
<Prompt.AttachItem>Add a screenshot</Prompt.AttachItem>
<Menu.Item>
<AtSignIcon />
Mention a file
<Menu.Shortcut>@</Menu.Shortcut>
</Menu.Item>
<Menu.Item>
<TerminalIcon />
Add terminal output
</Menu.Item>
</Prompt.Menu>
<Prompt.Model items={MODELS} variant="ghost" />
</Prompt.Tools>
<Prompt.Tools>
<Prompt.Submit />
</Prompt.Tools>
</Prompt.Footer>
</Prompt.Field>
<Prompt.Context>
<Prompt.ContextItem>
<LaptopIcon />
This machine
</Prompt.ContextItem>
<Prompt.ContextItem>
<GitPullRequestIcon />
#482
</Prompt.ContextItem>
<Select items={BRANCHES} defaultValue="feat/prompt">
<Select.Trigger variant="ghost" size="sm" aria-label="Branch" className="-my-1 ms-auto h-7 max-w-52 text-xs">
<GitBranchIcon className="size-3.5" />
<Select.Value />
</Select.Trigger>
<Select.Content>
{BRANCHES.map((branch) => (
<Select.Item key={branch.value} value={branch.value}>
{branch.label}
</Select.Item>
))}
</Select.Content>
</Select>
</Prompt.Context>
</Prompt>
</div>
);
}One line
layout="inline" on Prompt.Field sets every part in one row, with the text between them, and wraps as the text grows. width="field" opens the menu as wide as the field, and Menu.Description adds a quieter line to each item.
'use client';
import {
BookImageIcon,
GlobeIcon,
ImagePlusIcon,
LightbulbIcon,
MicIcon,
PenToolIcon,
TelescopeIcon,
} from 'lucide-react';
import { Menu } from '@/components/panelui/menu';
import { Prompt } from '@/components/panelui/prompt';
import { useState } from 'react';
export default function PromptInline() {
const [think, setThink] = useState(false);
const [search, setSearch] = useState(false);
return (
<div className="w-full max-w-2xl">
<Prompt>
<Prompt.Field layout="inline">
<Prompt.Attachments />
<Prompt.Menu width="field" side="bottom">
<Prompt.AttachItem shortcut={null}>
Add files
<Menu.Description>From this computer</Menu.Description>
</Prompt.AttachItem>
<Menu.Item>
<BookImageIcon />
Pick from library
<Menu.Description>Files you have shared before</Menu.Description>
</Menu.Item>
<Menu.Item>
<ImagePlusIcon />
Make an image
<Menu.Description>Describe it and get a picture</Menu.Description>
</Menu.Item>
<Menu.Separator />
<Menu.CheckboxItem checked={search} onCheckedChange={setSearch}>
<GlobeIcon />
Search the web
<Menu.Description>Fresh results, with sources</Menu.Description>
</Menu.CheckboxItem>
<Menu.Item>
<TelescopeIcon />
Research
<Menu.Description>A long report with citations</Menu.Description>
</Menu.Item>
<Menu.Item>
<PenToolIcon />
Sketch
<Menu.Description>Draw something and attach it</Menu.Description>
</Menu.Item>
</Prompt.Menu>
<Prompt.Textarea minRows={1} maxRows={8} placeholder="Message Lumen" />
<Prompt.Tool label="Think" showLabel variant="ghost" pressed={think} onPressedChange={setThink}>
<LightbulbIcon />
</Prompt.Tool>
<Prompt.Tool label="Dictate" variant="ghost">
<MicIcon />
</Prompt.Tool>
<Prompt.Submit onVoice={() => {}} />
</Prompt.Field>
</Prompt>
</div>
);
}Commands
Prompt.Commands opens a band of commands while the text is the trigger and a word. Typing narrows it, the arrow keys move through it, and Enter or Tab chooses. Here onSelect shows the chosen command as a pressed tool, which is pressed again to drop it.
'use client';
import {
ALargeSmallIcon,
BrainIcon,
LanguagesIcon,
ListTreeIcon,
SparklesIcon,
TableIcon,
TextQuoteIcon,
WandSparklesIcon,
ZapIcon,
} from 'lucide-react';
import { Prompt, type PromptCommandItem, type PromptModelItem } from '@/components/panelui/prompt';
import { useState } from 'react';
const MODELS: PromptModelItem[] = [
{ value: 'lumen', label: 'Lumen 4', icon: <SparklesIcon />, description: 'Balanced, for most questions' },
{ value: 'lumen-mini', label: 'Lumen 4 Mini', icon: <ZapIcon />, description: 'Quick answers to small asks' },
{ value: 'lumen-deep', label: 'Lumen 4 Deep', icon: <BrainIcon />, description: 'Slower, for hard problems' },
];
const COMMANDS: PromptCommandItem[] = [
{ value: 'summarise', icon: <TextQuoteIcon />, description: 'Cut it down to the points that matter' },
{ value: 'translate', icon: <LanguagesIcon />, description: 'Into any language, keeping the tone' },
{ value: 'rewrite', icon: <WandSparklesIcon />, description: 'Same meaning, clearer words' },
{ value: 'outline', icon: <ListTreeIcon />, description: 'Headings and bullets to start from' },
{ value: 'table', icon: <TableIcon />, description: 'Turn a list into rows and columns' },
{ value: 'shorter', icon: <ALargeSmallIcon />, description: 'Half the length, nothing lost' },
];
export default function PromptCommands() {
const [command, setCommand] = useState<PromptCommandItem | null>(null);
return (
<div className="flex w-full max-w-xl flex-col gap-3">
{/* Opens on the trigger, so the list is showing before anything is typed. */}
<Prompt defaultValue="/">
<Prompt.Commands items={COMMANDS} onSelect={setCommand} />
<Prompt.Field>
<Prompt.Textarea placeholder="Type / for commands" />
<Prompt.Footer>
<Prompt.Tools>
<Prompt.Menu>
<Prompt.AttachItem />
</Prompt.Menu>
{command ? (
<Prompt.Tool label={`/${command.value}`} showLabel pressed onPressedChange={() => setCommand(null)}>
{command.icon}
</Prompt.Tool>
) : null}
<Prompt.Model items={MODELS} variant="ghost" />
</Prompt.Tools>
<Prompt.Submit />
</Prompt.Footer>
</Prompt.Field>
</Prompt>
<p className="px-4 text-xs text-muted-foreground">Arrow keys to move, Enter or Tab to choose, Escape to close.</p>
</div>
);
}Voice
Prompt.Recorder takes over the field while voice is recorded. What has been heard is written in at the top through transcript, the wave scrolls underneath, and the check keeps the recording, which here puts the words into the field; the cross throws it away. Pass level to draw a real microphone's input.
'use client';
import { BrainIcon, SparklesIcon, ZapIcon } from 'lucide-react';
import { Prompt, type PromptModelItem } from '@/components/panelui/prompt';
import { useEffect, useState } from 'react';
const MODELS: PromptModelItem[] = [
{ value: 'lumen', label: 'Lumen 4', icon: <SparklesIcon />, description: 'Balanced, for most questions' },
{ value: 'lumen-mini', label: 'Lumen 4 Mini', icon: <ZapIcon />, description: 'Quick answers to small asks' },
{ value: 'lumen-deep', label: 'Lumen 4 Deep', icon: <BrainIcon />, description: 'Slower, for hard problems' },
];
/** What is "heard". A real app would get these words from speech to text as they arrive. */
const WORDS = 'Find a table for four near the office on Friday, somewhere quiet enough to talk'.split(' ');
export default function PromptVoice() {
const [recording, setRecording] = useState(true);
const [heard, setHeard] = useState(0);
const [text, setText] = useState('');
// The words arrive one at a time while recording, after a moment of silence.
useEffect(() => {
if (!recording || heard >= WORDS.length) return;
const timer = window.setTimeout(() => setHeard((count) => count + 1), heard === 0 ? 1400 : 320);
return () => window.clearTimeout(timer);
}, [recording, heard]);
const start = () => {
setHeard(0);
setRecording(true);
};
return (
<div className="w-full max-w-xl">
<Prompt value={text} onValueChange={setText}>
<Prompt.Field>
{recording ? (
<Prompt.Recorder
transcript={WORDS.slice(0, heard).join(' ')}
onCancel={() => setRecording(false)}
onDone={() => {
setRecording(false);
setText(WORDS.slice(0, Math.max(heard, 1)).join(' '));
}}
/>
) : (
<>
<Prompt.Textarea placeholder="Ask anything, or press the wave to talk" />
<Prompt.Footer>
<Prompt.Tools>
<Prompt.Menu>
<Prompt.AttachItem />
</Prompt.Menu>
<Prompt.Model items={MODELS} variant="ghost" />
</Prompt.Tools>
<Prompt.Submit onVoice={start} />
</Prompt.Footer>
</>
)}
</Prompt.Field>
</Prompt>
</div>
);
}Files
Two files attached to begin with through defaultFiles. accept and maxFiles decide which files are taken, and onReject is told about the others. Files also arrive by pasting, or by dropping them on the field.
Images, PDFs and spreadsheets, up to four at a time.
'use client';
import { BrainIcon, SparklesIcon, ZapIcon } from 'lucide-react';
import { Prompt, type PromptRejectReason, type PromptModelItem } from '@/components/panelui/prompt';
import { useEffect, useState } from 'react';
const MODELS: PromptModelItem[] = [
{ value: 'lumen', label: 'Lumen 4', icon: <SparklesIcon />, description: 'Balanced, for most questions' },
{ value: 'lumen-mini', label: 'Lumen 4 Mini', icon: <ZapIcon />, description: 'Quick answers to small asks' },
{ value: 'lumen-deep', label: 'Lumen 4 Deep', icon: <BrainIcon />, description: 'Slower, for hard problems' },
];
/** A small picture to attach, drawn here so the example needs no image file. */
async function drawSwatch(): Promise<File> {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 160;
const context = canvas.getContext('2d')!;
const style = getComputedStyle(document.documentElement);
const colour = (token: string, fallback: string) => style.getPropertyValue(token).trim() || fallback;
context.fillStyle = colour('--chart-2', '#6b8afd');
context.fillRect(0, 0, 160, 160);
context.fillStyle = colour('--chart-4', '#f5b84b');
context.beginPath();
context.arc(108, 56, 30, 0, Math.PI * 2);
context.fill();
context.fillStyle = colour('--chart-1', '#2f4fd6');
context.beginPath();
context.moveTo(0, 160);
context.lineTo(62, 84);
context.lineTo(118, 160);
context.fill();
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png'));
return new File([blob], 'homepage-hero.png', { type: 'image/png' });
}
const REASONS: Record<PromptRejectReason, string> = {
accept: 'is not an image, a PDF or a spreadsheet',
'max-files': 'is past the limit of four files',
};
export default function PromptAttachments() {
const [files, setFiles] = useState<File[] | null>(null);
const [rejected, setRejected] = useState<string | null>(null);
// Made after mount: a canvas, and the files' object URLs, exist only in the browser.
useEffect(() => {
let live = true;
drawSwatch().then((image) => {
const sheet = new File(['month,visits\nAugust,1840\nSeptember,2215\n'], 'visits-by-month.csv', { type: 'text/csv' });
if (live) setFiles([image, sheet]);
});
return () => {
live = false;
};
}, []);
return (
<div className="flex w-full max-w-xl flex-col gap-3">
{files ? (
<Prompt
defaultFiles={files}
defaultValue="Does the hero image match the September numbers?"
accept="image/*,.pdf,.csv"
maxFiles={4}
onReject={(refused, reason) => setRejected(`${refused[0]?.name} ${REASONS[reason]}.`)}
>
<Prompt.Field>
<Prompt.Attachments />
<Prompt.Textarea placeholder="Drop files here, paste an image, or press the clip" />
<Prompt.Footer>
<Prompt.Tools>
<Prompt.Attach />
<Prompt.Model items={MODELS} />
</Prompt.Tools>
<Prompt.Submit />
</Prompt.Footer>
</Prompt.Field>
</Prompt>
) : (
<div className="h-[11.5rem]" />
)}
<p className="px-4 text-xs text-muted-foreground" aria-live="polite">
{rejected ?? 'Images, PDFs and spreadsheets, up to four at a time.'}
</p>
</div>
);
}API Reference
Prompt
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | What is written in the field, when the caller holds it. |
defaultValue | string | '' | What the field starts with, when the caller does not hold it. |
onValueChange | (value: string) => void | — | Called with what is written, each time it changes, and with '' when it is sent. |
onSubmit | (message: PromptMessage) => void | — | Called with the text and the files when the message is sent. |
status | PromptState | 'idle' | Where the answer is. submitted and streaming stop the field being sent and turn the submit button into a stop button; error marks the field. Default idle. |
onStop | () => void | — | Called when the stop button is pressed. Without it, the button cannot be pressed while busy. |
disabled | boolean | false | Turns the whole prompt off. |
defaultFiles | File[] | — | Files attached to begin with, such as those of a message being edited and sent again. |
accept | string | — | Which files can be attached, as for <input type="file">: image/*,.pdf. Default any. |
maxFiles | number | — | How many files can be attached at once. Default no limit. |
onReject | (files: File[], reason: PromptRejectReason) => void | — | Called with the files that were not taken, and why. |
Prompt.Banner
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | true | Whether it is shown. It folds under the field when it is not. Default true. |
Prompt.Status
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | true | Whether it is shown. Default: while status is submitted or streaming. |
icon | ReactNode | — | Something before the words: the model's mark, a tool's icon. |
spinner | boolean | — | Whether a spinner turns at its end. Default true. |
Prompt.ContextItem
| Prop | Type | Default | Description |
|---|---|---|---|
onSelect | () => void | — | Called when it is pressed. Passing this makes the item a button. |
Prompt.Field
| Prop | Type | Default | Description |
|---|---|---|---|
layout | 'stacked' | 'inline' | — | stacked puts the text above a footer. inline puts every part in one row, the text between them, for a one-line composer; the row wraps as the text grows. Default stacked. |
Prompt.Tool
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | What it does, for a screen reader and for the tooltip. Shown beside the icon with showLabel. |
pressed | boolean | — | Whether it is switched on, for a tool that stays on. Leave it out for one that acts once. |
onPressedChange | (pressed: boolean) => void | — | Called with the new state when a tool with pressed is pressed. |
showLabel | boolean | false | Writes the label beside the icon. |
Prompt.Attach
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | 'Model' | Default Attach files. |
Prompt.Model
| Prop | Type | Default | Description |
|---|---|---|---|
items | readonly PromptModelItem[] | — | The models to choose from. |
value | string | — | The chosen model, when the caller holds it. |
defaultValue | string | — | The model chosen to begin with, when the caller does not hold it. |
onValueChange | (value: string) => void | — | Called with the model's value when another is chosen. |
variant | 'pill' | 'ghost' | 'pill' | pill has an edge, like the tools beside it. ghost has none until hovered. Default pill. |
label | string | 'Model' | What a screen reader calls the button. Default Model. |
className | string | — |
Prompt.Menu
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | 'Model' | What a screen reader and the tooltip call the button. Default Add. |
icon | ReactNode | — | Replaces the plus on the button. |
width | 'auto' | 'field' | — | field opens the menu as wide as the field, lined up with it, for items with descriptions. Default auto. |
Prompt.AttachItem
| Prop | Type | Default | Description |
|---|---|---|---|
shortcut | ReactNode | — | The keys shown at its end. Default ⌘U; pass null for none. ⌘U and Ctrl+U in the field open the picker. |
Prompt.Submit
| Prop | Type | Default | Description |
|---|---|---|---|
onVoice | () => void | — | Makes the button a voice button while the field is empty. Called when it is pressed. |
listening | boolean | false | Whether voice is being heard. The bars move while it is. |
sendLabel | string | 'Send' | What a screen reader calls the button while it sends. Default Send. |
stopLabel | string | 'Stop' | While it stops the answer. Default Stop. |
voiceLabel | string | 'Speak' | While it starts voice. Default Speak. |
Prompt.Tasks
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | true | Whether the band is shown. It folds under the field when it is not. Default true. |
title | ReactNode | — | What the agent is doing, on the band's one line: "Preparing the release". |
expanded | boolean | — | Whether the list is open under the title, when the caller holds it. |
defaultExpanded | boolean | false | Whether the list starts open, when the caller does not hold it. Default false. |
onExpandedChange | (expanded: boolean) => void | — | Called when the title is pressed to open or close the list. |
maxHeight | string | '10rem' | How tall the list grows before it scrolls, as a CSS length. Default 10rem. |
Prompt.Task
| Prop | Type | Default | Description |
|---|---|---|---|
status | PromptTaskStatus | 'idle' | pending, active (its words are lit while it runs), done (a check) or error. Default pending. |
Prompt.Commands
| Prop | Type | Default | Description |
|---|---|---|---|
items | readonly PromptCommandItem[] | — | The commands. |
trigger | string | — | The character that opens the list when the field starts with it. Default /. |
onSelect | (item: PromptCommandItem) => void | — | Called with the command chosen. With it, the field is cleared and the command is yours to show; without it, the command is written into the field. |
label | ReactNode | 'Model' | The heading over the list. Default Commands. |
maxHeight | string | '10rem' | How tall the list grows before it scrolls, as a CSS length. Default 12rem. |
Prompt.Recorder
| Prop | Type | Default | Description |
|---|---|---|---|
transcript | ReactNode | — | What has been heard so far, written in as it arrives. |
placeholder | ReactNode | 'Listening…' | Shown, lit by a moving sweep, until there is a transcript. Default Listening…. |
onCancel | () => void | — | Called when the cross is pressed, to throw the recording away. |
onDone | () => void | — | Called when the check is pressed, to keep it. |
level | number | — | How loud the input is now, from 0 to 1, to draw from a real microphone. Without it the wave moves on its own, as a sign that it is listening. |
cancelLabel | string | 'Cancel recording' | What a screen reader calls the cross. Default Cancel recording. |
doneLabel | string | 'Finish recording' | And the check. Default Finish recording. |
Every part also takes the props of the element it renders, and a className.
Notes
Sending
Enter sends and Shift+Enter starts a new line. An Enter that ends a composition in an input method, as when typing Japanese or Chinese, is left to the input method. onSubmit is called with { text, files }, the text trimmed, and the field and files are then cleared. Nothing is sent while there is no text and no file, or while status is submitted or streaming.
Controlled text
value and onValueChange hold the text from outside, to fill it from a suggestion or keep a draft. onValueChange is called with '' when the message is sent.
The submit button
It has three jobs and changes between them in place, so it never moves under the pointer. It sends while the prompt is idle. It stops the answer while status is submitted or streaming, calling onStop, and it cannot be pressed then if onStop is not given. With onVoice it is a voice button while the field is empty, and its bars move while listening is true.
Files
Files arrive from Prompt.AttachItem or Prompt.Attach, from ⌘U or Ctrl+U in the field, from a paste and from a drop on the field. accept takes the same rules as a file input's, such as image/*,.pdf, and maxFiles caps how many are held at once. Files that are not taken are passed to onReject with the reason, accept or max-files, so you can say why. Images get a thumbnail from an object URL, which is released when the file is removed.
Bands
Prompt.Banner and Prompt.Status open and close by their open prop, folding under the card, and nothing inside a closed band can be focused. The status band opens by itself while the prompt is busy. Its words are lit by a sweep that stops when reduced motion is asked for.
Screen readers
The text field is called "Message" unless you give it an aria-label. Each tool's label is its name, a pressed tool is announced as pressed, and the submit button's name follows its job: Send, Stop or Speak. The status band is a live region, so what it says is read as it changes.
The menu and what it switches on
Prompt.Menu keeps the footer to one button however many tools there are. A mode switched on in it, such as web search, is best shown beside it as a pressed Prompt.Tool with showLabel, which switches it off again when pressed. The first example does this for a chosen skill, and keeps web search as a tool that stays in the footer and turns blue while it is on.
Commands and the keyboard
While the command list is open, the arrow keys, Enter, Tab and Escape go to it rather than to the field, and the field points a screen reader at the highlighted command. Escape puts the list away until the text changes. Without onSelect, a chosen command is written into the field followed by a space.
Tasks
The count and the ring are worked out from the status of the Prompt.Task children, so keep the tasks as its direct children. The task in progress is scrolled to the middle of the list as it changes; the page itself does not scroll.
Server Components
Prompt is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read Prompt.Banner off the import, so each part is also exported by name: PromptBanner is Prompt.Banner, and so on for the others.
import { Prompt, PromptBanner, PromptBannerAction, PromptField, PromptAttachments, PromptTextarea, PromptFooter, PromptTools, PromptTool, PromptAttach, PromptMenu, PromptAttachItem, PromptModel, PromptSubmit, PromptStatus, PromptTasks, PromptTask, PromptCommands, PromptRecorder, PromptContext, PromptContextItem, PromptHint } from "@/components/panelui/prompt"