Composer (.ods-composer) is the answer to AGENTS.md's decision-table row "Freeform AI instruction" — reach for it instead of a bare .ods-textarea whenever the user is typing an instruction to an agent. It bundles a textarea, a toolbar (for context chips and a token-count meta), and a Send/Stop button that switches on isStreaming.
Installation
HTML — already included if you loaded the 3 stylesheets. See the Installation guide. Note: Cmd/Ctrl+Enter-to-send, Escape-to-stop, and the controlled/uncontrolled value handling below are React-layer behavior (a useState + onKeyDown handler) — plain HTML needs equivalent JS wired up by hand.
React
npm install @operant/ui
@operant/ui is currently a monorepo workspace package, not yet published to the public npm registry.
Usage
HTML
<div class="ods-composer">
<textarea class="ods-composer-input" rows="1" aria-label="Prompt" placeholder="Ask the agent…"></textarea>
<div class="ods-composer-toolbar">
<span class="grow"></span>
<button type="button" class="ods-btn -primary -sm">Send</button>
</div>
</div>
React
import { Composer } from "@operant/ui";
export default function Example() {
return <Composer placeholder="Ask the agent…" onSend={(value) => console.log(value)} />;
}
Examples
With attached context chips
children renders inside the toolbar, before the trailing meta/send controls — the natural slot for ComposerChips (attached files, the active model, etc.).
<div class="ods-composer">
<textarea class="ods-composer-input" rows="1" aria-label="Prompt" placeholder="Ask the agent…"></textarea>
<div class="ods-composer-toolbar">
<span class="ods-composer-chip">
<span class="ods-dot -automation"></span> claude-opus-4-8
<button type="button" class="ods-iconbtn -sm" aria-label="Remove">×</button>
</span>
<span class="ods-composer-chip">
checkout-svc/deploy.yaml
<button type="button" class="ods-iconbtn -sm" aria-label="Remove">×</button>
</span>
<span class="grow"></span>
<button type="button" class="ods-btn -primary -sm">Send</button>
</div>
</div>
import { Composer, ComposerChip } from "@operant/ui";
<Composer placeholder="Ask the agent…">
<ComposerChip onRemove={() => {}}>
<span className="ods-dot -automation" /> claude-opus-4-8
</ComposerChip>
<ComposerChip onRemove={() => {}}>checkout-svc/deploy.yaml</ComposerChip>
</Composer>;
With a token counter
meta renders as .ods-composer-meta, right before the Send/Stop button.
<div class="ods-composer">
<textarea class="ods-composer-input" rows="1" aria-label="Prompt"></textarea>
<div class="ods-composer-toolbar">
<span class="grow"></span>
<span class="ods-composer-meta">120 / 8k</span>
<button type="button" class="ods-btn -primary -sm">Send</button>
</div>
</div>
<Composer meta="120 / 8k" placeholder="Ask the agent…" />
Streaming
While isStreaming, the Send button is replaced by a Stop button. Escape stops generation only while streaming — it's not a generic "clear" shortcut.
<div class="ods-composer">
<textarea class="ods-composer-input" rows="1" aria-label="Prompt">Summarize the last deploy…</textarea>
<div class="ods-composer-toolbar">
<span class="grow"></span>
<button type="button" class="ods-btn -secondary -sm">Stop</button>
</div>
</div>
<Composer isStreaming defaultValue="Summarize the last deploy…" onStop={() => {}} />
Disabled
<div class="ods-composer">
<textarea class="ods-composer-input" rows="1" aria-label="Prompt" disabled>Composer is disabled</textarea>
<div class="ods-composer-toolbar">
<span class="grow"></span>
<button type="button" class="ods-btn -primary -sm" disabled>Send</button>
</div>
</div>
<Composer disabled defaultValue="Composer is disabled" />
API Reference
<Composer>
Extends TextareaHTMLAttributes (minus value/defaultValue/onChange/className, which are redefined below).
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled value. Pair with onValueChange. Omit both to use uncontrolled defaultValue. |
defaultValue | string | — | Initial value for uncontrolled usage. |
onValueChange | (value: string) => void | — | Fired on every keystroke. |
onSend | (value: string) => void | — | Fired with the current value on Cmd/Ctrl+Enter and on Send-button click. In uncontrolled mode, the composer clears itself after sending; in controlled mode you own value and decide whether/how to clear it. |
onStop | () => void | — | Fired on Escape, only while isStreaming is true. |
isStreaming | boolean | false | Swaps the Send button for a Stop button and enables the Escape-to-stop shortcut. |
children | ReactNode | — | Rendered in the toolbar before the trailing meta/send controls — typically ComposerChips. |
meta | ReactNode | — | Rendered as .ods-composer-meta (e.g. a token counter like "120 / 8k"). |
sendLabel | string | "Send" | Send button label. |
stopLabel | string | "Stop" | Stop button label. |
disabled | boolean | — | Disables the textarea and the Send button (blocked when the trimmed value is empty, too). |
rows | number | 1 | Initial textarea row count. |
aria-label | string | "Prompt" | Accessible name for the textarea. |
<ComposerChip>
| Prop | Type | Default | Description |
|---|---|---|---|
onRemove | () => void | — | Renders a remove control (.ods-iconbtn -sm) when provided. Omit for a non-removable chip. |
removeLabel | string | "Remove" | Accessible label for the remove control. |
children | ReactNode | — | Chip content. |
HTML-side modifiers: .ods-composer (base, no variants) wrapping .ods-composer-input (textarea) and .ods-composer-toolbar (holds .ods-composer-chips, .ods-composer-meta, and the send/stop button). The chip's remove affordance reuses .ods-iconbtn -sm — there's no dedicated "chip remove" class.
Accessibility
- Cmd/Ctrl+Enter sends and Escape stops (while streaming) per AGENTS.md #8's keyboard requirements for the composer — both are wired in the
onKeyDownhandler, not via a global listener, so they only fire while the textarea has focus. disabledand an empty (or whitespace-only) trimmed value both suppress the Send button natively (disabledattribute), not just visually.- The textarea has
aria-label="Prompt"by default — override it if the surrounding page already has a visible label for the composer, to avoid duplicate/conflicting accessible names. ComposerChip's remove control is a real<button aria-label>sized via the same.ods-iconbtnclass used for other icon-only toolbar actions (AGENTS.md #3), so it's independently focusable and announced correctly — it isn't just a decorative×inside the chip's text.