{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "terminal",
  "type": "registry:block",
  "title": "Terminal",
  "description": "Show command-line output and logs.",
  "dependencies": [
    "ansi-to-react",
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "button",
    "collapsible"
  ],
  "files": [
    {
      "path": "components/tool-ui/shared/contract.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/contract.ts",
      "content": "import { z } from \"zod\";\nimport { parseWithSchema, safeParseWithSchema } from \"./parse\";\n\nexport interface ToolUiContract<T> {\n  schema: z.ZodType<T>;\n  parse: (input: unknown) => T;\n  safeParse: (input: unknown) => T | null;\n}\n\nexport function defineToolUiContract<T>(\n  componentName: string,\n  schema: z.ZodType<T>,\n): ToolUiContract<T> {\n  return {\n    schema,\n    parse: (input: unknown) => parseWithSchema(schema, input, componentName),\n    safeParse: (input: unknown) => safeParseWithSchema(schema, input),\n  };\n}\n"
    },
    {
      "path": "components/tool-ui/shared/parse.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/parse.ts",
      "content": "import { z } from \"zod\";\n\nfunction formatZodPath(path: Array<string | number | symbol>): string {\n  if (path.length === 0) return \"root\";\n  return path\n    .map((segment) =>\n      typeof segment === \"number\" ? `[${segment}]` : String(segment),\n    )\n    .join(\".\");\n}\n\n/**\n * Format Zod errors into a compact `path: message` string.\n */\nexport function formatZodError(error: z.ZodError): string {\n  const parts = error.issues.map((issue) => {\n    const path = formatZodPath(issue.path);\n    return `${path}: ${issue.message}`;\n  });\n\n  return Array.from(new Set(parts)).join(\"; \");\n}\n\n/**\n * Parse unknown input and throw a readable error.\n */\nexport function parseWithSchema<T>(\n  schema: z.ZodType<T>,\n  input: unknown,\n  name: string,\n): T {\n  const res = schema.safeParse(input);\n  if (!res.success) {\n    throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);\n  }\n  return res.data;\n}\n\n/**\n * Parse unknown input, returning `null` instead of throwing on failure.\n *\n * Use this in assistant-ui `render` functions where `args` stream in\n * incrementally and may be incomplete until the tool call finishes.\n */\nexport function safeParseWithSchema<T>(\n  schema: z.ZodType<T>,\n  input: unknown,\n): T | null {\n  const res = schema.safeParse(input);\n  return res.success ? res.data : null;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/schema.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/schema.ts",
      "content": "import { z } from \"zod\";\nimport type { ReactNode } from \"react\";\n\n/**\n * Tool UI conventions:\n * - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).\n * - Schema: `SerializableXSchema`\n * - Parser: `parseSerializableX(input: unknown)` (throws on invalid)\n * - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)\n * - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions\n * - Root attrs: `data-tool-ui-id` + `data-slot`\n */\n\n/**\n * Schema for tool UI identity.\n *\n * Every tool UI should have a unique identifier that:\n * - Is stable across re-renders\n * - Is meaningful (not auto-generated)\n * - Is unique within the conversation\n *\n * Format recommendation: `{component-type}-{semantic-identifier}`\n * Examples: \"data-table-expenses-q3\", \"option-list-deploy-target\"\n */\nexport const ToolUIIdSchema = z.string().min(1);\n\nexport type ToolUIId = z.infer<typeof ToolUIIdSchema>;\n\n/**\n * Primary role of a Tool UI surface in a chat context.\n */\nexport const ToolUIRoleSchema = z.enum([\n  \"information\",\n  \"decision\",\n  \"control\",\n  \"state\",\n  \"composite\",\n]);\n\nexport type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;\n\nexport const ToolUIReceiptOutcomeSchema = z.enum([\n  \"success\",\n  \"partial\",\n  \"failed\",\n  \"cancelled\",\n]);\n\nexport type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;\n\n/**\n * Optional receipt metadata: a durable summary of an outcome.\n */\nexport const ToolUIReceiptSchema = z.object({\n  outcome: ToolUIReceiptOutcomeSchema,\n  summary: z.string().min(1),\n  identifiers: z.record(z.string(), z.string()).optional(),\n  at: z.string().datetime(),\n});\n\nexport type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;\n\n/**\n * Base schema for Tool UI payloads (id + optional role/receipt).\n */\nexport const ToolUISurfaceSchema = z.object({\n  id: ToolUIIdSchema,\n  role: ToolUIRoleSchema.optional(),\n  receipt: ToolUIReceiptSchema.optional(),\n});\n\nexport type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;\n\nexport const ActionSchema = z.object({\n  id: z.string().min(1),\n  label: z.string().min(1),\n  /**\n   * Canonical narration the assistant can use after this action is taken.\n   *\n   * Example: \"I exported the table as CSV.\" / \"I opened the link in a new tab.\"\n   */\n  sentence: z.string().optional(),\n  confirmLabel: z.string().optional(),\n  variant: z\n    .enum([\"default\", \"destructive\", \"secondary\", \"ghost\", \"outline\"])\n    .optional(),\n  icon: z.custom<ReactNode>().optional(),\n  loading: z.boolean().optional(),\n  disabled: z.boolean().optional(),\n  shortcut: z.string().optional(),\n});\n\nexport type Action = z.infer<typeof ActionSchema>;\nexport type LocalAction = Action;\nexport type DecisionAction = Action;\n\nexport const DecisionResultSchema = z.object({\n  kind: z.literal(\"decision\"),\n  version: z.literal(1),\n  decisionId: z.string().min(1),\n  actionId: z.string().min(1),\n  actionLabel: z.string().min(1),\n  at: z.string().datetime(),\n  payload: z.record(z.string(), z.unknown()).optional(),\n});\n\nexport type DecisionResult<\n  TPayload extends Record<string, unknown> = Record<string, unknown>,\n> = Omit<z.infer<typeof DecisionResultSchema>, \"payload\"> & {\n  payload?: TPayload;\n};\n\nexport function createDecisionResult<\n  TPayload extends Record<string, unknown> = Record<string, unknown>,\n>(args: {\n  decisionId: string;\n  action: { id: string; label: string };\n  payload?: TPayload;\n}): DecisionResult<TPayload> {\n  return {\n    kind: \"decision\",\n    version: 1,\n    decisionId: args.decisionId,\n    actionId: args.action.id,\n    actionLabel: args.action.label,\n    at: new Date().toISOString(),\n    payload: args.payload,\n  };\n}\n\nexport const ActionButtonsPropsSchema = z.object({\n  actions: z.array(ActionSchema).min(1),\n  align: z.enum([\"left\", \"center\", \"right\"]).optional(),\n  confirmTimeout: z.number().positive().optional(),\n  className: z.string().optional(),\n});\n\nexport const SerializableActionSchema = ActionSchema.omit({ icon: true });\nexport const SerializableActionsSchema = ActionButtonsPropsSchema.extend({\n  actions: z.array(SerializableActionSchema),\n}).omit({ className: true });\n\nexport interface ActionsConfig {\n  items: Action[];\n  align?: \"left\" | \"center\" | \"right\";\n  confirmTimeout?: number;\n}\n\nexport const SerializableActionsConfigSchema = z.object({\n  items: z.array(SerializableActionSchema).min(1),\n  align: z.enum([\"left\", \"center\", \"right\"]).optional(),\n  confirmTimeout: z.number().positive().optional(),\n});\n\nexport type SerializableActionsConfig = z.infer<\n  typeof SerializableActionsConfigSchema\n>;\n\nexport type SerializableAction = z.infer<typeof SerializableActionSchema>;\n"
    },
    {
      "path": "components/tool-ui/shared/use-copy-to-clipboard.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/use-copy-to-clipboard.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useState } from \"react\";\n\nfunction fallbackCopyToClipboard(text: string): boolean {\n  const textArea = document.createElement(\"textarea\");\n  try {\n    textArea.value = text;\n    textArea.setAttribute(\"readonly\", \"\");\n    textArea.style.position = \"fixed\";\n    textArea.style.top = \"-9999px\";\n    textArea.style.left = \"-9999px\";\n    document.body.appendChild(textArea);\n    textArea.select();\n    return document.execCommand(\"copy\");\n  } catch {\n    return false;\n  } finally {\n    if (textArea.parentNode) {\n      textArea.parentNode.removeChild(textArea);\n    }\n  }\n}\n\nexport function useCopyToClipboard(options?: { resetAfterMs?: number }): {\n  copiedId: string | null;\n  copy: (text: string, id?: string) => Promise<boolean>;\n} {\n  const resetAfterMs = options?.resetAfterMs ?? 2000;\n  const [copiedId, setCopiedId] = useState<string | null>(null);\n\n  const copy = useCallback(async (text: string, id: string = \"default\") => {\n    let ok = false;\n    try {\n      if (navigator.clipboard?.writeText) {\n        await navigator.clipboard.writeText(text);\n        ok = true;\n      } else {\n        ok = fallbackCopyToClipboard(text);\n      }\n    } catch {\n      ok = fallbackCopyToClipboard(text);\n    }\n\n    if (ok) {\n      setCopiedId(id);\n    }\n\n    return ok;\n  }, []);\n\n  useEffect(() => {\n    if (!copiedId) return;\n    const timeout = setTimeout(() => setCopiedId(null), resetAfterMs);\n    return () => clearTimeout(timeout);\n  }, [copiedId, resetAfterMs]);\n\n  return { copiedId, copy };\n}\n"
    },
    {
      "path": "components/tool-ui/terminal/_adapter.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/terminal/_adapter.tsx",
      "content": "/**\n * Adapter: UI and utility re-exports for copy-standalone portability.\n *\n * When copying this component to another project, update these imports\n * to match your project's paths:\n *\n *   cn          → Your Tailwind merge utility (e.g., \"@/lib/utils\", \"~/lib/cn\")\n *   Button      → shadcn/ui Button\n *   Collapsible → shadcn/ui Collapsible\n */\n\nexport { cn } from \"@/lib/utils\";\nexport { Button } from \"@/components/ui/button\";\nexport { Collapsible, CollapsibleTrigger } from \"@/components/ui/collapsible\";\n"
    },
    {
      "path": "components/tool-ui/terminal/index.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/terminal/index.tsx",
      "content": "export { Terminal } from \"./terminal\";\nexport type { TerminalProps, SerializableTerminal } from \"./schema\";\n"
    },
    {
      "path": "components/tool-ui/terminal/README.md",
      "type": "registry:file",
      "target": "components/tool-ui/terminal/README.md",
      "content": "# Terminal\n\nImplementation for the \"terminal\" Tool UI surface.\n\n## Files\n\n- public exports: components/tool-ui/terminal/index.tsx\n- serializable schema + parse helpers: components/tool-ui/terminal/schema.ts\n\n## Companion assets\n\n- Docs page: app/docs/terminal/content.mdx\n- Preset payload: lib/presets/terminal.ts\n\n## Quick check\n\nRun this after edits:\n\npnpm test\n"
    },
    {
      "path": "components/tool-ui/terminal/schema.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/terminal/schema.ts",
      "content": "import { z } from \"zod\";\nimport { defineToolUiContract } from \"../shared/contract\";\nimport {\n  ToolUIIdSchema,\n  ToolUIReceiptSchema,\n  ToolUIRoleSchema,\n} from \"../shared/schema\";\n\nexport const TerminalPropsSchema = z.object({\n  id: ToolUIIdSchema,\n  role: ToolUIRoleSchema.optional(),\n  receipt: ToolUIReceiptSchema.optional(),\n  command: z.string(),\n  stdout: z.string().optional(),\n  stderr: z.string().optional(),\n  exitCode: z.number().int().min(0),\n  durationMs: z.number().optional(),\n  cwd: z.string().optional(),\n  truncated: z.boolean().optional(),\n  maxCollapsedLines: z.number().min(1).optional(),\n  className: z.string().optional(),\n});\n\nexport type TerminalProps = z.infer<typeof TerminalPropsSchema>;\n\nexport const SerializableTerminalSchema = TerminalPropsSchema.omit({\n  className: true,\n});\n\nexport type SerializableTerminal = z.infer<typeof SerializableTerminalSchema>;\n\nconst SerializableTerminalSchemaContract = defineToolUiContract(\n  \"Terminal\",\n  SerializableTerminalSchema,\n);\n\nexport const parseSerializableTerminal: (\n  input: unknown,\n) => SerializableTerminal = SerializableTerminalSchemaContract.parse;\n\nexport const safeParseSerializableTerminal: (\n  input: unknown,\n) => SerializableTerminal | null = SerializableTerminalSchemaContract.safeParse;\n"
    },
    {
      "path": "components/tool-ui/terminal/terminal.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/terminal/terminal.tsx",
      "content": "\"use client\";\n\nimport { useState, useCallback } from \"react\";\nimport Ansi from \"ansi-to-react\";\nimport {\n  Copy,\n  Check,\n  ChevronDown,\n  ChevronUp,\n  Terminal as TerminalIcon,\n} from \"lucide-react\";\nimport type { TerminalProps } from \"./schema\";\nimport { useCopyToClipboard } from \"../shared/use-copy-to-clipboard\";\n\nimport { Button, Collapsible, CollapsibleTrigger } from \"./_adapter\";\nimport { cn } from \"./_adapter\";\n\nconst COPY_ID = \"terminal-output\";\n\ntype TerminalControlledProps = {\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n};\n\ntype TerminalRootProps = TerminalProps & TerminalControlledProps;\n\ntype TerminalHeaderProps = Pick<\n  TerminalProps,\n  \"command\" | \"cwd\" | \"exitCode\"\n> & {\n  formattedDuration: string | null;\n  hasOutput: boolean;\n  copiedId: string | null;\n  onCopy: () => void;\n};\n\ntype TerminalOutputProps = Pick<\n  TerminalProps,\n  \"stdout\" | \"stderr\" | \"truncated\"\n> & {\n  isCollapsed: boolean;\n  shouldCollapse: boolean;\n  lineCount: number;\n  onToggleCollapse: () => void;\n};\n\nfunction formatDuration(durationMs?: number): string | null {\n  if (durationMs == null) return null;\n  if (durationMs < 1000) return `${Math.round(durationMs)}ms`;\n  return `${(durationMs / 1000).toFixed(1)}s`;\n}\n\nfunction countOutputLines(output: string): number {\n  const trimmedTrailingNewlines = output.replace(/\\n+$/, \"\");\n  if (!trimmedTrailingNewlines) return 0;\n  return trimmedTrailingNewlines.split(\"\\n\").length;\n}\n\nfunction TerminalHeader({\n  command,\n  cwd,\n  exitCode,\n  formattedDuration,\n  hasOutput,\n  copiedId,\n  onCopy,\n}: TerminalHeaderProps) {\n  return (\n    <div className=\"bg-card flex items-center justify-between border-b px-4 py-2\">\n      <div className=\"flex items-center gap-2 overflow-hidden\">\n        <TerminalIcon className=\"text-muted-foreground h-4 w-4 shrink-0\" />\n        <code className=\"text-foreground truncate font-mono text-xs\">\n          {cwd && <span className=\"text-muted-foreground\">{cwd}$ </span>}\n          {command}\n        </code>\n      </div>\n      <div className=\"flex items-center gap-3\">\n        {formattedDuration && (\n          <span className=\"text-muted-foreground font-mono text-sm tabular-nums\">\n            {formattedDuration}\n          </span>\n        )}\n        <span\n          className={cn(\n            \"font-mono text-sm tabular-nums\",\n            exitCode === 0\n              ? \"text-muted-foreground\"\n              : \"text-red-600 dark:text-red-400\",\n          )}\n        >\n          {exitCode}\n        </span>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={onCopy}\n          disabled={!hasOutput}\n          className=\"h-7 w-7 p-0\"\n          aria-label={\n            !hasOutput\n              ? \"No output to copy\"\n              : copiedId === COPY_ID\n                ? \"Copied\"\n                : \"Copy output\"\n          }\n        >\n          {hasOutput && copiedId === COPY_ID ? (\n            <Check className=\"h-4 w-4 text-green-700 dark:text-green-400\" />\n          ) : (\n            <Copy className=\"text-muted-foreground h-4 w-4\" />\n          )}\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nfunction TerminalOutput({\n  stdout,\n  stderr,\n  truncated,\n  isCollapsed,\n  shouldCollapse,\n  lineCount,\n  onToggleCollapse,\n}: TerminalOutputProps) {\n  return (\n    <Collapsible open={!isCollapsed}>\n      <div\n        className={cn(\n          \"relative font-mono text-sm\",\n          isCollapsed && \"max-h-[200px] overflow-hidden\",\n        )}\n      >\n        <div className=\"overflow-x-auto p-4\">\n          {stdout && (\n            <div className=\"text-foreground whitespace-pre\">\n              <Ansi>{stdout}</Ansi>\n            </div>\n          )}\n          {stderr && (\n            <div className=\"mt-2 whitespace-pre text-red-500 dark:text-red-400\">\n              <Ansi>{stderr}</Ansi>\n            </div>\n          )}\n          {truncated && (\n            <div className=\"text-muted-foreground mt-2 text-xs italic\">\n              Output truncated...\n            </div>\n          )}\n        </div>\n\n        {isCollapsed && (\n          <div className=\"from-card absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t to-transparent\" />\n        )}\n      </div>\n\n      {shouldCollapse && (\n        <CollapsibleTrigger asChild>\n          <Button\n            variant=\"ghost\"\n            onClick={onToggleCollapse}\n            className=\"text-muted-foreground w-full rounded-none border-t font-normal\"\n          >\n            {isCollapsed ? (\n              <>\n                <ChevronDown className=\"mr-1 size-4\" />\n                Show all {lineCount} lines\n              </>\n            ) : (\n              <>\n                <ChevronUp className=\"mr-1 size-4\" />\n                Collapse\n              </>\n            )}\n          </Button>\n        </CollapsibleTrigger>\n      )}\n    </Collapsible>\n  );\n}\n\nfunction TerminalEmpty() {\n  return (\n    <div className=\"text-muted-foreground px-4 py-3 font-mono text-sm italic\">\n      No output\n    </div>\n  );\n}\n\nfunction TerminalRoot({\n  id,\n  command,\n  stdout,\n  stderr,\n  exitCode,\n  durationMs,\n  cwd,\n  truncated,\n  maxCollapsedLines,\n  className,\n  expanded,\n  defaultExpanded = false,\n  onExpandedChange,\n}: TerminalRootProps) {\n  const [uncontrolledExpanded, setUncontrolledExpanded] =\n    useState(defaultExpanded);\n  const { copiedId, copy } = useCopyToClipboard();\n\n  const isExpanded = expanded ?? uncontrolledExpanded;\n  const hasOutput = Boolean(stdout || stderr);\n  const fullOutput = [stdout, stderr].filter(Boolean).join(\"\\n\");\n  const formattedDuration = formatDuration(durationMs);\n  const lineCount = countOutputLines(fullOutput);\n  const shouldCollapse =\n    maxCollapsedLines !== undefined && lineCount > maxCollapsedLines;\n  const isCollapsed = shouldCollapse && !isExpanded;\n\n  const setExpanded = useCallback(\n    (nextExpanded: boolean) => {\n      if (expanded === undefined) {\n        setUncontrolledExpanded(nextExpanded);\n      }\n      onExpandedChange?.(nextExpanded);\n    },\n    [expanded, onExpandedChange],\n  );\n\n  const handleCopy = useCallback(() => {\n    if (!hasOutput) return;\n    copy(fullOutput, COPY_ID);\n  }, [hasOutput, fullOutput, copy]);\n\n  return (\n    <div\n      className={cn(\n        \"@container flex w-full min-w-80 flex-col gap-3\",\n        className,\n      )}\n      data-tool-ui-id={id}\n      data-slot=\"terminal\"\n    >\n      <div className=\"border-border bg-card overflow-hidden rounded-lg border shadow-xs\">\n        <TerminalHeader\n          command={command}\n          cwd={cwd}\n          exitCode={exitCode}\n          formattedDuration={formattedDuration}\n          hasOutput={hasOutput}\n          copiedId={copiedId}\n          onCopy={handleCopy}\n        />\n\n        {hasOutput && (\n          <TerminalOutput\n            stdout={stdout}\n            stderr={stderr}\n            truncated={truncated}\n            isCollapsed={isCollapsed}\n            shouldCollapse={shouldCollapse}\n            lineCount={lineCount}\n            onToggleCollapse={() => setExpanded(!isExpanded)}\n          />\n        )}\n\n        {!hasOutput && <TerminalEmpty />}\n      </div>\n    </div>\n  );\n}\n\ntype TerminalComponent = typeof TerminalRoot & {\n  Header: typeof TerminalHeader;\n  Output: typeof TerminalOutput;\n  Empty: typeof TerminalEmpty;\n};\n\nexport const Terminal = Object.assign(TerminalRoot, {\n  Header: TerminalHeader,\n  Output: TerminalOutput,\n  Empty: TerminalEmpty,\n}) as TerminalComponent;\n"
    }
  ]
}
