> Premium standalone component (part of no design system, works in any React + Tailwind app) from AI Canvas. Index of every component and install setup: https://aicanvas.me/llms.txt

# Agent Composer

The message input an agent sits behind: a textarea that grows to six rows, inline slash commands, @file mentions as removable chips, a drag-and-drop attachment row, a model picker with each provider's logo, and a round send button whose glyph becomes a pulsing stop square while the agent works.

- Category: AI Agent UI
- Built with: Motion, Tailwind CSS
- License: Premium (proprietary, not in the public repo)
- Page: https://aicanvas.me/components/agent-composer

## Install

```bash
npx shadcn@latest add @aicanvas/agent-composer
```

Requires Premium. Set your AI Canvas token as AICANVAS_TOKEN and register the @aicanvas registry with it, as described in the Install section of https://aicanvas.me/llms.txt. Without a Premium token the command writes a placeholder file instead of the component.

AI agents can also get its install command from the read-only AI Canvas MCP server (`npx -y @aicanvas/mcp`) and run it. Setup: https://aicanvas.me/mcp

Peer dependencies:

```bash
npm install framer-motion
```

## Props

### CommandItem

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | Stable command identifier that must be unique within the commands list. |
| `label` | `string` |  | Command name shown in the picker and selected chip. |
| `hint` | `string` |  | Supporting text shown beside the command name in the picker. |
| `icon?` | `IconType` |  | Icon shown for the command; left out, the picker uses its generic command mark. |

### FileItem

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `path` | `string` |  | File path shown in mention results and inserted into the context list. |
| `kb` | `number` |  | File size in kilobytes used to estimate the context token count. |

### ModelItem

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | Stable model identifier that must be unique within the models list. |
| `label` | `string` |  | Model name shown on the selector and in its menu. |
| `speed` | `string` |  | Speed description shown in the model menu. |
| `cost` | `string` |  | Cost description shown in the model menu when space allows. |
| `mark?` | `ModelMark` |  | Mark drawn beside the model; left out, a generic circular mark is used. |

### AgentComposer

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value?` | `string` |  | Controls the message text; left out, text is kept internally, while passing it turns the demo off. |
| `onChange?` | `(value: string) => void` |  | Receives each text edit; left out, uncontrolled text still updates locally but a controlled value cannot change. |
| `onSend?` | `(payload: SendPayload) => void` |  | Receives the trimmed text, selected command, mentions and attachments; left out, submission only clears the local composition, while passing it turns the demo off. |
| `onStop?` | `() => void` |  | Handles stopping a busy request and makes the stop control available outside the demo; left out, a host-controlled busy state has no stop control. |
| `busy?` | `boolean` |  | Controls whether the composer shows its working state; left out, that state is managed internally, while passing it turns the demo off. |
| `commands?` | `CommandItem[]` |  | Supplies slash commands; left out, the demo uses sample commands and host mode uses none, while passing it turns the demo off. |
| `files?` | `FileItem[]` |  | Supplies files for mentions and attachments; left out, the demo uses sample files and host mode uses none, while passing it turns the demo off. |
| `models?` | `ModelItem[]` |  | Supplies the model selector options; left out, the demo uses sample models and host mode uses none, while passing it turns the demo off. |
| `model?` | `string` |  | Controls the selected model by id; left out, the first available model is selected internally, while passing it turns the demo off. |
| `onModelChange?` | `(id: string) => void` |  | Receives a selected model id; left out, model selection is kept internally when model is uncontrolled. |
| `placeholder?` | `string` |  | Sets the empty text prompt; left out, it is assembled from the available command and file shortcuts. |
| `submitMode?` | `SubmitMode` | `'enter'` | Chooses whether Enter, modified Enter or no keyboard shortcut sends the message. |
| `maxRows?` | `number` |  | Sets the textarea height limit in text rows; left out, the tuning height limit is used. |
| `className?` | `string` |  | Adds classes to the outermost root; left out, only the built-in classes are used. |
| `tuning?` | `Partial<Tuning>` |  | Overrides composer measurements and motion values; left out, every value in TUNING is used. |

## About

Agent Composer is the message input for an agent: a growing textarea with slash commands, file mentions, attachments and a model chip, and a send button that turns into stop while the agent works. Typing a slash opens an inline command list that becomes a chip, an at sign opens recent files and the footer counts the context they add, dropped files sit in a row above the input, and Escape stops a running turn while queued text waits with a chip. Every list is a real listbox with full keyboard control, and the whole thing is typed props with sensible defaults.

Use cases: AI coding agents, Chat assistants, Internal tools.

## Source

The source is Premium and is not published. Preview it at https://aicanvas.me/components/agent-composer.

## Remix prompt

The public part of the remix prompt: 1. Setup and 2. Constants. The rest of the build spec (state, markup, how it works) is part of Premium.

```markdown
# Agent Composer
A message composer for AI agents with slash commands, file mentions, attachments, model picker and stop control.

