# Composer and pickers

> The desktop app's prompt box, model pickers and question cards as React components.

`Composer` is the desktop app's prompt box: a plain-text editor, staged attachments, a row for pickers and one button that sends or stops the run. `HostComposer` binds it to a `ChatHost`. `HostInteractions` shows the questions and plans that the agent waits on. The demo runs all three on a fixture host. Your first prompt gets a question back.

## Mount it

Stack the three components as the desktop app's chat column does ([hosts](https://zidane.si/docs/ui-kit.md#pick-a-host)).

```tsx
import type { ChatHost } from 'zidane-ui/chat/headless'
import 'zidane-ui/styles.css'
import { ChatProvider, HostComposer, HostInteractions, HostTranscript } from 'zidane-ui/chat'

export function Chat({ host, id }: { host: ChatHost, id: string }) {
  return (
    <ChatProvider host={host}>
      <HostTranscript sessionId={id} />
      <HostInteractions sessionId={id} autoFocus />
      <HostComposer sessionId={id} attachments />
    </ChatProvider>
  )
}
```

`Composer` alone needs no host:

```tsx
import { Composer } from 'zidane-ui/chat'

<Composer
  running={running}
  onSend={({ prompt, attachments }) => agent.run(prompt, attachments)}
  onStop={() => agent.abort()}
  delivery="queue"
/>
```

The editor has no Markdown shortcuts. Paragraphs join with one `\n` and the prompt is trimmed.

The editor grows with its text up to 40% of the window, then scrolls inside the card. Set `--zg-composer-max-h` on an ancestor to change that cap. In a panel of fixed height, make the panel a size container and cap the editor at a share of it, so a long prompt never grows the panel:

```css
.chat-panel {
  container-type: size;
  --zg-composer-max-h: 30cqh;
}
```

## Composer props

