{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-response-stream",
  "title": "Chat Response Stream",
  "description": "A reply revealed word by word or character by character, from a fixed text or an async stream.",
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/ui/chat-response-stream.tsx",
      "content": "// Adapted from prompt-kit (MIT, Copyright (c) 2025 Julien Thibeaut).\n'use client';\n\nimport { useEffect, useEffectEvent, useMemo, useState } from 'react';\n\nimport { cn } from '@/registry/ndeko/lib/utils';\n\ntype StreamMode = 'typewriter' | 'fade';\n\ntype TextSource = string | AsyncIterable<string>;\n\ntype StreamSegment = {\n  text: string;\n  index: number;\n};\n\ntype UseTextStreamOptions = {\n  textStream: TextSource;\n  /** From 1, slowest, to 100, fastest. */\n  speed?: number;\n  mode?: StreamMode;\n  onComplete?: () => void;\n  onError?: (error: unknown) => void;\n  /** Milliseconds between two revealed pieces; overrides `speed`. */\n  segmentDelay?: number;\n  /** Characters revealed at a time in typewriter mode; overrides `speed`. */\n  characterChunkSize?: number;\n};\n\ntype UseTextStreamResult = {\n  displayedText: string;\n  segments: StreamSegment[];\n  isComplete: boolean;\n  isPaused: boolean;\n  reset: () => void;\n  startStreaming: () => void;\n  pause: () => void;\n  resume: () => void;\n};\n\nconst WORD_PIECES = /\\s+|\\S+\\s*/g;\n\nfunction clampedSpeedOf(speed: number) {\n  const atLeastOne = Math.max(1, speed);\n  return Math.min(100, atLeastOne);\n}\n\nfunction delayOf(speed: number, segmentDelay: number | undefined) {\n  if (typeof segmentDelay === 'number') {\n    return Math.max(0, segmentDelay);\n  }\n  const normalized = clampedSpeedOf(speed);\n  const delay = Math.round(100 / Math.sqrt(normalized));\n  return Math.max(1, delay);\n}\n\nfunction chunkSizeOf(speed: number, characterChunkSize: number | undefined) {\n  if (typeof characterChunkSize === 'number') {\n    return Math.max(1, characterChunkSize);\n  }\n  const normalized = clampedSpeedOf(speed);\n  if (normalized < 25) {\n    return 1;\n  }\n  const size = Math.round((normalized - 25) / 10);\n  return Math.max(1, size);\n}\n\nfunction wordsOf(text: string) {\n  return text.match(WORD_PIECES) ?? [];\n}\n\nfunction charactersOf(text: string, chunkSize: number) {\n  const chunks: string[] = [];\n  for (let start = 0; start < text.length; start += chunkSize) {\n    const chunk = text.slice(start, start + chunkSize);\n    chunks.push(chunk);\n  }\n  return chunks;\n}\n\nfunction piecesOf(text: string, mode: StreamMode, chunkSize: number) {\n  if (mode === 'fade') {\n    return wordsOf(text);\n  }\n  return charactersOf(text, chunkSize);\n}\n\nfunction toSegments(pieces: string[]) {\n  return pieces.map((text, index) => ({ text, index }));\n}\n\nfunction completionOf(\n  isFixedText: boolean,\n  isFixedTextDone: boolean,\n  isIterableDone: boolean\n) {\n  if (isFixedText) {\n    return isFixedTextDone;\n  }\n  return isIterableDone;\n}\n\nfunction textOf(source: TextSource) {\n  if (typeof source === 'string') {\n    return source;\n  }\n  return '';\n}\n\n/** Fade mode reveals whole words, typewriter mode reveals characters. */\nfunction useTextStream({\n  textStream,\n  speed = 20,\n  mode = 'typewriter',\n  onComplete,\n  onError,\n  segmentDelay,\n  characterChunkSize,\n}: UseTextStreamOptions): UseTextStreamResult {\n  const [source, setSource] = useState(textStream);\n  const [revealedCount, setRevealedCount] = useState(0);\n  const [isPaused, setIsPaused] = useState(false);\n  const [streamedText, setStreamedText] = useState('');\n  const [isIterableDone, setIsIterableDone] = useState(false);\n  const [runId, setRunId] = useState(0);\n\n  if (source !== textStream) {\n    setSource(textStream);\n    setRevealedCount(0);\n    setIsPaused(false);\n    setStreamedText('');\n    setIsIterableDone(false);\n  }\n\n  const isFixedText = typeof source === 'string';\n  const fixedText = textOf(source);\n  const delay = delayOf(speed, segmentDelay);\n  const chunkSize = chunkSizeOf(speed, characterChunkSize);\n  const pieces = useMemo(\n    () => piecesOf(fixedText, mode, chunkSize),\n    [fixedText, mode, chunkSize]\n  );\n  const pieceCount = pieces.length;\n  const isFixedTextDone = revealedCount >= pieceCount;\n  const isIdle = isPaused || isFixedTextDone;\n\n  useEffect(() => {\n    if (!isFixedText) {\n      return;\n    }\n    if (isIdle) {\n      return;\n    }\n    const timer = setInterval(() => {\n      setRevealedCount((count) => Math.min(count + 1, pieceCount));\n    }, delay);\n    return () => clearInterval(timer);\n  }, [isFixedText, isIdle, delay, pieceCount, runId]);\n\n  const reportError = useEffectEvent((error: unknown) => {\n    onError?.(error);\n  });\n\n  useEffect(() => {\n    if (typeof source === 'string') {\n      return;\n    }\n    let isActive = true;\n\n    async function follow(stream: AsyncIterable<string>) {\n      try {\n        for await (const chunk of stream) {\n          if (!isActive) {\n            return;\n          }\n          setStreamedText((text) => text + chunk);\n        }\n      } catch (error) {\n        if (isActive) {\n          reportError(error);\n        }\n      }\n      if (isActive) {\n        setIsIterableDone(true);\n      }\n    }\n\n    follow(source);\n\n    return () => {\n      isActive = false;\n    };\n  }, [source]);\n\n  const visiblePieces = useMemo(() => {\n    if (isFixedText) {\n      return pieces.slice(0, revealedCount);\n    }\n    return piecesOf(streamedText, mode, chunkSize);\n  }, [isFixedText, pieces, revealedCount, streamedText, mode, chunkSize]);\n\n  const displayedText = visiblePieces.join('');\n  const segments = useMemo(() => toSegments(visiblePieces), [visiblePieces]);\n  const isComplete = completionOf(isFixedText, isFixedTextDone, isIterableDone);\n  const hasContent = displayedText.length > 0;\n  const shouldNotify = isComplete && hasContent;\n\n  const notifyComplete = useEffectEvent(() => {\n    onComplete?.();\n  });\n\n  useEffect(() => {\n    if (shouldNotify) {\n      notifyComplete();\n    }\n  }, [shouldNotify, runId]);\n\n  function reset() {\n    setRevealedCount(0);\n    setIsPaused(false);\n    setStreamedText('');\n    setIsIterableDone(false);\n  }\n\n  function startStreaming() {\n    reset();\n    setRunId((id) => id + 1);\n  }\n\n  function pause() {\n    setIsPaused(true);\n  }\n\n  function resume() {\n    setIsPaused(false);\n  }\n\n  return {\n    displayedText,\n    segments,\n    isComplete,\n    isPaused,\n    reset,\n    startStreaming,\n    pause,\n    resume,\n  };\n}\n\nconst DEFAULT_FADE_MS = 150;\n\ntype ResponseStreamSegmentsProps = {\n  segments: StreamSegment[];\n  /** How long each word takes to fade in, in milliseconds. */\n  fadeDuration?: number;\n  className?: string;\n};\n\nfunction ResponseStreamSegments({\n  segments,\n  fadeDuration = DEFAULT_FADE_MS,\n  className,\n}: ResponseStreamSegmentsProps) {\n  const fadeStyle = { transitionDuration: `${fadeDuration}ms` };\n\n  return (\n    <span className={cn('whitespace-pre-wrap', className)}>\n      {segments.map((segment) => (\n        <span\n          key={segment.index}\n          style={fadeStyle}\n          className='transition-opacity ease-(--ease-out-strong) starting:opacity-0'\n        >\n          {segment.text}\n        </span>\n      ))}\n    </span>\n  );\n}\n\ntype StreamTag = 'div' | 'p' | 'span';\n\ntype ResponseStreamProps = Omit<UseTextStreamOptions, 'onError'> & {\n  as?: StreamTag;\n  className?: string;\n  /** Fade mode only: how long each word takes to appear, in milliseconds. */\n  fadeDuration?: number;\n};\n\nfunction ResponseStream({\n  as = 'div',\n  className,\n  fadeDuration,\n  mode = 'typewriter',\n  ...options\n}: ResponseStreamProps) {\n  const { displayedText, segments } = useTextStream({ mode, ...options });\n  const Tag = as;\n  const isFade = mode === 'fade';\n\n  return (\n    <Tag\n      data-slot='response-stream'\n      className={cn('whitespace-pre-wrap', className)}\n    >\n      {isFade && (\n        <ResponseStreamSegments\n          segments={segments}\n          fadeDuration={fadeDuration}\n        />\n      )}\n      {!isFade && displayedText}\n    </Tag>\n  );\n}\n\nexport {\n  ResponseStream,\n  ResponseStreamSegments,\n  useTextStream,\n  type ResponseStreamProps,\n  type StreamMode,\n  type StreamSegment,\n  type UseTextStreamOptions,\n  type UseTextStreamResult,\n};\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "primitives",
    "chat"
  ],
  "type": "registry:ui"
}