{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"streaming-json","type":"registry:block","title":"Streaming JSON","description":"Tolerant syntax-colored rendering for partial JSON as it streams in: an incremental tokenizer that survives unclosed strings, fade-in for freshly arrived spans, a streaming caret, and a function-call wrapper.","author":"UI Lab","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/streaming-json/index.tsx","type":"registry:component","target":"@components/motion/streaming-json/index.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/streaming-json\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useRef } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Token kinds produced by the tokenizer. `literal` covers `true` / `false` /\n * `null` — and any bare run of letters seen while a value is still\n * streaming in (e.g. a partial `tru`) — since they all share one color.\n */\ntype JsonTokenType = \"key\" | \"string\" | \"number\" | \"literal\" | \"punct\";\n\ninterface JsonToken {\n  type: JsonTokenType;\n  text: string;\n  /** Character offsets into the source text — used to split a token across the stable/fresh boundary while streaming. */\n  start: number;\n  end: number;\n}\n\nconst WHITESPACE = /\\s/;\nconst DIGIT = /[0-9]/;\nconst WORD_CHAR = /[a-zA-Z_]/;\n\n/**\n * Hand-rolled tokenizer for (possibly incomplete) JSON text. Deliberately\n * not `JSON.parse`-based — partial JSON throws, and re-parsing the whole\n * document on every chunk would also throw away exactly the incremental\n * info this component needs. Scans once, left to right, never backtracks\n * past a token boundary, so it stays cheap to re-run on every streamed\n * chunk.\n *\n * Key vs. string is the one genuinely ambiguous call. Correctly telling\n * \"this string is an object member's key\" apart from \"this string is a\n * value\" needs real structural context — a bracket-depth stack plus\n * \"are we before the first colon of this member\" tracking. Instead this\n * uses a simpler, good-enough rule: a string token is a \"key\" if the first\n * non-whitespace character after its closing quote is `:`. That rule is\n * wrong only for a string *value* immediately followed by a colon, which\n * essentially never happens in real tool-call / tool-result JSON — a\n * deliberate simplicity-over-completeness trade-off.\n */\nfunction tokenizeJson(input: string): JsonToken[] {\n  const tokens: JsonToken[] = [];\n  const n = input.length;\n  let i = 0;\n\n  const pushPunct = (text: string, start: number, end: number) => {\n    const last = tokens.at(-1);\n    if (last?.type === \"punct\" && last.end === start) {\n      last.text += text;\n      last.end = end;\n    } else {\n      tokens.push({ type: \"punct\", text, start, end });\n    }\n  };\n\n  while (i < n) {\n    const ch = input[i];\n\n    if (ch === '\"') {\n      const start = i;\n      let j = i + 1;\n      let closed = false;\n      while (j < n) {\n        const c = input[j];\n        if (c === \"\\\\\") {\n          // Skip the escaped character too, so an escaped quote (`\\\"`)\n          // never looks like the closing quote. If this runs past the end\n          // of a still-streaming string, the loop below simply stops.\n          j += 2;\n          continue;\n        }\n        if (c === '\"') {\n          j += 1;\n          closed = true;\n          break;\n        }\n        j += 1;\n      }\n      const end = Math.min(j, n);\n      let k = end;\n      while (k < n && WHITESPACE.test(input[k])) k += 1;\n      const isKey = closed && input[k] === \":\";\n      tokens.push({\n        type: isKey ? \"key\" : \"string\",\n        text: input.slice(start, end),\n        start,\n        end,\n      });\n      i = end;\n      continue;\n    }\n\n    if (ch === \"-\" || DIGIT.test(ch)) {\n      const start = i;\n      let j = i;\n      if (input[j] === \"-\") j += 1;\n      while (j < n && DIGIT.test(input[j])) j += 1;\n      if (input[j] === \".\") {\n        j += 1;\n        while (j < n && DIGIT.test(input[j])) j += 1;\n      }\n      if (input[j] === \"e\" || input[j] === \"E\") {\n        j += 1;\n        if (input[j] === \"+\" || input[j] === \"-\") j += 1;\n        while (j < n && DIGIT.test(input[j])) j += 1;\n      }\n      // A lone trailing \"-\" (the number hasn't streamed in yet) still gets\n      // colored as a number rather than falling through uncounted.\n      if (j === start) j = start + 1;\n      tokens.push({ type: \"number\", text: input.slice(start, j), start, end: j });\n      i = j;\n      continue;\n    }\n\n    if (WORD_CHAR.test(ch)) {\n      const start = i;\n      let j = i;\n      while (j < n && WORD_CHAR.test(input[j])) j += 1;\n      tokens.push({ type: \"literal\", text: input.slice(start, j), start, end: j });\n      i = j;\n      continue;\n    }\n\n    // Structural punctuation and whitespace both fall here; merging\n    // consecutive characters keeps the token count down and doesn't cost\n    // anything visually since both render in the same muted color.\n    pushPunct(ch, i, i + 1);\n    i += 1;\n  }\n\n  return tokens;\n}\n\nconst TOKEN_CLASS: Record<JsonTokenType, string> = {\n  key: \"text-[#e25507] dark:text-[#ff8549]\",\n  string: \"text-emerald-600 dark:text-[#40C977]\",\n  number: \"text-[#339CFF]\",\n  literal: \"text-violet-500 dark:text-violet-400\",\n  punct: \"text-muted-foreground\",\n};\n\nfunction renderTokens(tokens: JsonToken[], keyPrefix: string) {\n  // Keyed by the token's start offset (unique and stable within a given\n  // text — a split token's two halves get distinct starts too) rather than\n  // array index, since index would misattribute state across renders as\n  // tokens are inserted ahead of it while streaming.\n  return tokens.map((token) => (\n    <span key={`${keyPrefix}-${token.start}`} className={TOKEN_CLASS[token.type]}>\n      {token.text}\n    </span>\n  ));\n}\n\n/**\n * Splits a token list at a character `offset`, cutting the one token that\n * straddles the boundary in two so both halves keep the original type\n * (and thus color). Used to separate the stable, already-rendered prefix\n * from the newly-arrived tail.\n */\nfunction splitTokens(tokens: JsonToken[], offset: number): [JsonToken[], JsonToken[]] {\n  const before: JsonToken[] = [];\n  const after: JsonToken[] = [];\n\n  for (const token of tokens) {\n    if (token.end <= offset) {\n      before.push(token);\n    } else if (token.start >= offset) {\n      after.push(token);\n    } else {\n      const cut = offset - token.start;\n      before.push({ ...token, text: token.text.slice(0, cut), end: offset });\n      after.push({ ...token, text: token.text.slice(cut), start: offset });\n    }\n  }\n\n  return [before, after];\n}\n\n/**\n * Small breathing block cursor for in-progress streams. Reimplemented\n * locally rather than imported from `agent-thread` — this block stays\n * self-contained with no cross-component dependency, same reasoning as\n * `ThreadThinking`'s independent header in that module.\n */\nfunction StreamCaret({ className }: { className?: string }) {\n  const reduce = useReducedMotion() ?? false;\n  const base = \"inline-block h-3.5 w-[7px] translate-y-[2px] rounded-[2px] bg-foreground/70\";\n\n  if (reduce) {\n    return <span aria-hidden className={cn(base, \"opacity-50\", className)} />;\n  }\n\n  return (\n    <motion.span\n      aria-hidden\n      className={cn(base, className)}\n      animate={{ opacity: [1, 0.15, 1] }}\n      transition={{ duration: 1, repeat: Infinity, ease: \"easeInOut\" }}\n    />\n  );\n}\n\nexport interface StreamingJsonProps {\n  /** The (possibly incomplete) JSON text to render, colorized as it grows. */\n  text: string;\n  /** Appends a breathing block cursor at the tail while true. */\n  streaming?: boolean;\n  /** Drops the rounded background/padding shell for inline embedding, e.g. inside `StreamingFunctionCall`. */\n  bare?: boolean;\n  className?: string;\n}\n\n/**\n * Fault-tolerant syntax-colored renderer for streaming JSON — tool inputs\n * and structured outputs that arrive as a growing, possibly-unclosed\n * string. Colors keys, strings, numbers and booleans/null as they appear,\n * without ever calling `JSON.parse` (which throws on partial JSON).\n *\n * Only the newly-appended tail re-animates in: a ref remembers the text\n * from the previous render, the stable prefix renders as plain colored\n * spans, and just the new suffix is wrapped in a `motion.span` that fades\n * in — so a long, already-settled block never reflows or re-flashes as\n * more text streams in behind it. Under `useReducedMotion()` the fade is\n * skipped entirely and the full text renders immediately.\n */\nexport function StreamingJson({ text, streaming = false, bare = false, className }: StreamingJsonProps) {\n  const reduce = useReducedMotion() ?? false;\n  const prevTextRef = useRef(\"\");\n\n  const tokens = tokenizeJson(text);\n\n  // Track the previous render's text so only the newly-appended tail\n  // animates. If `text` isn't a continuation of the previous value (e.g. a\n  // caller resets it), treat the whole thing as fresh rather than diffing\n  // unrelated content.\n  const prevText = prevTextRef.current;\n  const prefixLen = text.startsWith(prevText) ? prevText.length : 0;\n  prevTextRef.current = text;\n\n  const [stable, fresh] = reduce ? [tokens, []] : splitTokens(tokens, prefixLen);\n\n  const content = (\n    <>\n      {renderTokens(stable, \"s\")}\n      {fresh.length > 0 ? (\n        <motion.span\n          key={text.length}\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          transition={{ duration: 0.15, ease: EASE_OUT }}\n        >\n          {renderTokens(fresh, \"f\")}\n        </motion.span>\n      ) : null}\n      {streaming ? <StreamCaret className=\"ml-0.5\" /> : null}\n    </>\n  );\n\n  if (bare) {\n    return <code className={cn(\"whitespace-pre-wrap break-words align-baseline\", className)}>{content}</code>;\n  }\n\n  return (\n    <pre\n      className={cn(\n        \"overflow-x-auto rounded-xl bg-black/[0.04] p-3 font-mono text-[13px] leading-[20px] whitespace-pre dark:bg-white/5\",\n        className,\n      )}\n    >\n      <code>{content}</code>\n    </pre>\n  );\n}\n\nexport interface StreamingFunctionCallProps {\n  /** Function / tool name shown before the argument list, e.g. `\"web_search\"`. */\n  name: string;\n  /** The (possibly incomplete) JSON argument text, rendered via `StreamingJson`'s bare variant. */\n  text: string;\n  streaming?: boolean;\n  className?: string;\n}\n\n/**\n * Streaming tool/function-call row — `name(` then its arguments fading in\n * through `StreamingJson`, matching how tool-call deltas actually arrive:\n * the name first, then the argument JSON key by key. The caller drives\n * completeness through `streaming` alone; this component never tries to\n * detect a balanced closing brace itself, so the trailing `)` only renders\n * once `streaming` is false — showing it mid-stream would misleadingly\n * suggest the call had already finished.\n */\nexport function StreamingFunctionCall({\n  name,\n  text,\n  streaming = false,\n  className,\n}: StreamingFunctionCallProps) {\n  return (\n    <div className={cn(\"font-mono text-[13px]\", className)}>\n      <span className=\"font-medium text-foreground\">{name}</span>\n      <span className=\"text-muted-foreground\">(</span>\n      <StreamingJson text={text} streaming={streaming} bare />\n      {!streaming ? <span className=\"text-muted-foreground\">)</span> : null}\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}