| Prop | Type | Description |
| - | - | - |
| `onSend` | `({ prompt, attachments, delivery? }) => void \| boolean \| Promise<…>` | Required. Enter or the send button. While `running`, `delivery` is `steer` or `queue` |
| `running` | `boolean` | A run is in flight: Enter steers or queues, Escape and the button stop |
| `delivery` | `'steer' \| 'queue'` | What Enter does while `running`. `Alt`+`Enter` does the other. Pass the user's "Messages during a run" setting. Default `queue` |
| `onStop` | `() => void` | Makes the button Stop while `running` and binds Escape |
| `defaultValue`, `value`, `onChange` | `string` | Uncontrolled by default. Setting `value` turns badges back into text |
| `placeholder` | `string` | Default: `composerPlaceholder` label (`Message the agent…`) |
| `ariaLabel` | `string` | Textbox name. Default: `composerLabel` label (`Message`) |
| `disabled` | `boolean` | Not editable, nothing sends |
| `attachments` | `boolean` | Paste, drop and pick images, PDFs and text files |
| `defaultAttachments` | `ComposerAttachment[]` | Staged on mount (a restored draft) |
| `footer` | `ReactNode` | Controls after the attach button, such as pickers |
| `trailing` | `ReactNode` | Controls at the end of the controls row, before Send (a context meter) |
| `below` | `ReactNode` | A band under the card, inside the tray ring |
| `badges` | `ComposerBadge[]` | The badge strip at the top of the tray ring, in display order, see [badges](#badges) |
| `onBadgesReorder` | `(ids) => void` | Makes the badges draggable. Gets every badge id in the new order |
| `badgesKey` | `string` | What the badges belong to (a session id). A new key shows its badges without animating them. `HostComposer` passes `sessionId` |
| `mentions`, `commands` | `SuggestionSource` | `@` and `/` completions, see [below](#mentions-and-commands) |
| `history` | `string[]` | Earlier prompts for ↑ and ↓, oldest first. Default: what this composer sent while mounted |
| `autoFocus` | `boolean` | Focus on mount |
| `onError` | `(error) => void` | Called when a handler throws. Default: the composer shows the message under the editor |
| `className` | `string` | Extra classes for the root |

A handler that returns `false` or throws puts the draft back, text and attachments included.

A `ref` gives a `ComposerHandle`: `focus()`, `clear()`, `insertText(text)` at the caret, `getText()` (the draft as the composer sends it).

## Badges

`badges` puts a strip of pills at the top of the composer's tray, above the card: what waits around the conversation, such as a todo list, queued messages or an extension's status. A badge with `onSelect` is a button, and the `active` one is the raised chip. The desktop app uses the strip for its Inbox, Todos and Queue tabs and for extension badges ([composer badges](https://zidane.si/docs/desktop-composer.md#badges)). Click a badge below, drag one, or add and clear them with the buttons.

```tsx
import { Composer, mergeBadgeOrder, orderComposerBadges } from 'zidane-ui/chat/composer'

const [order, setOrder] = useState<readonly string[]>([])
const badges = orderComposerBadges([
  { id: 'todos', label: 'Todos', icon: 'i-ph-list-checks', count: '2/5', order: 10, onSelect: openTodos },
  { id: 'inbox', label: 'Plan to review', icon: 'i-ph-tray', highlight: true, order: 0, onSelect: openInbox },
], order)

<Composer
  onSend={send}
  badges={badges}
  badgesKey={sessionId}
  onBadgesReorder={ids => setOrder(saved => mergeBadgeOrder(saved, ids))}
/>
```

| `ComposerBadge` field | Type | Description |
| - | - | - |
| `id` | `string` | Required. The key, and what a saved order names |
| `label` | `string` | Required. The badge's name |
| `count` | `string` | A count or short value after the label, drawn as a count badge |
| `countTone` | `'info' \| 'neutral' \| 'attention'` | Look of the count. Default `info`, `attention` on a highlighted badge |
| `icon` | `string` | Leading icon class, such as `i-ph-list-checks` |
| `tone` | `'muted' \| 'subtle' \| 'accent' \| 'success' \| 'warn' \| 'danger'` | Tints the icon |
| `loader` | `boolean` | A running arc in place of the icon |
| `highlight` | `boolean` | Something waits on the user: raised in the warn tone, breathing, scrolled into view |
| `title` | `string` | Tooltip, read with the badge by assistive tech |
| `active` | `boolean` | What the badge opens is on screen: the raised chip, `aria-pressed` |
| `order` | `number` | Default slot for `orderComposerBadges`, lower first (default 100) |
| `onSelect` | `() => void` | Click, Enter or Space. Without it the badge is a plain pill |

The strip is a `group` of buttons. It scrolls sideways when the badges overflow, edges faded, and a vertical wheel scrolls it while it can move. The highlighted badge, else the active one, stays in view.

With `onBadgesReorder`, the user drags a badge, or moves the focused one with `Alt` `Shift` `←` `→`. The new order comes once per move. `orderComposerBadges(badges, saved)` sorts by `order` and applies a saved order over the badges it names, so a badge that is new since the user last moved one keeps its default slot. `mergeBadgeOrder(saved, ids)` is the order to save after a move: the badges shown in their new order, with saved ids not shown now kept where they were.

Badges enter on the kit's `settle` motion with a fade and leave on `exit`, and the others slide to their slots. The band opens and closes with the first and last badge. A new `badgesKey` shows the next conversation's badges as they are, with nothing animating in. Under reduced motion only the fades remain, and with motion off nothing moves. `ComposerBadges` is the strip alone, for a composer of your own: put it first in the tray.

## Attachments

With `attachments` on, each pasted, dropped or picked file becomes a `ComposerAttachment`: `{ id, name, mediaType, size, dataUrl }`. The rules come from `planAttachment` in `zidane/chat/pure`, so the desktop app uses the same rules.

| File | Rule |
| - | - |
| PNG, JPEG, GIF or WebP image up to 3.5 MiB | Attached as it is |
| Larger image, or another image format (AVIF, BMP, SVG, HEIC) | Encoded again as PNG (a JPEG stays JPEG) and scaled down until it fits 3.5 MiB. If the browser cannot decode it, the composer refuses it |
| PDF or text file | Attached up to 20 MiB. A larger file is refused |
| Office document | Refused, with a request to export it as PDF |
| Other binary file | Refused |
| Pasted text | Becomes a `.txt` attachment at more than 10 lines, or at 4,092 characters or more |
| Text preview in the lightbox | Files up to 1 MiB. A larger file shows its name and size only |

For a refused file, the composer shows the reason under the editor.

Click a tile to open it in the lightbox. The terminal app has [its own limits](https://zidane.si/docs/tui-prompt.md).

## Keyboard

| Key | Action |
| - | - |
| `Enter` | Send. While running: with `delivery` (steer or queue) |
| `Alt` `Enter` | While running: send with the other delivery |
| `Shift` `Enter` | New line |
| `Esc` | Stop the run, when `running` and `onStop` is set |
| `↑` | On the first line: previous prompt (the draft is kept) |
| `↓` | On the last line, while recalling: next prompt, then the draft |
| `↑` `↓` `Enter` `Tab` `Esc` | In a suggestion popup: move, pick, pick, close |

A popup with no matches leaves `Enter`, `Tab` and the arrows to the draft. A command typed out in full opens no popup, so `Enter` sends it.

The editor is a `textbox` named by `ariaLabel`. The attach, send and stop buttons have labels. The popup is a `listbox` with an `aria-selected` option.

## HostComposer

`HostComposer` takes every `Composer` prop except the handlers and `running`, plus the props below. It reads `running` from `host.transcript(sessionId)`. A missing host capability hides its control.

| Control | Needs | Calls |
| - | - | - |
| Composer | `host.send` | `send({ sessionId, prompt, attachments, model, thinking })`. Without `send`, nothing renders |
| Stop, Escape | `host.interrupt` | `interrupt(sessionId)` |
| Enter while running | `host.send` | `send({ …, delivery })`: `steer` into the run or `queue` for its end, attachments included. A run that ended first gets the prompt as a new send |
| Model picker | non-empty `host.models` | Sent as `model` (`provider/model`) |
| Reasoning picker | picked model has `thinking: true` | Sent as `thinking` |

| Prop | Description |
| - | - |
| `sessionId` | Required |
| `defaultModel` | `provider/model` or a bare id. Default: the session's `modelId`, else the first offered |
| `defaultThinking` | `off`, `minimal`, `low`, `medium` (default), `high` |
| `footer` | Controls after the pickers |
| `onModelChange` | Called with the `provider/model` key when the user picks a model |
| `badges`, `onBadgesReorder` | The badge strip, as on `Composer`. `badgesKey` defaults to `sessionId` |

`SendInput.thinking` takes those five levels only (`THINKING_LEVELS`). zidane has three more: `xhigh`, `max` and `adaptive` (see [thinking](https://zidane.si/docs/agents.md#thinking)). `ModelOptionsPicker` used alone lists all eight (`EFFORT_LEVELS`) unless you pass `levels`.

The desktop app's pickers are exported for your own `Composer`. Put them in one `zg-segment` track in `footer`.

| Picker | Props | Picks |
| - | - | - |
| `AgentPicker` | `profiles`, `value`, `onChange(id)`, optional `onOpenChange(open)`. A profile's `hint` shows dim at the end of its row | An agent profile |
| `ModelPicker` | `providers: ChatModelProvider[]`, `value`, `onChange(choice)`, optional `onOpenChange(open)` | One model |
| `ModelChainPopover` | a `ModelCatalogRow[]` catalog, `chain`, `onCommit(chain)`, `showLegacy`, `onShowLegacyChange`, optional `onOpenChange(open)`, `forNextChat` and `shortcut`. A row's `details` show beside its name. Hovering the pill shows the chain (`ModelChainTooltip`). `images` (`{ rows, value, onPick }`) adds a Chat / Image switch under the search field, and `kind: 'chat' \| 'image'` shows one list only | A model with fallbacks (the app's model pill). `catalogFromProviders` turns `host.models` into a catalog |
| `ImageModelList` | `rows: ImageModelRow[] \| null`, `value`, `onPick(key)`, `showLegacy`, `onShowLegacyChange`, optional `toolbar`, `query` and `onQueryChange` | The image models in the chat models' list (`ModelChainList`, one model): the same header, pills, empty states and footer, the default pinned as In use. `effectiveImageModel` resolves the stored default |
| `ModelOptionsPicker` | `supportsReasoning`, `effort`, `onEffort(level)`, `options`, `enabled`, `onOptions(next)`, optional `levels`, `effortHints` (a note per level) and `onOpenChange(open)` | Effort and per-model options |

`onOpenChange` fires on every open and close, so you can load what the menu shows each time it opens. The desktop app uses it to fetch the cache hints (`re-reads ~452k tokens`).

## Mentions and commands

`@` anywhere, or `/` at the start of the prompt, opens a popup at the caret.

```ts
interface MentionItem {
  id: string           // what the host resolves: a path, a session id, a command
  label: string        // what the popup and the badge show
  description?: string
  icon?: string        // an icon class, e.g. 'i-ph-file'
}

interface SuggestionSource {
  items: (query: string) => MentionItem[] | Promise<MentionItem[]>
  serialize?: (item: { id: string, label: string }) => string
}
```

`items` runs on every keystroke with the text after the trigger and returns items ranked (the first is selected). A picked item shows as a badge and is sent as `@label` or `/label` unless `serialize` returns something else. Most hosts want the id:

```tsx
<HostComposer
  sessionId={id}
  mentions={{
    items: query => files.filter(f => f.path.includes(query)).slice(0, 20).map(f => ({ id: f.path, label: f.name })),
    serialize: item => `@${item.id}`,
  }}
  commands={{ items: query => commands.filter(c => c.name.startsWith(query)).map(c => ({ id: c.name, label: c.name })) }}
/>
```

## Interactions

When the agent calls `ask_user` or `present_plan`, the run waits. `HostInteractions` lists pending requests from `host.interactions.pending(sessionId)`, oldest first, answers with `host.interactions.answer(requestId, response)` and renders nothing when none are pending.

| Request | Card |
| - | - |
| Question | A form (free text, single or multiple choice, yes/no) plus "Additional instructions". Required questions block Send. Dismiss (continue without an answer) shows when the host has `interactions.cancel` |
| Plan | Text and steps. Approve answers at once. Revise and Reject take an optional note. Reject stops the run |
| Unknown kind | Its tool and payload, read-only |

`InteractionCard` renders one card: `interaction` (a `ChatInteraction`), `onAnswer(response)`, optional `onDismiss` and `autoFocus`. Both components draw Markdown through the transcript's `Markdown` slot, or a `Markdown` prop.

## Sending from a desktop-style composer

`sendPrompt(request, deps)` (`zidane-ui/chat/headless`) is what the desktop app's Send does, React-free. It checks a provider is connected (`ChatHost.providerReady`) when the app last knew none, hands `/side` to the side panel, creates the session from the new-chat draft (`deps.createSession`, built with `newSessionRequest(draft, text, defaults)`), runs a `!` command with `ChatHost.shell`, queues a prompt sent mid-run (`deps.enqueue`, the session host's queue), and otherwise sends it with its chips (`promptRefs`) and previews. A known `/` command shows its own header, so it is sent with `echo: false`. It rejects, so the composer keeps the text, when no provider is connected, `/side` is refused or the session could not be created.

| Host member | What it adds | Absent |
| - | - | - |
| `send({ …, activatedSkills, refs, echo, keepDraft })` | Skills activated before the run, the echo's chips, no echo for a `/` command, the shared draft kept | Nothing is sent |
| `shell(sessionId, command)` | `!` runs outside any agent run, streamed into the transcript | A `!` prompt goes to the model as text |
| `providerReady(cwd?)` | A send with no provider connected is refused | Never checked |

`useProviderReadyPoll(store, paused)` keeps a `Writable<boolean | null>` on that check, at mount and each time `paused` turns off (the desktop app pauses it while a modal is open, as Settings › Accounts and the setup wizard write the credentials): pass the same store as `useSendPrompt`'s `providerReady`, and read it for a sign-in pill. A failed check stores `null` (unknown), never "none".

`useSendPrompt(options)` (`zidane-ui/chat/composer`) binds it to the providers: the `ChatHost`, the session host's queue, the composer host's `/` commands (`completions.commands`), the git host's machine folders and the prompt history. The app passes what only it holds: `createSession(request)`, `newSessionDefaults()` (the picks a new session starts with), `onSessionCreated`, `reveal`, `side(sessionId, question)` and the provider flag (`providerReady`, a `Writable<boolean | null>`). It returns `send`, `stop` and `needsProvider`, which stays up until the flag says a provider is ready.

`useDraftTarget({ session, defaultFolder, setDefaultFolder, sessions, servers, onLocalFolder })` keeps the draft's picks: the folder (or none), a saved server and its folder, an environment kind (`useEnvironmentChoices`, `zidane-ui/git`) and a machine's folder. Every pick resets when the conversation moves to a session. `onLocalFolder` hears the draft's folder on this computer while the draft shows.

The draft's rules are there too: `recentLocalFolders`, `draftFolderPath` and `draftLocalFolder` say which folders the folder menu offers, which folder the draft is in and when it makes the window's draft scope. `createDraftScopeStore({ projectRoot, release, forget })` holds that scope: the draft id, renewed after each send and when the draft moves to another project, so the terminals opened in one draft never follow the next.

## Fixture hosts

`createFixtureHost` runs all of this without an agent. `reply(prompt)` returns a script: transcript events, streamed in, and `{ interaction, then }` steps that raise a request and continue with `then(answer)` (`null` when dismissed). The fixture steers, queues follow-ups until the run ends and stops on `interrupt`. Its `interactions` field seeds pending requests.

```ts
const host = createFixtureHost({ sessions, models }, {
  reply: prompt => [
    { kind: 'tool', tool: 'read_file', callId: 'r1', text: 'read_file(a.ts)', input: { path: 'a.ts' } },
    { kind: 'tool-result', tool: 'read_file', callId: 'r1', text: 'export {}' },
    {
      interaction: question,
      then: answer => [{ kind: 'markdown', text: answer ? 'Done.' : 'Skipped.' }],
    },
  ],
})
```

## Reference

[`zidane-ui/chat/composer`](https://zidane.si/docs/reference/ui-chat-composer.md), [`zidane-ui/chat/interactions`](https://zidane.si/docs/reference/ui-chat-interactions.md).