## 1. Setup
// npm install framer-motion
File: components-workspace/agent-composer/index.tsx  ·  'use client'  ·  export default function AgentComposer()
import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  useSyncExternalStore,
} from 'react'
import type { ComponentType } from 'react'
import type * as React from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { BookOpenDuotone } from '@/app/_icons/icons/book-open-duotone'
import { Check } from '@/app/_icons/icons/check'
import { ChevronDown } from '@/app/_icons/icons/chevron-down'
import { CircleXDuotone } from '@/app/_icons/icons/circle-x-duotone'
import { Command } from '@/app/_icons/icons/command'
import { FileDuotone } from '@/app/_icons/icons/file-duotone'
import { FileImageDuotone } from '@/app/_icons/icons/file-image-duotone'
import { FlaskDuotone } from '@/app/_icons/icons/flask-duotone'
import { ListChecksDuotone } from '@/app/_icons/icons/list-checks-duotone'
import { Paperclip } from '@/app/_icons/icons/paperclip'
import { PaperclipDuotone } from '@/app/_icons/icons/paperclip-duotone'
import { RefreshDuotone } from '@/app/_icons/icons/refresh-duotone'
import { Send } from '@/app/_icons/icons/send'
import { WrenchDuotone } from '@/app/_icons/icons/wrench-duotone'

Styling is Tailwind CSS v4 (the bg-(--var) and text-(color:--var) syntax, pointer-coarse: and class-based dark:). The @/app/_icons imports are the AI Canvas icon set; without it, use any icons that accept the same size, weight and className props.

## 2. Constants
export type Tuning = {
  listOpenMs: number
  listCloseMs: number
  listRiseY: number
  glyphFadeMs: number
  stopPulseS: number
  chipEnterMs: number
  chipExitMs: number
  stateMs: number
  lightMs: number
  errorInMs: number
  errorOutMs: number
  panelMaxW: number
  lineHeight: number
  minTextareaH: number
  maxTextareaH: number
  chipMaxW: number
  modelListW: number
  modelCostMinW: number
  listRowH: number
  listMaxH: number
  listMinW: number
  listOffset: number
  dotPitch: number
  dotFieldH: number
  counterThreshold: number
  counterWarnThreshold: number
  truncateChars: number
  tokensPerKb: number
  demoBusyMs: number
}

export const TUNING: Tuning = {
  listOpenMs: 200,
  listCloseMs: 150,
  listRiseY: 6,
  glyphFadeMs: 150,
  stopPulseS: 1.6,
  chipEnterMs: 200,
  chipExitMs: 150,
  stateMs: 200,
  lightMs: 600,
  errorInMs: 200,
  errorOutMs: 150,

  panelMaxW: 480,
  lineHeight: 20,
  minTextareaH: 20,
  maxTextareaH: 120,
  chipMaxW: 220,
  modelListW: 360,
  modelCostMinW: 360,
  listRowH: 36,
  listMaxH: 216,
  listMinW: 240,
  listOffset: 8,

  dotPitch: 6,
  dotFieldH: 64,

  counterThreshold: 200,
  counterWarnThreshold: 1800,
  truncateChars: 18,
  tokensPerKb: 100,

  demoBusyMs: 3000,
}

