Skip to content

Composer ​

The box a person writes a message in, with everything that goes with it: @ for files and other chats, $ for skills, / for commands, attachments, the model and permission settings, messages queued behind a running turn, and the approvals and questions the chat waits on.

tsx
import { Composer } from '@adecore/agents-react/chat/ui/Composer';
tsx
import { useState } from 'react';
import type { ChatSendExtras } from '@adecore/agents-react/chat/chat-client';
import { Composer } from '@adecore/agents-react/chat/ui/Composer';
import { useChatScope } from '@adecore/agents-react/scope';
import { useChatRow } from '@adecore/agents-react/state/chats';
import { chatInfo } from '../shared/agents-data.ts';
import { AgentDemo } from '../shared/agents.tsx';

const CHAT = 'chat-new';

/* A chat nobody wrote in yet, so the model picker still offers both CLIs. */
const newChat = () => ({
    [CHAT]: { info: chatInfo(CHAT, { agentSessionId: null, usage: { contextTokens: 0, contextWindow: 200_000, costUsd: 0, turns: 0 } }), items: [] }
});

function Sending() {
    const scope = useChatScope();
    const info = useChatRow(CHAT, (row) => row?.info);
    const [sent, setSent] = useState<{ text: string; extras: ChatSendExtras } | null>(null);

    return (
        <div className="flex w-full max-w-2xl flex-col gap-3">
            {info && (
                <Composer
                    chatId={CHAT}
                    info={info}
                    focused={false}
                    disabled={false}
                    providerFixed={false}
                    onSend={(text, extras) => {
                        setSent({ text, extras });
                        void scope.chats.send(CHAT, text, extras);
                    }}
                    onRetarget={(provider, selection) => scope.chats.retarget(CHAT, provider, selection)}
                />
            )}
            <pre className="overflow-x-auto rounded-md bg-surface-sunken px-3 py-2 font-mono text-xs text-text-muted">
                {sent === null ? 'Type @ for a file or $ for a skill, then press Enter.' : JSON.stringify(sent, null, 2)}
            </pre>
        </div>
    );
}

export default function ComposerDemo() {
    return (
        <AgentDemo chats={newChat}>
            <Sending />
        </AgentDemo>
    );
}
tsx
<Composer
    chatId={chatId}
    info={info}
    focused={isActiveView}
    disabled={!connected}
    providerFixed={false}
    onSend={(text, extras) => void scope.chats.send(chatId, text, extras)}
    onRetarget={(provider, selection) => scope.chats.retarget(chatId, provider, selection)}
/>

info is the chat's ChatInfo from the store, which the composer reads its model, queue and limits from. It reads the CLIs from the scope, so a chat that has not spoken yet can still move to another CLI's model.

Sending ​

onSend(text, extras) gets the trimmed text and ChatSendExtras: the mentions, skills and referenced chats picked, and the attachments as uploads. A quote becomes a Markdown quote above the text. The paths and skill names are also in the text; the lists are what the thread draws as chips.

onSend returns nothing and the composer clears its draft at once, without waiting for the host. If a send can fail in your app, keep the text and files until send resolves, so a person can try again.

Before the first message, picking another CLI's model calls onRetarget(provider, selection), which moves the empty chat. After it, or with providerFixed, the picker offers only the chat's own CLI.

Keys ​

Mod is Command on macOS and Control elsewhere. A key inside an IME composition belongs to the composition, and a key the host's isAppShortcut claims passes through.

Key
EnterSends, except inside a code fence that is not closed yet, where it adds a line.
Enter in a listStarts the next item; on an empty item, leaves the list.
Shift+EnterA new line.
Mod+EnterSends from anywhere.
Tab, Shift+Tab in a fenceIndent and outdent. Outside a fence, Tab moves the focus.
Up and Down in a pickerMove through it; Enter or Tab picks.
Up in an empty boxBrings back an earlier message, up to the last 50; Escape puts it back.
PageUp, PageDownScroll the thread.
Mod+SStashes the draft; in an empty box, brings back the newest stashed prompt.
Mod+Shift+VPastes a long text inline instead of as a file.

