{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-tool-views",
  "title": "Chat Tool Views",
  "description": "Shared renderers mapping real agent tool output onto AI Elements — used by every chat skin.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@mastra-chat-kit/code-block",
    "@mastra-chat-kit/image",
    "@mastra-chat-kit/chat-engine",
    "https://ai-sdk.dev/elements/api/registry/chain-of-thought.json",
    "https://ai-sdk.dev/elements/api/registry/file-tree.json",
    "https://ai-sdk.dev/elements/api/registry/inline-citation.json",
    "https://ai-sdk.dev/elements/api/registry/message.json",
    "https://ai-sdk.dev/elements/api/registry/plan.json",
    "https://ai-sdk.dev/elements/api/registry/sources.json",
    "https://ai-sdk.dev/elements/api/registry/terminal.json"
  ],
  "files": [
    {
      "path": "components/chat/tool-views.tsx",
      "content": "'use client';\n\nimport { CheckIcon, MessageCircleQuestionMarkIcon, TargetIcon, XIcon } from 'lucide-react';\nimport { type ComponentProps, useEffect, useRef, useState } from 'react';\nimport {\n  ChainOfThought,\n  ChainOfThoughtContent,\n  ChainOfThoughtHeader,\n  ChainOfThoughtStep,\n} from '@/components/ai-elements/chain-of-thought';\nimport { CodeBlock, CodeBlockCopyButton } from '@/components/ai-elements/code-block';\nimport { FileTree, FileTreeFile, FileTreeFolder } from '@/components/ai-elements/file-tree';\nimport { Image } from '@/components/ai-elements/image';\nimport {\n  InlineCitation,\n  InlineCitationCard,\n  InlineCitationCardBody,\n  InlineCitationCardTrigger,\n  InlineCitationCarousel,\n  InlineCitationCarouselContent,\n  InlineCitationCarouselItem,\n  InlineCitationSource,\n  InlineCitationText,\n} from '@/components/ai-elements/inline-citation';\nimport { MessageResponse } from '@/components/ai-elements/message';\nimport {\n  Plan,\n  PlanContent,\n  PlanDescription,\n  PlanHeader,\n  PlanTitle,\n} from '@/components/ai-elements/plan';\nimport { Source, Sources, SourcesContent, SourcesTrigger } from '@/components/ai-elements/sources';\nimport {\n  Terminal,\n  TerminalContent,\n  TerminalHeader,\n  TerminalTitle,\n} from '@/components/ai-elements/terminal';\nimport type { AgentControllerGoal, PendingSuspension } from '@/lib/agent-controller/events';\nimport { useGeneratedImage } from '@/lib/agent-controller/use-workspace';\nimport { cn } from '@/lib/utils';\n\n/**\n * Shared renderers that turn real agent TOOL output into the matching AI Elements,\n * shared by the chat view and the workbench panels so the two never drift.\n * Each takes the real data a tool produced — no static/example props.\n */\n\nexport type KnowledgeResult = { title: string; url: string; snippet?: string };\n\n/** Real `searchKnowledge` results → Sources list + inline citations. */\nexport function KnowledgeSources({ results }: { results: KnowledgeResult[] }) {\n  if (!results?.length) {\n    return null;\n  }\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <Sources>\n        <SourcesTrigger count={results.length} />\n        <SourcesContent>\n          {results.map((r) => (\n            <Source key={r.url} href={r.url} title={r.title} />\n          ))}\n        </SourcesContent>\n      </Sources>\n      <p className=\"text-muted-foreground text-sm\">\n        Citations:{' '}\n        {results.map((r, i) => (\n          <InlineCitation key={r.url}>\n            <InlineCitationText>[{i + 1}]</InlineCitationText>\n            <InlineCitationCard>\n              <InlineCitationCardTrigger sources={[r.url]} />\n              <InlineCitationCardBody>\n                <InlineCitationCarousel>\n                  <InlineCitationCarouselContent>\n                    <InlineCitationCarouselItem>\n                      <InlineCitationSource title={r.title} url={r.url} description={r.snippet} />\n                    </InlineCitationCarouselItem>\n                  </InlineCitationCarouselContent>\n                </InlineCitationCarousel>\n              </InlineCitationCardBody>\n            </InlineCitationCard>\n          </InlineCitation>\n        ))}\n      </p>\n    </div>\n  );\n}\n\n/**\n * Real `generateImage` output → the Image element. The agent returns only a small\n * `imageId` (the bytes never enter the model context); we fetch the base64 from\n * `/api/images/:id` and feed it to <Image>. A direct `base64` is also supported.\n */\nexport function GeneratedImage({\n  imageId,\n  base64,\n  mediaType = 'image/webp',\n  prompt,\n}: {\n  imageId?: string;\n  base64?: string;\n  mediaType?: string;\n  prompt?: string;\n}) {\n  // The id→bytes fetch lives in the engine (bd h27) so any skin rendering a\n  // generated image gets it without restating the /api/images contract.\n  const data = useGeneratedImage({ imageId, base64, mediaType });\n\n  if (!data?.base64) {\n    return <p className=\"text-muted-foreground text-xs\">Loading generated image…</p>;\n  }\n  return (\n    <Image\n      base64={data.base64}\n      mediaType={data.mediaType}\n      alt={prompt ?? 'Generated image'}\n      // Subtle pure-black/white outline (not a tinted neutral, which reads as dirt\n      // on the image edge); ring follows the rounded corners.\n      className=\"max-w-sm rounded-md ring-1 ring-black/10 ring-inset dark:ring-white/10\"\n    />\n  );\n}\n\n/** Real `submit_plan` tool args → the Plan element. */\nexport function PlanCard({ title, plan }: { title?: string; plan: string }) {\n  return (\n    <Plan>\n      <PlanHeader>\n        <PlanTitle>{title ?? 'Plan'}</PlanTitle>\n        <PlanDescription>Proposed by the agent</PlanDescription>\n      </PlanHeader>\n      <PlanContent>\n        <MessageResponse>{plan}</MessageResponse>\n      </PlanContent>\n    </Plan>\n  );\n}\n\n/**\n * Goal-run card: the objective the agent is iterating toward, its progress against the\n * run budget, the judge's verdict, and the latest judge reason. Driven by `goal_evaluation`\n * events (see reduceAgentControllerEvent); seeded optimistically by setGoal. `onClear` renders a\n * clear control. States: passed (judge complete) / paused (waiting for the user) / working.\n */\nexport function GoalCard({ goal, onClear }: { goal: AgentControllerGoal; onClear?: () => void }) {\n  const passed = goal.passed === true || goal.status === 'done';\n  const waiting = goal.waitingForUser === true;\n  const paused = !passed && (waiting || goal.status === 'paused' || goal.maxRunsReached === true);\n  const working = !passed && !paused;\n  const iteration = goal.iteration ?? 0;\n  const maxRuns = goal.maxRuns;\n  const pct =\n    maxRuns && maxRuns > 0 ? Math.min(100, Math.round((iteration / maxRuns) * 100)) : null;\n\n  const statusLabel = passed\n    ? 'Passed'\n    : waiting\n      ? 'Waiting for you'\n      : goal.maxRunsReached\n        ? 'Budget reached'\n        : goal.status === 'paused'\n          ? 'Paused'\n          : 'Working…';\n\n  return (\n    // Outer radius rounded-xl (12px); the icon chip is rounded-lg (8px) and the progress\n    // track rounded-full — concentric, so nested corners never fight.\n    <div className=\"my-3 rounded-xl border border-border bg-card p-4 text-pretty shadow-[var(--shadow-float)]\">\n      <div className=\"flex items-start gap-3\">\n        <span\n          className={cn(\n            'flex size-8 shrink-0 items-center justify-center rounded-lg',\n            passed\n              ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'\n              : paused\n                ? 'bg-amber-500/10 text-amber-600 dark:text-amber-400'\n                : 'bg-primary/10 text-primary',\n          )}\n        >\n          {passed ? <CheckIcon className=\"size-4\" /> : <TargetIcon className=\"size-4\" />}\n        </span>\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex items-center justify-between gap-2\">\n            <p className=\"font-medium text-muted-foreground text-xs uppercase tracking-wide\">\n              Goal\n            </p>\n            <span\n              className={cn(\n                'shrink-0 rounded-full px-2 py-0.5 font-medium text-[11px]',\n                passed\n                  ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'\n                  : paused\n                    ? 'bg-amber-500/10 text-amber-600 dark:text-amber-400'\n                    : 'animate-pulse bg-muted text-muted-foreground',\n              )}\n            >\n              {statusLabel}\n            </span>\n          </div>\n          <p className=\"mt-1 text-pretty text-sm\">{goal.objective}</p>\n        </div>\n        {onClear && (\n          // 40×40 hit area via padding around a size-4 glyph; scale-on-press feedback.\n          <button\n            type=\"button\"\n            onClick={onClear}\n            aria-label=\"Clear goal\"\n            className=\"-m-2 flex size-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-foreground active:scale-[0.96]\"\n          >\n            <XIcon className=\"size-4\" />\n          </button>\n        )}\n      </div>\n\n      {(pct !== null || iteration > 0) && (\n        <div className=\"mt-3 flex items-center gap-2\">\n          <div className=\"h-1.5 flex-1 overflow-hidden rounded-full bg-muted\">\n            <div\n              className={cn(\n                'h-full rounded-full transition-[width] duration-500',\n                passed ? 'bg-emerald-500' : paused ? 'bg-amber-500' : 'bg-primary',\n              )}\n              style={{ width: `${pct ?? (working ? 100 : 0)}%` }}\n            />\n          </div>\n          {/* Dynamically updating counter → tabular-nums so the bar never shifts. */}\n          <span className=\"shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums\">\n            {iteration}\n            {maxRuns ? ` / ${maxRuns}` : ''}\n          </span>\n        </div>\n      )}\n\n      {goal.reason && (\n        <p className=\"mt-2 text-pretty text-muted-foreground text-xs\">{goal.reason}</p>\n      )}\n    </div>\n  );\n}\n\n/**\n * `ask_user` prompt: the agent paused to ask a clarifying question and the run is\n * suspended awaiting the answer (see the `tool_suspended` reducer). Renders one of\n * three shapes from the suspend payload — free-text (a textarea), single-select\n * (choice buttons that answer on click), or multi-select (toggle chips + Send). The\n * answer resumes the suspended tool (POST /api/agent-controller/answer) and the run continues\n * on the still-open SSE. Focuses the input on mount so answering is immediate.\n */\nexport function AskUserPrompt({\n  suspension,\n  onAnswer,\n}: {\n  suspension: PendingSuspension;\n  onAnswer: (answer: string | string[], toolCallId?: string) => void;\n}) {\n  const { question, options, selectionMode, toolCallId } = suspension;\n  const hasOptions = Array.isArray(options) && options.length > 0;\n  const isMulti = selectionMode === 'multi_select';\n  const [text, setText] = useState('');\n  const [picked, setPicked] = useState<string[]>([]);\n  const inputRef = useRef<HTMLTextAreaElement>(null);\n\n  // Focus the free-text input once when the prompt appears (ref+effect, not\n  // autoFocus, so it's a one-shot and doesn't fight re-renders).\n  useEffect(() => {\n    if (!hasOptions) inputRef.current?.focus();\n  }, [hasOptions]);\n\n  const submitText = () => {\n    const v = text.trim();\n    if (v) onAnswer(v, toolCallId);\n  };\n  const toggle = (label: string) =>\n    setPicked((cur) => (cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label]));\n\n  return (\n    // Concentric radii: outer rounded-xl (12px), inner controls rounded-lg (8px) — matches GoalCard.\n    <div className=\"my-3 rounded-xl border border-border bg-card p-4 text-pretty shadow-[var(--shadow-float)]\">\n      <div className=\"flex items-start gap-3\">\n        <span className=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary\">\n          <MessageCircleQuestionMarkIcon className=\"size-4\" />\n        </span>\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"font-medium text-muted-foreground text-xs uppercase tracking-wide\">\n            Question\n          </p>\n          <p className=\"mt-1 text-pretty text-sm\">{question}</p>\n        </div>\n      </div>\n\n      {/* Answer controls, indented under the question text (32px chip + 12px gap). */}\n      <div className=\"mt-3 pl-11\">\n        {hasOptions ? (\n          isMulti ? (\n            <div className=\"flex flex-col gap-2\">\n              <div className=\"flex flex-wrap gap-2\">\n                {options.map((o) => {\n                  const on = picked.includes(o.label);\n                  return (\n                    <button\n                      key={o.label}\n                      type=\"button\"\n                      onClick={() => toggle(o.label)}\n                      title={o.description}\n                      aria-pressed={on}\n                      className={cn(\n                        'rounded-lg border px-3 py-1.5 text-sm transition-[background-color,border-color,color,scale] active:scale-[0.96]',\n                        on\n                          ? 'border-primary bg-primary/10 text-foreground'\n                          : 'border-border bg-background text-muted-foreground hover:text-foreground',\n                      )}\n                    >\n                      {o.label}\n                    </button>\n                  );\n                })}\n              </div>\n              <div className=\"flex justify-end\">\n                <button\n                  type=\"button\"\n                  onClick={() => picked.length && onAnswer(picked, toolCallId)}\n                  disabled={picked.length === 0}\n                  className=\"rounded-lg bg-primary px-3 py-1.5 font-medium text-primary-foreground text-sm transition-[opacity,scale] active:scale-[0.96] disabled:opacity-50\"\n                >\n                  Send{picked.length ? ` (${picked.length})` : ''}\n                </button>\n              </div>\n            </div>\n          ) : (\n            // single-select: clicking a choice answers immediately.\n            <div className=\"flex flex-wrap gap-2\">\n              {options.map((o) => (\n                <button\n                  key={o.label}\n                  type=\"button\"\n                  onClick={() => onAnswer(o.label, toolCallId)}\n                  title={o.description}\n                  className=\"rounded-lg border border-border bg-background px-3 py-1.5 text-foreground text-sm transition-[background-color,border-color,scale] hover:border-primary hover:bg-primary/5 active:scale-[0.96]\"\n                >\n                  {o.label}\n                </button>\n              ))}\n            </div>\n          )\n        ) : (\n          // free-text: Enter sends, Shift+Enter for a newline.\n          <div className=\"flex items-end gap-2\">\n            <textarea\n              ref={inputRef}\n              value={text}\n              onChange={(e) => setText(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === 'Enter' && !e.shiftKey) {\n                  e.preventDefault();\n                  submitText();\n                }\n              }}\n              rows={1}\n              placeholder=\"Type your answer…\"\n              className=\"min-h-9 flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            />\n            <button\n              type=\"button\"\n              onClick={submitText}\n              disabled={!text.trim()}\n              className=\"shrink-0 rounded-lg bg-primary px-3 py-2 font-medium text-primary-foreground text-sm transition-[opacity,scale] active:scale-[0.96] disabled:opacity-50\"\n            >\n              Send\n            </button>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\n/** Real step sequence (the agent's actual tool calls) → ChainOfThought. */\nexport function StepTrace({ steps }: { steps: string[] }) {\n  if (!steps?.length) {\n    return null;\n  }\n  return (\n    <ChainOfThought defaultOpen>\n      <ChainOfThoughtHeader>Steps</ChainOfThoughtHeader>\n      <ChainOfThoughtContent>\n        {steps.map((label, i) => (\n          // biome-ignore lint/suspicious/noArrayIndexKey: step list is render-stable per message\n          <ChainOfThoughtStep key={`${i}-${label}`} label={label} status=\"complete\" />\n        ))}\n      </ChainOfThoughtContent>\n    </ChainOfThought>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Code Agent — Mastra workspace tool output → the Code AI Elements.\n// The workspace tools (mastra_workspace_*) return formatted TEXT, so these\n// renderers parse that text into the real File Tree / Terminal / Code Block\n// elements. See packages/server/src/mastra/agents/code.ts.\n// ---------------------------------------------------------------------------\n\nconst LANG_BY_EXT: Record<string, string> = {\n  ts: 'ts',\n  tsx: 'tsx',\n  js: 'js',\n  jsx: 'jsx',\n  mjs: 'js',\n  cjs: 'js',\n  json: 'json',\n  md: 'md',\n  py: 'python',\n  rb: 'ruby',\n  go: 'go',\n  rs: 'rust',\n  sh: 'bash',\n  bash: 'bash',\n  css: 'css',\n  html: 'html',\n  yml: 'yaml',\n  yaml: 'yaml',\n  toml: 'toml',\n  sql: 'sql',\n};\n\ntype CodeLanguage = ComponentProps<typeof CodeBlock>['language'];\n\nfunction languageFromPath(path?: string): CodeLanguage {\n  const ext = path?.split('.').pop()?.toLowerCase() ?? '';\n  return (LANG_BY_EXT[ext] ?? 'text') as CodeLanguage;\n}\n\n/** read_file output is `\"<name> (<n> bytes)\\n   1→<code>\"` — strip the header + line-number gutter. */\nfunction cleanReadFile(output: string): string {\n  const lines = output.split('\\n');\n  const body = /^.+ \\(\\d+ bytes\\)\\s*$/.test(lines[0] ?? '') ? lines.slice(1) : lines;\n  return body.map((l) => l.replace(/^\\s*\\d+→/, '')).join('\\n');\n}\n\ntype TreeNode = { name: string; path: string; children: TreeNode[] };\n\n/** Parse the tab-indented `list_files` tree text into a nested structure. */\nfunction parseFileTree(text: string): TreeNode[] {\n  const lines = text\n    .split('\\n')\n    .filter((l) => l.length > 0 && l.trim() !== '.' && !/^\\d+ director(y|ies),/.test(l.trim()));\n  const roots: TreeNode[] = [];\n  const stack: { depth: number; node: TreeNode }[] = [];\n  for (const line of lines) {\n    const depth = line.match(/^\\t*/)?.[0].length ?? 0;\n    const name = line.replace(/^\\t+/, '').trim();\n    if (!name) {\n      continue;\n    }\n    while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {\n      stack.pop();\n    }\n    const parent = stack.at(-1)?.node;\n    const path = parent ? `${parent.path}/${name}` : name;\n    const node: TreeNode = { name, path, children: [] };\n    if (parent) {\n      parent.children.push(node);\n    } else {\n      roots.push(node);\n    }\n    stack.push({ depth, node });\n  }\n  return roots;\n}\n\nfunction collectFolderPaths(nodes: TreeNode[], acc: string[] = []): string[] {\n  for (const n of nodes) {\n    if (n.children.length > 0) {\n      acc.push(n.path);\n      collectFolderPaths(n.children, acc);\n    }\n  }\n  return acc;\n}\n\nfunction renderTreeNodes(nodes: TreeNode[]) {\n  return nodes.map((n) =>\n    n.children.length > 0 ? (\n      <FileTreeFolder key={n.path} name={n.name} path={n.path}>\n        {renderTreeNodes(n.children)}\n      </FileTreeFolder>\n    ) : (\n      <FileTreeFile key={n.path} name={n.name} path={n.path} />\n    ),\n  );\n}\n\n/** `list_files` tree text → the File Tree element. */\nexport function WorkspaceFileTree({ tree }: { tree: string }) {\n  const nodes = parseFileTree(tree);\n  if (nodes.length === 0) {\n    return <p className=\"text-muted-foreground text-xs\">Empty workspace.</p>;\n  }\n  return (\n    <FileTree defaultExpanded={new Set(collectFolderPaths(nodes))}>\n      {renderTreeNodes(nodes)}\n    </FileTree>\n  );\n}\n\n/** `execute_command` → the Terminal element (renders `output` via context). */\nexport function WorkspaceTerminal({ title, output }: { title: string; output: string }) {\n  return (\n    <Terminal output={output || '(no output)'}>\n      <TerminalHeader>\n        <TerminalTitle>{title}</TerminalTitle>\n      </TerminalHeader>\n      <TerminalContent />\n    </Terminal>\n  );\n}\n\n/** read/write/edit file → the Code Block element. */\nexport function WorkspaceCodeBlock({ path, code }: { path?: string; code: string }) {\n  return (\n    <CodeBlock code={code} language={languageFromPath(path)} showLineNumbers>\n      {path && <span className=\"px-1 font-mono text-muted-foreground text-xs\">{path}</span>}\n      <CodeBlockCopyButton />\n    </CodeBlock>\n  );\n}\n\n/**\n * Dispatch a Mastra workspace tool call to its Code element. Returns null for\n * tools without a dedicated element (mkdir/delete/stat) so the caller falls back\n * to the generic <Tool> view. `input`/`output` are whatever the tool produced.\n */\nexport function WorkspaceTool({\n  toolName,\n  input,\n  output,\n}: {\n  toolName: string;\n  input: unknown;\n  output: unknown;\n}) {\n  const suffix = toolName.replace('mastra_workspace_', '');\n  const inp = (input ?? {}) as {\n    path?: string;\n    command?: string;\n    args?: string[];\n    content?: string;\n  };\n  const out =\n    typeof output === 'string' ? output : output != null ? JSON.stringify(output, null, 2) : '';\n\n  if (suffix === 'list_files') {\n    return out ? <WorkspaceFileTree tree={out} /> : null;\n  }\n  if (suffix === 'execute_command') {\n    const title = [inp.command, ...(inp.args ?? [])].filter(Boolean).join(' ') || 'command';\n    return <WorkspaceTerminal title={title} output={out} />;\n  }\n  if (suffix === 'read_file') {\n    return out ? <WorkspaceCodeBlock path={inp.path} code={cleanReadFile(out)} /> : null;\n  }\n  if (suffix === 'write_file' || suffix === 'edit_file') {\n    const code = typeof inp.content === 'string' ? inp.content : cleanReadFile(out);\n    return code ? <WorkspaceCodeBlock path={inp.path} code={code} /> : null;\n  }\n  if (suffix === 'grep') {\n    return <WorkspaceTerminal title={`grep ${inp.path ?? ''}`.trim()} output={out} />;\n  }\n  return null;\n}\n\n/** True for any Mastra workspace tool part (`tool-mastra_workspace_*`). */\nexport function isWorkspaceTool(toolName?: string): boolean {\n  return typeof toolName === 'string' && toolName.startsWith('mastra_workspace_');\n}\n\nconst WORKSPACE_VIEW_SUFFIXES = new Set([\n  'list_files',\n  'execute_command',\n  'read_file',\n  'write_file',\n  'edit_file',\n  'grep',\n]);\n\n/** True only for workspace tools that have a dedicated Code element renderer. */\nexport function hasWorkspaceView(toolName?: string): boolean {\n  if (!isWorkspaceTool(toolName)) {\n    return false;\n  }\n  return WORKSPACE_VIEW_SUFFIXES.has((toolName as string).replace('mastra_workspace_', ''));\n}\n",
      "type": "registry:component",
      "target": "components/chat/tool-views.tsx"
    }
  ],
  "type": "registry:component"
}