const THEME_VARS = [
  '[--cm-page:#EEEEF3] dark:[--cm-page:#0E0E0F]',
  '[--cm-panel:#F4F4FA] dark:[--cm-panel:#1B1B1C]',
  '[--cm-well:#EEEEF3] dark:[--cm-well:#0E0E0F]',
  '[--cm-raised:#DFDFE3] dark:[--cm-raised:#2D2D2E]',
  '[--cm-raised-hover:#CACACD] dark:[--cm-raised-hover:#373738]',
  '[--cm-border:#DFDFE3] dark:[--cm-border:#2D2D2E]',
  '[--cm-border-strong:#CACACD] dark:[--cm-border-strong:#373738]',
  '[--cm-hairline:#DFDFE3] dark:[--cm-hairline:#2D2D2E]',
  '[--cm-rim:#CACACD] dark:[--cm-rim:#F4F4FA2E]',
  '[--cm-rim-lit:#9B9B9E] dark:[--cm-rim-lit:#F4F4FA4D]',
  '[--cm-text:#1B1B1C] dark:[--cm-text:#F4F4FA]',
  '[--cm-body:#373738] dark:[--cm-body:#CACACD]',
  '[--cm-muted:#575759] dark:[--cm-muted:#9B9B9E]',
  '[--cm-faint:#7B7B7D]',
  '[--cm-selection:#A0A0A233] dark:[--cm-selection:#5E5E6333]',
  '[--cm-success:#047857] dark:[--cm-success:#34D399]',
  '[--cm-ai:#0369A1] dark:[--cm-ai:#7DD3FC]',
  '[--cm-attention:#B45309] dark:[--cm-attention:#FFB574]',
  '[--cm-red:#C8262C] dark:[--cm-red:#E5484D]',
  '[--cm-primary:#1B1B1C] dark:[--cm-primary:#F4F4FA]',
  '[--cm-primary-hover:#373738] dark:[--cm-primary-hover:#FFFFFF]',
  '[--cm-on-primary:#F4F4FA] dark:[--cm-on-primary:#0E0E0F]',
  '[--cm-focus:#1B1B1C99] dark:[--cm-focus:#F4F4FA99]',
  '[--cm-focus-gap:#F4F4FA] dark:[--cm-focus-gap:#1B1B1C]',
  '[--cm-dot:#1B1B1C] dark:[--cm-dot:#F4F4FA]',
  '[--cm-scrim:#1B1B1C14] dark:[--cm-scrim:#0E0E0F99]',
  '[--cm-elev-card:0_2px_6px_rgba(0,0,0,0.06),0_28px_56px_-16px_rgba(0,0,0,0.25)] dark:[--cm-elev-card:0_2px_6px_rgba(0,0,0,0.5),0_36px_72px_-18px_rgba(0,0,0,0.9)]',
  '[--cm-elev-1:0_1px_2px_rgba(0,0,0,0.08)] dark:[--cm-elev-1:0_1px_2px_rgba(0,0,0,0.5)]',
  '[--cm-elev-menu:0_4px_12px_rgba(0,0,0,0.10),0_16px_32px_-8px_rgba(0,0,0,0.2)] dark:[--cm-elev-menu:0_4px_12px_rgba(0,0,0,0.5),0_16px_32px_-8px_rgba(0,0,0,0.8)]',
].join(' ')

const META = 'shrink-0 text-[12px] leading-4 tabular-nums text-(color:--cm-muted)'

const MONO = 'font-mono text-[12px] leading-[18px]'

const BTN =
  'inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 text-[12px] leading-4 font-semibold transition-[background-color,color,border-color,box-shadow,scale] duration-150 active:scale-[0.97] disabled:opacity-50'

const BTN_SECONDARY = `${BTN} border border-(color:--cm-border-strong) text-(color:--cm-text) hover:bg-(--cm-raised)`

