Skip to content

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.

What can I ask you here?
  • Anything about this small project. Type below and press Enter; @ mentions a file and / runs a command.

Mount it#

Stack the three components as the desktop app's chat column does (hosts).

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#

PropTypeDescription
onSend({ prompt, attachments, delivery? }) => void | boolean | Promise<…>Required. Enter or the send button. While running, delivery is steer or queue
runningbooleanA run is in flight: Enter steers or queues, Escape and the button stop
delivery'steer' | 'queue'What Enter does while running. AltEnter does the other. Pass the user's "Messages during a run" setting. Default queue
onStop() => voidMakes the button Stop while running and binds Escape
defaultValue, value, onChangestringUncontrolled by default. Setting value turns badges back into text
placeholderstringDefault: composerPlaceholder label (Message the agent…)
ariaLabelstringTextbox name. Default: composerLabel label (Message)
disabledbooleanNot editable, nothing sends
attachmentsbooleanPaste, drop and pick images, PDFs and text files
defaultAttachmentsComposerAttachment[]Staged on mount (a restored draft)
footerReactNodeControls after the attach button, such as pickers
trailingReactNodeControls at the end of the controls row, before Send (a context meter)
belowReactNodeA band under the card, inside the tray ring
badgesComposerBadge[]The badge strip at the top of the tray ring, in display order, see badges
onBadgesReorder(ids) => voidMakes the badges draggable. Gets every badge id in the new order
badgesKeystringWhat the badges belong to (a session id). A new key shows its badges without animating them. HostComposer passes sessionId
mentions, commandsSuggestionSource@ and / completions, see below
historystring[]Earlier prompts for ↑ and ↓, oldest first. Default: what this composer sent while mounted
autoFocusbooleanFocus on mount
onError(error) => voidCalled when a handler throws. Default: the composer shows the message under the editor
classNamestringExtra 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). 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 fieldTypeDescription
idstringRequired. The key, and what a saved order names
labelstringRequired. The badge's name
countstringA 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
iconstringLeading icon class, such as i-ph-list-checks
tone'muted' | 'subtle' | 'accent' | 'success' | 'warn' | 'danger'Tints the icon
loaderbooleanA running arc in place of the icon
highlightbooleanSomething waits on the user: raised in the warn tone, breathing, scrolled into view
titlestringTooltip, read with the badge by assistive tech
activebooleanWhat the badge opens is on screen: the raised chip, aria-pressed
ordernumberDefault slot for orderComposerBadges, lower first (default 100)
onSelect() => voidClick, 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.

FileRule
PNG, JPEG, GIF or WebP image up to 3.5 MiBAttached 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 fileAttached up to 20 MiB. A larger file is refused
Office documentRefused, with a request to export it as PDF
Other binary fileRefused
Pasted textBecomes a .txt attachment at more than 10 lines, or at 4,092 characters or more
Text preview in the lightboxFiles 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.

Keyboard#

KeyAction
EnterSend. While running: with delivery (steer or queue)
Alt EnterWhile running: send with the other delivery
Shift EnterNew line
EscStop 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 EscIn 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.

ControlNeedsCalls
Composerhost.sendsend({ sessionId, prompt, attachments, model, thinking }). Without send, nothing renders
Stop, Escapehost.interruptinterrupt(sessionId)
Enter while runninghost.sendsend({ …, 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 pickernon-empty host.modelsSent as model (provider/model)
Reasoning pickerpicked model has thinking: trueSent as thinking
PropDescription
sessionIdRequired
defaultModelprovider/model or a bare id. Default: the session's modelId, else the first offered
defaultThinkingoff, minimal, low, medium (default), high
footerControls after the pickers
onModelChangeCalled with the provider/model key when the user picks a model
badges, onBadgesReorderThe 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). 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.

PickerPropsPicks
AgentPickerprofiles, value, onChange(id), optional onOpenChange(open). A profile's hint shows dim at the end of its rowAn agent profile
ModelPickerproviders: ChatModelProvider[], value, onChange(choice), optional onOpenChange(open)One model
ModelChainPopovera 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 onlyA model with fallbacks (the app's model pill). catalogFromProviders turns host.models into a catalog
ImageModelListrows: ImageModelRow[] | null, value, onPick(key), showLegacy, onShowLegacyChange, optional toolbar, query and onQueryChangeThe 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
ModelOptionsPickersupportsReasoning, 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.

TypeScript
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.

RequestCard
QuestionA 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
PlanText and steps. Approve answers at once. Revise and Reject take an optional note. Reject stops the run
Unknown kindIts 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 memberWhat it addsAbsent
send({ …, activatedSkills, refs, echo, keepDraft })Skills activated before the run, the echo's chips, no echo for a / command, the shared draft keptNothing is sent
shell(sessionId, command)! runs outside any agent run, streamed into the transcriptA ! prompt goes to the model as text
providerReady(cwd?)A send with no provider connected is refusedNever 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.

TypeScript
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, zidane-ui/chat/interactions.