{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-status",
  "title": "Agent Status",
  "description": "A compact agent state chip — a dot and a label morphing through idle, thinking, tool use, waiting-on-you and failure, with a drift-free phase clock and one polite announcement per phase.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/agent-status.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\n/* -------------------------------------------------------------------------- *\n * States\n *\n * The five states an agent run can be in from the reader's point of view: it is\n * doing nothing, it is reasoning, it is calling a named tool, it needs a human,\n * or it broke. Everything else in this file is a table keyed by that union —\n * adding a sixth state is five edits and no new branching.\n * -------------------------------------------------------------------------- */\n\nexport type AgentStatusState = \"idle\" | \"thinking\" | \"using-tool\" | \"waiting\" | \"error\"\n\nexport interface AgentStatusLabels {\n  idle: string\n  thinking: string\n  /** Prefix in front of the tool name — the name itself is a separate slot. */\n  usingTool: string\n  /** Stands in for the tool name when `state=\"using-tool\"` arrives without one. */\n  unnamedTool: string\n  waiting: string\n  error: string\n}\n\nconst DEFAULT_LABELS: AgentStatusLabels = {\n  idle: \"Idle\",\n  thinking: \"Thinking\",\n  usingTool: \"Using\",\n  unnamedTool: \"a tool\",\n  waiting: \"Waiting for you\",\n  error: \"Run failed\",\n}\n\n/**\n * State → color. Every value is a theme variable, so the host palette (and dark\n * mode) decides the actual colors; the root publishes the winner as\n * `--agent-tone` and every child paints with that one variable.\n */\nconst TONE: Record<AgentStatusState, string> = {\n  idle: \"var(--muted-foreground)\",\n  thinking: \"var(--chart-1)\",\n  \"using-tool\": \"var(--chart-4)\",\n  waiting: \"var(--chart-3)\",\n  error: \"var(--destructive)\",\n}\n\n/** \"The agent is burning tokens right now\" — the only states that animate. */\nconst ACTIVE = new Set<AgentStatusState>([\"thinking\", \"using-tool\"])\n\n/**\n * States where \"how long has this been going on\" is a real question. `idle` has\n * nothing to time, and a fresh `error` epoch would count how long the failure\n * has been on screen — a number that means nothing.\n */\nconst TIMED = new Set<AgentStatusState>([\"thinking\", \"using-tool\", \"waiting\"])\n\nfunction labelFor(state: AgentStatusState, text: AgentStatusLabels): string {\n  switch (state) {\n    case \"thinking\":\n      return text.thinking\n    case \"using-tool\":\n      return text.usingTool\n    case \"waiting\":\n      return text.waiting\n    case \"error\":\n      return text.error\n    default:\n      return text.idle\n  }\n}\n\n/** m:ss, and h:mm:ss past the hour. */\nfunction formatElapsed(ms: number): string {\n  const total = Math.max(0, Math.floor(ms / 1000))\n  const seconds = total % 60\n  const minutes = Math.floor(total / 60) % 60\n  const hours = Math.floor(total / 3600)\n  const mm = hours > 0 ? String(minutes).padStart(2, \"0\") : String(minutes)\n  return `${hours > 0 ? `${hours}:` : \"\"}${mm}:${String(seconds).padStart(2, \"0\")}`\n}\n\n/* -------------------------------------------------------------------------- *\n * Component\n * -------------------------------------------------------------------------- */\n\nexport interface AgentStatusProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"children\"> {\n  /** What the run is doing right now. */\n  state: AgentStatusState\n  /**\n   * Tool being called. Rendered next to the label for `using-tool`, and next to\n   * the failure label for `error` — so a broken run can name the call that broke.\n   */\n  tool?: string\n  /** `inline` blends into a sentence; `pill` is a tinted capsule. @default \"inline\" */\n  variant?: \"inline\" | \"pill\"\n  /** Copy overrides; `usingTool` is a prefix, the tool name stays its own slot. */\n  labels?: Partial<AgentStatusLabels>\n  /** Run a clock for the current phase (thinking / using-tool / waiting). @default false */\n  showElapsed?: boolean\n  /**\n   * Epoch (ms) the current phase started at. Pass it when the run began before\n   * this component mounted — a remount then keeps the reading instead of\n   * restarting it. Without it the phase is timed from the moment it appears.\n   */\n  since?: number\n  /**\n   * Minimum time (ms) a phase stays on screen before the next one may replace\n   * it. Fast agents flip tools every few hundred ms and the label strobes; this\n   * queues the changes instead. Nothing is ever dropped — the last queued phase\n   * wins — and `error` jumps the queue. @default 0 (off)\n   */\n  minDwell?: number\n  /**\n   * Own a polite live region that announces every phase change. Turn it off in\n   * lists: one region per row talks over itself. @default true\n   */\n  announce?: boolean\n  /** Allow the active-state motion (ping halo / spinning arc). @default true */\n  pulse?: boolean\n}\n\nexport const AgentStatus = React.forwardRef<HTMLSpanElement, AgentStatusProps>(function AgentStatus(\n  {\n    state,\n    tool,\n    variant = \"inline\",\n    labels,\n    showElapsed = false,\n    since,\n    minDwell = 0,\n    announce = true,\n    pulse = true,\n    className,\n    style,\n    ...props\n  },\n  ref,\n) {\n  const text = { ...DEFAULT_LABELS, ...labels }\n\n  // A phase is the PAIR (state, tool), not the state alone: a second tool call\n  // is a new phase even though `state` never changed, so the clock has to\n  // restart and the dwell lock has to re-arm.\n  const phaseKey = `${state} ${tool ?? \"\"}`\n  const latched = minDwell > 0\n\n  const [held, setHeld] = React.useState({ state, tool, key: phaseKey })\n  // With the latch off the displayed phase is derived straight from props — the\n  // default path must not pay for a feature nobody switched on.\n  const shown = latched ? held : { state, tool, key: phaseKey }\n\n  // Epoch of the phase currently on screen. Render-phase state adjustment, not\n  // an effect: an effect would leave the first painted frame one phase behind.\n  const [phaseStart, setPhaseStart] = React.useState(() => Date.now())\n  const [prevKey, setPrevKey] = React.useState(shown.key)\n  if (prevKey !== shown.key) {\n    setPrevKey(shown.key)\n    setPhaseStart(Date.now())\n  }\n\n  React.useEffect(() => {\n    if (!latched) {\n      // Keep the mirror fresh while the latch is off, otherwise switching it on\n      // later would resurrect the phase that was current when it went off.\n      if (held.key !== phaseKey) setHeld({ state, tool, key: phaseKey })\n      return\n    }\n    if (held.key === phaseKey) return\n    // A failure is never held back; everything else waits out the rest of the\n    // dwell. Rescheduling on every change means the LAST phase of a burst wins\n    // and none of them is dropped.\n    const wait = state === \"error\" ? 0 : Math.max(0, minDwell - (Date.now() - phaseStart))\n    const timer = window.setTimeout(() => setHeld({ state, tool, key: phaseKey }), wait)\n    return () => window.clearTimeout(timer)\n  }, [latched, minDwell, phaseKey, phaseStart, state, tool, held.key])\n\n  const startedAt = since ?? phaseStart\n  const ticking = showElapsed && TIMED.has(shown.state)\n\n  // `null` until the first client tick: the server frame and the hydrating frame\n  // must agree, and Date.now() does not agree across the two — with `since`\n  // pointing into the past the digits would differ and tear hydration.\n  const [now, setNow] = React.useState<number | null>(null)\n\n  React.useEffect(() => {\n    if (!ticking) return\n    let timer = 0\n    const tick = () => {\n      const stamp = Date.now()\n      setNow(stamp)\n      // Re-derive from the epoch and re-align on the phase's own second\n      // boundary. setInterval(1000) drifts, and a throttled tab or a long task\n      // makes it skip a whole second in the display; this cannot lose time.\n      timer = window.setTimeout(tick, 1000 - ((stamp - startedAt) % 1000))\n    }\n    tick()\n    return () => window.clearTimeout(timer)\n  }, [ticking, startedAt])\n\n  const elapsedMs = now === null ? 0 : Math.max(0, now - startedAt)\n\n  const label = labelFor(shown.state, text)\n  const detail =\n    shown.state === \"using-tool\"\n      ? (shown.tool ?? text.unnamedTool)\n      : shown.state === \"error\"\n        ? shown.tool\n        : undefined\n  const announcement = detail\n    ? shown.state === \"error\"\n      ? `${label} — ${detail}`\n      : `${label} ${detail}`\n    : label\n\n  const animated = pulse && ACTIVE.has(shown.state)\n  const pinging = animated && shown.state === \"thinking\"\n  const spinning = animated && shown.state === \"using-tool\"\n  const ringed = shown.state === \"waiting\" || shown.state === \"error\"\n  const hollow = shown.state === \"idle\"\n\n  return (\n    <>\n      {/*\n        A permanently mounted region beats one that appears together with its\n        text: several screen readers skip a live region inserted in the same\n        frame as its content. The sentence carries no digits, so nothing\n        re-announces once per second.\n      */}\n      {announce ? (\n        <span aria-atomic=\"true\" aria-live=\"polite\" className=\"sr-only\" role=\"status\">\n          {announcement}\n        </span>\n      ) : null}\n\n      <span\n        // The sr-only region above is the accessible copy; leaving both exposed\n        // would read the same status twice in browse mode.\n        aria-hidden={announce || undefined}\n        className={cn(\n          \"inline-flex max-w-full min-w-0 items-center align-middle\",\n          variant === \"inline\" && \"gap-2 text-sm\",\n          variant === \"pill\" && [\n            \"gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium text-foreground\",\n            \"border-[color:color-mix(in_oklab,var(--agent-tone)_28%,transparent)]\",\n            \"bg-[color:color-mix(in_oklab,var(--agent-tone)_10%,var(--card))]\",\n          ],\n          className,\n        )}\n        data-state={shown.state}\n        ref={ref}\n        style={{ \"--agent-tone\": TONE[shown.state], ...style } as React.CSSProperties}\n        {...props}\n      >\n        <span aria-hidden=\"true\" className=\"relative flex size-2.5 shrink-0 items-center justify-center\">\n          {pinging ? (\n            <span className=\"absolute inset-0 animate-ping rounded-full bg-[color:var(--agent-tone)] opacity-60 motion-reduce:hidden\" />\n          ) : null}\n          {spinning ? (\n            // Stops under reduced motion but stays visible: a lopsided ring\n            // still reads as \"a tool is running\".\n            <span className=\"absolute inset-0 animate-spin rounded-full border border-[color:color-mix(in_oklab,var(--agent-tone)_25%,transparent)] border-t-[color:var(--agent-tone)] motion-reduce:animate-none\" />\n          ) : null}\n          {ringed ? (\n            <span className=\"absolute inset-0 rounded-full border border-[color:color-mix(in_oklab,var(--agent-tone)_35%,transparent)]\" />\n          ) : null}\n          <span\n            className={cn(\n              \"rounded-full\",\n              hollow\n                ? \"size-2 border border-[color:var(--agent-tone)]\"\n                : spinning\n                  ? \"size-1 bg-[color:var(--agent-tone)]\"\n                  : \"size-2 bg-[color:var(--agent-tone)]\",\n            )}\n          />\n        </span>\n\n        <span className=\"shrink-0 whitespace-nowrap\">{label}</span>\n\n        {detail ? (\n          // The one part that can be arbitrarily long: it truncates instead of\n          // widening whatever header this sits in.\n          <span className=\"min-w-0 truncate font-mono text-[0.9em] text-muted-foreground\" title={detail}>\n            {detail}\n          </span>\n        ) : null}\n\n        {ticking ? (\n          // aria-hidden even when `announce` is false: a ticking number inside\n          // anyone's live region interrupts the reader every second.\n          <span aria-hidden=\"true\" className=\"shrink-0 tabular-nums text-muted-foreground\">\n            {formatElapsed(elapsedMs)}\n          </span>\n        ) : null}\n      </span>\n    </>\n  )\n})\n\nAgentStatus.displayName = \"AgentStatus\"\n\nexport default AgentStatus\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}