const BTN_ICON =
  'inline-grid size-8 shrink-0 place-items-center rounded-full transition-[background-color,color,scale] duration-150 active:scale-[0.97]'

const FOCUS =
  'outline-none focus-visible:ring-2 focus-visible:ring-(color:--cm-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(color:--cm-focus-gap)'

// Touch reach via pseudo-element: 32px buttons reach 44px, 16px attachment removes 40px.
const HIT = "relative pointer-coarse:after:absolute pointer-coarse:after:content-['']"

const HIT_BUTTON = `${HIT} pointer-coarse:after:-inset-1.5`

const HIT_SMALL = `${HIT} pointer-coarse:after:-inset-3`

// Chip remove (16px box): 40px wide so neighbours never overlap, 48px tall.
const HIT_CHIP_REMOVE = `${HIT} pointer-coarse:after:-inset-x-3 pointer-coarse:after:-inset-y-4`

const EASE = [0.23, 1, 0.32, 1] as const

const SPRING = { type: 'spring' as const, duration: 0.35, bounce: 0 }

const SR_ONLY =
  'absolute h-px w-px overflow-hidden [clip:rect(0,0,0,0)] whitespace-nowrap border-0 p-0'

const REDUCED_QUERY = '(prefers-reduced-motion: reduce)'

function subscribeReduced(onChange: () => void) {
  if (typeof window === 'undefined' || !window.matchMedia) return () => {}
  const mq = window.matchMedia(REDUCED_QUERY)
  mq.addEventListener('change', onChange)
  return () => mq.removeEventListener('change', onChange)
}

function getReduced() {
  return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(REDUCED_QUERY).matches
}

function getReducedServer() {
  return false
}

type Tone = 'neutral' | 'ai' | 'attention' | 'success' | 'red'

const TONE_VAR: Record<Tone, string> = {
  neutral: 'var(--cm-dot)',
  ai: 'var(--cm-ai)',
  attention: 'var(--cm-attention)',
  success: 'var(--cm-success)',
  red: 'var(--cm-red)',
}

const TONE_STRENGTH: Record<Tone, number> = { neutral: 6, ai: 14, attention: 14, success: 10, red: 12 }

type IconType = ComponentType<{ size?: number; className?: string }>

type CommandItem = { id: string; label: string; hint: string; icon?: IconType }

type FileItem = { path: string; kb: number }

type ModelMark = ComponentType<{ size: number }>

type ModelItem = {
  id: string
  label: string
  speed: string
  cost: string
  mark?: ModelMark
}

type SubmitMode = 'enter' | 'modEnter' | 'none'

type Attachment = {
  id: string
  name: string
  kind: 'image' | 'file'
  url?: string
  dimensions?: string
  kb?: number
  file?: File
}

type SendPayload = {
  text: string
  command: CommandItem | null
  mentions: FileItem[]
  attachments: Attachment[]
}

type OpenList =
  | { kind: 'commands'; start: number; query: string; source: 'type' | 'button' }
  | { kind: 'mentions'; start: number; query: string; source: 'type' | 'button' }
  | { kind: 'models' }
  | null

const CONTEXT_ZERO = 'No files in context'

const QUEUED_LABEL = 'Queued'

const BUSY_LIVE = 'Agent is working. Press Escape to stop.'

const ERROR_COPY = 'Message could not be sent. Try again.'

const NO_COMMAND = 'No command matches'

const NO_FILE = 'No file matches'

const DROP_LABEL = 'Drop files to attach'

const DEFAULT_COMMANDS: CommandItem[] = [
  { id: 'fix', label: 'Fix', hint: 'Repair the failing test or build', icon: WrenchDuotone },
  { id: 'explain', label: 'Explain', hint: 'Walk through what this code does', icon: BookOpenDuotone },
  { id: 'test', label: 'Test', hint: 'Write tests for the selected file', icon: FlaskDuotone },
  {
    id: 'refactor',
    label: 'Refactor',
    hint: 'Restructure without changing behavior',
    icon: RefreshDuotone,
  },
  { id: 'plan', label: 'Plan', hint: 'Draft the steps before editing', icon: ListChecksDuotone },
]

