# Composer

> Message input for agents: grows with the text, attachments, slash commands, a model picker, and Send that turns into Stop while the agent works.

Component · AI · Pro kit · [Live page](https://ui.twentyfirst.media/pro/components/Composer/)

Message input for agents: grows with the text, attachments, slash commands, a model picker, and Send that turns into Stop while the agent works.

Composer props: `value` / `onChange`, `onSend(text, attachments)`, `busy`, `onStop`, `placeholder`, `models` ({id, label, hint}) + `model` + `onModel`, `commands` ({name, hint, icon, insert}) + `onCommand`, `attachments` + `onAttach(FileList)` + `onRemoveAttachment(i)`, `hint`, `left` (extra tools).
Also here: `PromptStarters` (`items` {icon, tint, title, text, prompt}, `onPick`), `ModelPicker`, `AgentStatus` (`status`, `step`), `UsageMeter` (`used`, `limit`, `format`, `label`, `caption`), `AgentMark`, `TypingDots`.

- Enter sends, Shift+Enter adds a line; typing "/" opens commands (↑ ↓ Enter).
- Drop files anywhere on the composer to attach them.
- Stop must always work: cancel streaming and mark running tools as stopped.
- Show starters only on an empty conversation, three or four, written as things people actually ask.

## Example

```tsx
import * as React from "react";
import { AgentStatus, Composer, PromptStarters, UsageMeter } from "@twentyfirstmedia/pro";

const MODELS = [
  {
    id: "opus",
    label: "Claude Opus 5.5",
    hint: "Deepest reasoning",
  },
  {
    id: "sonnet",
    label: "Claude Sonnet 5.5",
    hint: "Balanced default",
  },
  {
    id: "haiku",
    label: "Claude Haiku 5.5",
    hint: "Fast and cheap",
  },
];
function App() {
  const m = React.useState("sonnet"),
    f = React.useState([
      {
        name: "Q1-report.pdf",
      },
    ]),
    busy = React.useState(false);
  return (
    <div
      className="tf-panel"
      style={{
        gap: 24,
      }}
    >
      <PromptStarters
        items={[
          {
            icon: "edit",
            tint: "sky",
            title: "Draft a reply",
            text: "To the latest customer email",
          },
          {
            icon: "file",
            tint: "mint",
            title: "Summarize today",
            text: "Runs, approvals, costs",
          },
          {
            icon: "search",
            tint: "butter",
            title: "Ask the docs",
            text: "Search the knowledge base",
          },
        ]}
      />
      <Composer
        placeholder="Ask Atlas something, or type / for commands"
        models={MODELS}
        model={m[0]}
        onModel={m[1]}
        attachments={f[0]}
        onAttach={(fl) => {
          f[1](f[0].concat(Array.prototype.slice.call(fl)));
        }}
        onRemoveAttachment={(i) => {
          f[1](f[0].filter((_, k) => k !== i));
        }}
        commands={[
          {
            name: "summarize",
            hint: "Summarize today",
            icon: "file",
          },
          {
            name: "draft",
            hint: "Draft a reply",
            icon: "edit",
          },
          {
            name: "clear",
            hint: "New conversation",
            icon: "trash",
          },
        ]}
        busy={busy[0]}
        onSend={() => {
          busy[1](true);
          setTimeout(() => {
            busy[1](false);
          }, 2500);
        }}
        onStop={() => {
          busy[1](false);
        }}
      />
      <div
        className="tf-row"
        style={{
          justifyContent: "space-between",
        }}
      >
        <AgentStatus
          status={busy[0] ? "Thinking" : "Idle"}
          step={busy[0] ? "Reading 3 messages" : "Drafts only"}
        />
        <div
          style={{
            width: 200,
          }}
        >
          <UsageMeter label="Context" used={21} limit={200} format={(v) => v + "k"} />
        </div>
      </div>
    </div>
  );
}
export default App;
```

## Props

### AgentStatus

| Prop | Type | Notes |
| --- | --- | --- |
| `status` | `"Running" \| "Thinking" \| "Waiting for approval" \| "Idle" \| "Paused" \| "Error" \| string` | Required |
| `step?` | `string` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### Composer

| Prop | Type | Notes |
| --- | --- | --- |
| `value?` | `string` |  |
| `onChange?` | `(v: string) => void` |  |
| `onSend?` | `(text: string, files: (File \| { name: string })[]) => void` |  |
| `busy?` | `boolean` |  |
| `onStop?` | `() => void` |  |
| `placeholder?` | `string` |  |
| `label?` | `string` |  |
| `models?` | `ModelOption[]` |  |
| `model?` | `string` |  |
| `onModel?` | `(id: string) => void` |  |
| `commands?` | `ComposerCommand[]` |  |
| `onCommand?` | `(c: ComposerCommand) => void` |  |
| `attachments?` | `(File \| { name: string })[]` |  |
| `onAttach?` | `(files: FileList) => void` |  |
| `onRemoveAttachment?` | `(i: number) => void` |  |
| `hint?` | `string` |  |
| `left?` | `ReactNode` |  |
| `disabled?` | `boolean` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### PromptStarters

| Prop | Type | Notes |
| --- | --- | --- |
| `items` | `{ icon?: IconName; tint?: Tint; title: string; text?: string; prompt?: string }[]` | Required |
| `onPick?` | `(prompt: string) => void` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### UsageMeter

| Prop | Type | Notes |
| --- | --- | --- |
| `used` | `number` | Required |
| `limit` | `number` | Required |
| `label?` | `string` |  |
| `caption?` | `string` |  |
| `format?` | `(v: number) => string` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |
