PixelDosa UI

Prompt Composer

A request composer that isn't a blank chat box. Free text, structured controls for tone, length, audience and format, and attachments for context.

  • release-notes-4.2.mdMarkdown · 6 KB

Enter to send, Shift+Enter for a new line.

Installation

bash
npx shadcn@latest add @pixeldosa/prompt-composer

Requires `cn` at `@/lib/utils`. Put anything users would otherwise type as boilerplate ("make it friendlier, keep it short") into `controls`, and turn `values` into the prompt on your side. Upload in `onAttachFiles` and keep `attachments` in your state.

Usage

"use client";

import { PromptComposer } from "@/components/ui/prompt-composer";

<PromptComposer
  controls={[
    { id: "tone", label: "Tone", options: ["Neutral", "Friendly", "Formal"], defaultValue: "Neutral" },
    { id: "length", label: "Length", options: ["Short", "Medium", "Long"], defaultValue: "Medium" },
  ]}
  attachments={attachments}
  onAttachFiles={upload}
  onRemoveAttachment={remove}
  busy={generating}
  onStop={abort}
  onSubmit={({ text, values, attachments }) => generate(buildPrompt(text, values), attachments)}
/>

Why it exists

A blank box asks people to be good at prompting. "Make it friendlier, keep it short, it's for customers" isn't creative input. It's a specification, and typing it as prose every time is inconsistent and invisible until the result comes back wrong. The composer turns it into controls you can see before you send.

Features

  • Specification as pills. "Tone: Friendly" is a Select pill, so it works with a keyboard, typeahead and a screen reader, and its list is drawn in the system's own surface. Pills changed from their default are outlined, so the non-default parts of the request stand out.
  • Enter to send, Shift+Enter for a new line. Enter doesn't send while an input method (IME) is composing. Japanese or Chinese typists don't send half a sentence.
  • Attach any way. Use the button, drag and drop, or paste. Upload status is shown on each file, and sending waits for uploads with a visible reason.
  • Stop while generating. Send becomes Stop and stays reachable.
  • A count only when it matters. It appears in the last 10% before maxLength.
  • Suggestions fill, they don't send.

Examples

Turn values into the prompt on your side, where you control the wording:

function buildPrompt(text, { tone, length, audience, format }) {
  return `Write a ${length.toLowerCase()} ${format.toLowerCase()} for ${audience.toLowerCase()} in a ${tone.toLowerCase()} tone.\n\n${text}`;
}

Uploads stay yours. Report status back:

onAttachFiles={(files) => {
  for (const file of files) {
    const id = crypto.randomUUID();
    add({ id, name: file.name, status: "uploading" });
    uploadFile(file).then(
      () => update(id, { status: "ready" }),
      () => update(id, { status: "failed" }),
    );
  }
}}

Props

PropTypeDescription
onSubmit({ text, values, attachments }) => voidRequired.
controls{ id, label, options, defaultValue }[]Options are strings or { value, label }.
attachments{ id, name, detail?, status? }[]status: "uploading" | "ready" | "failed".
onAttachFiles(files: File[]) => voidEnables attach, drop and paste.
onRemoveAttachment(id) => void
acceptstringPassed to the file input.
suggestionsstring[]Shown while the text is empty.
busy / onStopboolean / () => voidSend becomes Stop.
maxLengthnumber
submitLabelstringDefaults to "Generate".
labelstringAccessible name for the text area.
clearOnSubmitbooleanDefaults to true. Controls keep their values.
value / onValueChangestring / (value) => voidControlled text, e.g. to fill the composer from a suggestion.

Accessibility

  • The text area has a label and is described by the keyboard hint.
  • Each control is a Select combobox named after its label ("Tone") that announces its current value; the visible "Tone:" prefix is hidden from screen readers so it isn't read twice.
  • Send uses aria-disabled, so it stays focusable, and the reason it's blocked is shown and announced.
  • Attachment remove buttons are named after the file.

When not to use

This isn't a chat input. There's no thread and no history. Use it where a request produces an artifact. For redirecting an agent that's already running, use Agent Steer.

why it's built this way

Engineering Notes

Exists because the system argues against making every AI feature a blank chat box and needed an alternative. The composer splits a request into what only words can say (the text) and what is really a specification (controls), because specifications typed as prose are inconsistent and invisible until the result comes back wrong. Controls are Select pills: keyboard, typeahead and screen reader behaviour come from Select, each pill sizes to its current choice rather than its longest option, and the open list is drawn in the system's own surface instead of the platform's menu. A changed value gets a stronger outline, so the non-default parts of the request stand out. Details are handled where composers usually slip. Enter is ignored while `isComposing`, so IME users confirming a candidate don't send half a sentence. The text area autosizes from scrollHeight up to 240px, because `field-sizing: content` isn't universal yet. Send is `aria-disabled`, not `disabled`, so it stays focusable and the blocked reason ('Waiting for attachments to finish uploading') is shown and announced. The count appears only in the last 10% before `maxLength`. The file input is reset after each pick, so the same file can be picked again. Uploads are the caller's, and the composer only reflects their status. It stays headless about what happens next: the caller assembles the prompt from `values`.

how it moves, and when it doesn't

Motion Notes

Quiet, because composing is the user's turn. The only moving element is the shared `pd-shimmer` on an uploading attachment's 'Uploading' label, which is real machine work. Borders and backgrounds change colour at `--pd-duration-instant` on focus, hover, drag-over and control changes. The text area grows instantly as lines are added, with no height transition, so the caret never lags the text. Send swaps to Stop without animation, so the control is usable the moment generation starts. Everything is instant or static under prefers-reduced-motion.