const DEFAULT_FILES: FileItem[] = [
  { path: 'app/lib/component-registry.tsx', kb: 12 },
  { path: 'src/components/composer/index.tsx', kb: 41 },
  { path: 'app/globals.css', kb: 8 },
  { path: 'scripts/build.mjs', kb: 3 },
  { path: 'README.md', kb: 6 },
]

const NONE: never[] = []

const DEFAULT_MODELS: ModelItem[] = [
  { id: 'gpt-sol', label: 'GPT-6.1 Sol', speed: 'Deepest', cost: 'high cost', mark: LogoGpt },
  { id: 'claude-opus', label: 'Claude Opus 5.5', speed: 'Balanced', cost: 'mid cost', mark: LogoAnthropic },
  { id: 'gemini-argon', label: 'Gemini 4 Argon', speed: 'Fast', cost: 'low cost', mark: LogoGoogle },
  { id: 'mistral-large', label: 'Mistral Large 4', speed: 'Fast', cost: 'mid cost', mark: LogoMistral },
  { id: 'deepseek-flash', label: 'DeepSeek V4 Flash', speed: 'Instant', cost: 'lowest cost', mark: LogoDeepSeek },
]

const DEMO_TEXT = 'Draft the steps for the new composer'

const DEMO_ATTACHMENT: Attachment = {
  id: 'demo-hero-frame',
  name: 'hero-frame.png',
  kind: 'image',
  dimensions: '1440 x 900',
}

// React's synthetic event does not surface isComposing, so read the native one.
function isComposingEvent(e: React.KeyboardEvent): boolean {
  return (e.nativeEvent as unknown as { isComposing?: boolean }).isComposing === true
}

const COARSE_POINTER = '(pointer: coarse)'

function subscribeCoarse(onChange: () => void) {
  if (typeof window === 'undefined' || !window.matchMedia) return () => {}
  const mq = window.matchMedia(COARSE_POINTER)
  mq.addEventListener('change', onChange)
  return () => mq.removeEventListener('change', onChange)
}

function getCoarse() {
  if (typeof window === 'undefined' || !window.matchMedia) return false
  return window.matchMedia(COARSE_POINTER).matches
}

function getCoarseServer() {
  return false
}

let uidSeed = 0

function uid() {
  uidSeed += 1
  return `cm-${uidSeed}`
}

function truncate(name: string, max: number) {
  return name.length <= max ? name : `${name.slice(0, max - 1)}…`
}

function formatTokens(n: number) {
  return n < 1000 ? `${n} tokens` : `${(n / 1000).toFixed(1)}k tokens`
}

function springOf(reduce: boolean) {
  return reduce ? { duration: 0 } : SPRING
}

// Framer wants seconds; tuning is in milliseconds, and reduced motion is instant.
function secs(reduce: boolean, value: number) {
  return reduce ? 0 : value / 1000
}

function detectTrigger(
  text: string,
  caret: number,
  hasCommand: boolean,
): { kind: 'commands' | 'mentions'; start: number; query: string } | null {
  if (!hasCommand && text.startsWith('/') && caret >= 1) {
    const token = text.slice(1)
    if (!/\s/.test(token)) return { kind: 'commands', start: 0, query: token }
  }
  const before = text.slice(0, caret)
  const at = before.lastIndexOf('@')
  if (at !== -1) {
    const prev = at === 0 ? '' : before.charAt(at - 1)
    if (at === 0 || /\s/.test(prev)) {
      const token = before.slice(at + 1)
      if (!/\s/.test(token)) return { kind: 'mentions', start: at, query: token }
    }
  }
  return null
}

function filterCommands(items: CommandItem[], query: string) {
  const q = query.trim().toLowerCase()
  if (!q) return items
  return items.filter(
    (c) => c.label.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q),
  )
}

