Elements

Context

A token ring in the rail fills as the conversation grows, warning near the limit.

Context

37%

System12k
Tools8k
Messages54k
Total74k / 200k
fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-composer"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

A ring button that sits in the composer's rail. Hovering or focusing it opens a small breakdown of system, tool, and message tokens against the model's window, and the ring itself fills to match. With a runtime the running total comes from the thread's own token usage; standalone you compute and pass every number yourself.

Getting started

With a runtime, assistant-ui does not track a system, tools, and messages split on its own. What it does track is per-turn usage: a ChatModelAdapter can report inputTokens and outputTokens for each generation step, and the thread carries that on every assistant message.

Read the running total

Reduce every message's steps into a single count. Steps without a usage object (a provider that does not report it, or a message still streaming) contribute nothing.

components/assistant-ui/elements/composer-context-rail.tsx
"use client";

import { useAuiState } from "@assistant-ui/react";

function useThreadTokensUsed(): number {
  return useAuiState((s) =>
    s.thread.messages.reduce((sum, message) => {
      const steps = message.metadata.steps ?? [];
      return (
        sum +
        steps.reduce(
          (stepSum, step) =>
            stepSum +
            (step.usage?.inputTokens ?? 0) +
            (step.usage?.outputTokens ?? 0),
          0,
        )
      );
    }, 0),
  );
}

Feed the ring

ComposerContext expects a category breakdown, and the runtime does not meter tokens by category. Your system prompt and tool schemas cost roughly the same fixed amount on every turn, so treat those as constants you already know; the number that actually grows is the conversation, and that is the one real figure above. The values are read as thousands (the ring appends k without dividing), and the total is the active model's context window, which you already picked and the thread state does not expose.

import { ComposerContext } from "@/components/assistant-ui/elements/composer";

const SYSTEM_PROMPT_TOKENS = 1;
const TOOL_SCHEMA_TOKENS = 3;
const MODEL_CONTEXT_WINDOW = 200;

export function ComposerContextRail() {
  const used = useThreadTokensUsed();
  return (
    <ComposerContext
      usage={{
        system: SYSTEM_PROMPT_TOKENS,
        tools: TOOL_SCHEMA_TOKENS,
        messages: Math.round(used / 1000),
        total: MODEL_CONTEXT_WINDOW,
      }}
    />
  );
}

The reduction re-runs from an empty array on every new thread, so switching threads resets the ring without any extra bookkeeping.

Anatomy

<div data-slot="composer-context">
  <div>{/* hover/focus panel: header, stacked bar, per-segment legend, total line */}</div>
  <button aria-label="Context usage">{/* ring svg */}</button>
</div>

The trigger is always a ring: an outer track and a foreground arc whose stroke-dashoffset follows system + tools + messages against total. A zero total is treated as zero fraction rather than dividing by it. Past 85 percent the ring, the percentage readout, and the trigger itself turn red. The detail panel opens on hover or keyboard focus through CSS group state; there is no open prop to control it, unlike ComposerMenu. Its three segments (System, Tools, Messages) have fixed colors at increasing opacity and are not restylable per segment; if you need arbitrary labeled categories with your own colors, use Context breakdown instead.

Examples

Placing it in the toolbar

The ring is sized to sit beside the send button, inside ComposerActions.

<ComposerToolbar>
  <ComposerActions>
    <ComposerAttachButton onClick={pick} />
  </ComposerActions>
  <ComposerActions>
    <ComposerContext usage={usage} />
    <ComposerSend streaming={streaming} idle={!value} onClick={send} />
  </ComposerActions>
</ComposerToolbar>

Restyle the trigger

className merges onto the root, which wraps both the trigger button and the panel; style the ring's size through it.

<ComposerContext className="[&_button]:size-9" usage={usage} />

API reference

Thread state

SelectorTypeDescription
s.thread.messagesreadonly MessageState[]Every message in the active thread. Each assistant message's metadata.steps is a readonly ThreadStep[], and a step may carry usage: { inputTokens: number; outputTokens: number } when the model adapter reports it.

There is no selector for a category breakdown or for the model's context window size; both are derived or supplied as shown above.