/model, /compact and /clear are handled by the composer; the CLI's own commands go to it as text, except those that only make sense in its terminal (usableSlashCommands).

Pickers and pasting ​

@ searches files through the host's searchFiles (debounced, at most 8 results) and the chats of useReferableChats (at most 4, above the files). $ lists the CLI's skills; a name starts with a letter, so $20 stays text. A sigil only opens a picker at the start of a word.

A file pasted or dropped becomes an attachment, within the limits of the contract. Pasted text of 32 KiB or more becomes paste-1.txt beside the message. The box counts characters from 100,000 and refuses to send past 120,000 (PROMPT_COUNTER_FROM, PROMPT_MAX_CHARS).

Drafts, stash and queue ​

Each chat keeps its draft while the composer is away; see Persistence. offerDraft(chatId, text) puts text under the draft for a person to read and send, whether the composer is mounted or not.

The stash is a shelf of prompts shared by every chat: Mod+S puts the draft there and StashPicker lists them. A restored prompt brings back its text, mentions and skills, not its files.

A message sent during a turn waits in the queue over the box. It can be sent at once, or taken back into the draft to edit, which merges with what was typed since.

Props ​

PropType
chatIdstring
infoChatInfoThe chat as the store holds it.
focusedbooleanWhether the composer and its prompts take the keyboard.
disabledbooleanNothing can be sent or answered, such as while the host is away.
readOnlybooleanNothing can be written, while the chat's prompts can still be answered. false by default.
providerFixedbooleanThe chat belongs to one CLI; the picker offers only its models.
onSend(text: string, extras: ChatSendExtras) => void
onRetarget(provider: AgentKind, selection: ModelSelection) => Promise<void>
answerPromptsElsewhere() => voidThe prompts are answered somewhere else; the composer only points there.

ComposerInput ​

The text field alone: CodeMirror under a plain string, with the Markdown, chips and keys of the composer left to you.

tsx
import { useRef, useState } from 'react';
import { Button } from '@adecore/ui';
import { ComposerInput, type ComposerInputHandle } from '@adecore/agents-react/chat/ui/ComposerInput';

export default function ComposerInputDemo() {
    const [text, setText] = useState('');
    const input = useRef<ComposerInputHandle>(null);

    return (
        <div className="flex w-full max-w-md flex-col gap-2">
            <div className="rounded-lg border border-border bg-surface px-3 py-2">
                <ComposerInput ref={input} value={text} placeholder="Describe the change" disabled={false} tabbable onChange={(value) => setText(value)} />
            </div>
            <div className="flex items-center gap-2">
                <Button size="sm" variant="secondary" onClick={() => input.current?.insert('@src/http.ts')}>
                    Insert a path
                </Button>
                <span className="text-xs text-text-muted">{text.length} characters</span>
            </div>
        </div>
    );
}
tsx
const input = useRef<ComposerInputHandle>(null);

<ComposerInput ref={input} value={text} placeholder="Describe the change" disabled={false} tabbable onChange={(value) => setText(value)} />;

The handle has focus(), setCaret(pos) and insert(text). onKeyDown(event, view) and onPaste(event, view) run before the editor's own and return true for a key or paste they handled. onChange and onSelectionChange get the text, the selection as InputSelection and the editor state. Keep extensions and an element placeholder the same object between renders, since a new one reconfigures the editor. A value from outside moves the caret to the end and stays out of the undo history.

The editor parts are exported for an input of your own: composerEditorExtensions, markdownLanguage, chipDecorations, insertAtSelection, and the key decisions in chat/ui/composer/keys (enterAction, listItemAt, inOpenFence, recallDirection, ...). PlainTextarea is the textarea a written answer uses unless the host's dictation hands another.

Licenses and third-party notices are listed in each package.