function filterFiles(items: FileItem[], query: string) {
  const q = query.trim().toLowerCase()
  if (!q) return items
  return items.filter((f) => f.path.toLowerCase().includes(q))
}

/**
 * @typedef {object} CommandItem
 * @property {string} id Stable command identifier that must be unique within the commands list.
 * @property {string} label Command name shown in the picker and selected chip.
 * @property {string} hint Supporting text shown beside the command name in the picker.
 * @property {IconType} [icon] Icon shown for the command; left out, the picker uses its generic command mark.
 */

/**
 * @typedef {object} FileItem
 * @property {string} path File path shown in mention results and inserted into the context list.
 * @property {number} kb File size in kilobytes used to estimate the context token count.
 */

/**
 * @typedef {object} ModelItem
 * @property {string} id Stable model identifier that must be unique within the models list.
 * @property {string} label Model name shown on the selector and in its menu.
 * @property {string} speed Speed description shown in the model menu.
 * @property {string} cost Cost description shown in the model menu when space allows.
 * @property {ModelMark} [mark] Mark drawn beside the model; left out, a generic circular mark is used.
 */

/**
 * @typedef {object} AgentComposerProps
 * @property {string} [value] Controls the message text; left out, text is kept internally, while passing it turns the demo off.
 * @property {(value: string) => void} [onChange] Receives each text edit; left out, uncontrolled text still updates locally but a controlled value cannot change.
 * @property {(payload: SendPayload) => void} [onSend] Receives the trimmed text, selected command, mentions and attachments; left out, submission only clears the local composition, while passing it turns the demo off.
 * @property {() => void} [onStop] Handles stopping a busy request and makes the stop control available outside the demo; left out, a host-controlled busy state has no stop control.
 * @property {boolean} [busy] Controls whether the composer shows its working state; left out, that state is managed internally, while passing it turns the demo off.
 * @property {CommandItem[]} [commands] Supplies slash commands; left out, the demo uses sample commands and host mode uses none, while passing it turns the demo off.
 * @property {FileItem[]} [files] Supplies files for mentions and attachments; left out, the demo uses sample files and host mode uses none, while passing it turns the demo off.
 * @property {ModelItem[]} [models] Supplies the model selector options; left out, the demo uses sample models and host mode uses none, while passing it turns the demo off.
 * @property {string} [model] Controls the selected model by id; left out, the first available model is selected internally, while passing it turns the demo off.
 * @property {(id: string) => void} [onModelChange] Receives a selected model id; left out, model selection is kept internally when model is uncontrolled.
 * @property {string} [placeholder] Sets the empty text prompt; left out, it is assembled from the available command and file shortcuts.
 * @property {SubmitMode} [submitMode='enter'] Chooses whether Enter, modified Enter or no keyboard shortcut sends the message.
 * @property {number} [maxRows] Sets the textarea height limit in text rows; left out, the tuning height limit is used.
 * @property {string} [className] Adds classes to the outermost root; left out, only the built-in classes are used.
 * @property {Partial<Tuning>} [tuning] Overrides composer measurements and motion values; left out, every value in TUNING is used.
 */
```

## Related

- [Agent Status](https://aicanvas.me/components/agent-status.md): A status pill that says what the agent is doing, whose move it is, time and cost, and opens to the step history and token bill.
- [Thinking Trace](https://aicanvas.me/components/reasoning-trace.md): A single steady line shows what an agent is thinking right now, then settles into how long it thought, with every step one click away.
- [Checkpoint](https://aicanvas.me/components/agent-checkpoint.md): An agent checkpoint card that pauses for a decision on a question, command or plan, then collapses into a one-line receipt with undo, edit-before-allow and a risk grade.
- [Permission Request](https://aicanvas.me/components/permission-scope.md): A permission card where an agent asks for access and you allow it once, for this chat or always for a pattern, then revoke it from the receipt.
- [Code Block](https://aicanvas.me/components/code-panel.md): Code an agent just wrote streams in line by line, then numbered markers tie its notes to the exact lines, with Copy and Apply.
