{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"core-response-stream","type":"registry:ui","title":"Response Stream","description":"Tailark core response stream component","registryDependencies":["@tailark/core-utils"],"files":[{"path":"core/ui/response-stream.tsx","type":"registry:ui","target":"@ui/response-stream.tsx","content":"\"use client\"\n\nimport { cn } from \"@/lib/utils\"\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\n\nexport type Mode = \"typewriter\" | \"fade\"\n\nexport type UseTextStreamOptions = {\n  textStream: string | AsyncIterable<string>\n  speed?: number\n  mode?: Mode\n  onComplete?: () => void\n  fadeDuration?: number\n  segmentDelay?: number\n  characterChunkSize?: number\n  onError?: (error: unknown) => void\n}\n\nexport type UseTextStreamResult = {\n  displayedText: string\n  isComplete: boolean\n  segments: { text: string; index: number }[]\n  getFadeDuration: () => number\n  getSegmentDelay: () => number\n  reset: () => void\n  startStreaming: () => void\n  pause: () => void\n  resume: () => void\n}\n\nfunction useTextStream({\n  textStream,\n  speed = 20,\n  mode = \"typewriter\",\n  onComplete,\n  fadeDuration,\n  segmentDelay,\n  characterChunkSize,\n  onError,\n}: UseTextStreamOptions): UseTextStreamResult {\n  const [displayedText, setDisplayedText] = useState(\"\")\n  const [isComplete, setIsComplete] = useState(false)\n  const [segments, setSegments] = useState<{ text: string; index: number }[]>(\n    []\n  )\n\n  const speedRef = useRef(speed)\n  const modeRef = useRef(mode)\n  const currentIndexRef = useRef(0)\n  const animationRef = useRef<number | null>(null)\n  const fadeDurationRef = useRef(fadeDuration)\n  const segmentDelayRef = useRef(segmentDelay)\n  const characterChunkSizeRef = useRef(characterChunkSize)\n  const streamRef = useRef<AbortController | null>(null)\n  const completedRef = useRef(false)\n  const onCompleteRef = useRef(onComplete)\n  const onErrorRef = useRef(onError)\n\n  useEffect(() => {\n    speedRef.current = speed\n    modeRef.current = mode\n    fadeDurationRef.current = fadeDuration\n    segmentDelayRef.current = segmentDelay\n    characterChunkSizeRef.current = characterChunkSize\n  }, [speed, mode, fadeDuration, segmentDelay, characterChunkSize])\n\n  useEffect(() => {\n    onCompleteRef.current = onComplete\n  }, [onComplete])\n\n  useEffect(() => {\n    onErrorRef.current = onError\n  }, [onError])\n\n  const getChunkSize = useCallback(() => {\n    if (typeof characterChunkSizeRef.current === \"number\") {\n      return Math.max(1, characterChunkSizeRef.current)\n    }\n\n    const normalizedSpeed = Math.min(100, Math.max(1, speedRef.current))\n\n    if (modeRef.current === \"typewriter\") {\n      if (normalizedSpeed < 25) return 1\n      return Math.max(1, Math.round((normalizedSpeed - 25) / 10))\n    } else if (modeRef.current === \"fade\") {\n      return 1\n    }\n\n    return 1\n  }, [])\n\n  const getProcessingDelay = useCallback(() => {\n    if (typeof segmentDelayRef.current === \"number\") {\n      return Math.max(0, segmentDelayRef.current)\n    }\n\n    const normalizedSpeed = Math.min(100, Math.max(1, speedRef.current))\n    return Math.max(1, Math.round(100 / Math.sqrt(normalizedSpeed)))\n  }, [])\n\n  const getFadeDuration = useCallback(() => {\n    if (typeof fadeDurationRef.current === \"number\")\n      return Math.max(10, fadeDurationRef.current)\n\n    const normalizedSpeed = Math.min(100, Math.max(1, speedRef.current))\n    return Math.round(1000 / Math.sqrt(normalizedSpeed))\n  }, [])\n\n  const getSegmentDelay = useCallback(() => {\n    if (typeof segmentDelayRef.current === \"number\")\n      return Math.max(0, segmentDelayRef.current)\n\n    const normalizedSpeed = Math.min(100, Math.max(1, speedRef.current))\n    return Math.max(1, Math.round(100 / Math.sqrt(normalizedSpeed)))\n  }, [])\n\n  const updateSegments = useCallback((text: string) => {\n    if (modeRef.current === \"fade\") {\n      try {\n        type Segment = { segment: string }\n        type SegmenterLike = { segment: (input: string) => Iterable<Segment> }\n        type SegmenterConstructor = new (\n          locales?: string | string[],\n          options?: { granularity?: \"grapheme\" | \"word\" | \"sentence\" }\n        ) => SegmenterLike\n\n        const SegmenterCtor = (\n          Intl as unknown as { Segmenter?: SegmenterConstructor }\n        ).Segmenter\n\n        if (typeof SegmenterCtor === \"function\") {\n          const segmenter = new SegmenterCtor(navigator.language, {\n            granularity: \"word\",\n          })\n          const segmentIterator = segmenter.segment(text)\n          const newSegments = Array.from(segmentIterator).map(\n            (segment, index) => ({\n              text: segment.segment,\n              index,\n            })\n          )\n          setSegments(newSegments)\n        } else {\n          const newSegments = text\n            .split(/(\\s+)/)\n            .filter(Boolean)\n            .map((word, index) => ({\n              text: word,\n              index,\n            }))\n          setSegments(newSegments)\n        }\n      } catch (error) {\n        const newSegments = text\n          .split(/(\\s+)/)\n          .filter(Boolean)\n          .map((word, index) => ({\n            text: word,\n            index,\n          }))\n        setSegments(newSegments)\n        onErrorRef.current?.(error)\n      }\n    }\n  }, [])\n\n  const markComplete = useCallback(() => {\n    if (!completedRef.current) {\n      completedRef.current = true\n      setIsComplete(true)\n      onCompleteRef.current?.()\n    }\n  }, [])\n\n  const reset = useCallback(() => {\n    currentIndexRef.current = 0\n    setDisplayedText(\"\")\n    setSegments([])\n    setIsComplete(false)\n    completedRef.current = false\n\n    if (animationRef.current) {\n      cancelAnimationFrame(animationRef.current)\n      animationRef.current = null\n    }\n  }, [])\n\n  const processStringTypewriter = useCallback(\n    (text: string) => {\n      let lastFrameTime = 0\n\n      const streamContent = (timestamp: number) => {\n        const delay = getProcessingDelay()\n        if (delay > 0 && timestamp - lastFrameTime < delay) {\n          animationRef.current = requestAnimationFrame(streamContent)\n          return\n        }\n        lastFrameTime = timestamp\n\n        if (currentIndexRef.current >= text.length) {\n          markComplete()\n          return\n        }\n\n        const chunkSize = getChunkSize()\n        const endIndex = Math.min(\n          currentIndexRef.current + chunkSize,\n          text.length\n        )\n        const newDisplayedText = text.slice(0, endIndex)\n\n        setDisplayedText(newDisplayedText)\n        if (modeRef.current === \"fade\") {\n          updateSegments(newDisplayedText)\n        }\n\n        currentIndexRef.current = endIndex\n\n        if (endIndex < text.length) {\n          animationRef.current = requestAnimationFrame(streamContent)\n        } else {\n          markComplete()\n        }\n      }\n\n      animationRef.current = requestAnimationFrame(streamContent)\n    },\n    [getProcessingDelay, getChunkSize, updateSegments, markComplete]\n  )\n\n  const processAsyncIterable = useCallback(\n    async (stream: AsyncIterable<string>) => {\n      const controller = new AbortController()\n      streamRef.current = controller\n\n      let displayed = \"\"\n\n      try {\n        for await (const chunk of stream) {\n          if (controller.signal.aborted) return\n\n          displayed += chunk\n          setDisplayedText(displayed)\n          updateSegments(displayed)\n        }\n\n        markComplete()\n      } catch (error) {\n        console.error(\"Error processing text stream:\", error)\n        markComplete()\n        onError?.(error)\n      }\n    },\n    [updateSegments, markComplete, onError]\n  )\n\n  const startStreaming = useCallback(() => {\n    reset()\n\n    if (typeof textStream === \"string\") {\n      processStringTypewriter(textStream)\n    } else if (textStream) {\n      processAsyncIterable(textStream)\n    }\n  }, [textStream, reset, processStringTypewriter, processAsyncIterable])\n\n  const pause = useCallback(() => {\n    if (animationRef.current) {\n      cancelAnimationFrame(animationRef.current)\n      animationRef.current = null\n    }\n  }, [])\n\n  const resume = useCallback(() => {\n    if (typeof textStream === \"string\" && !isComplete) {\n      processStringTypewriter(textStream)\n    }\n  }, [textStream, isComplete, processStringTypewriter])\n\n  useEffect(() => {\n    startStreaming()\n\n    return () => {\n      if (animationRef.current) {\n        cancelAnimationFrame(animationRef.current)\n      }\n      if (streamRef.current) {\n        streamRef.current.abort()\n      }\n    }\n  }, [textStream, startStreaming])\n\n  return {\n    displayedText,\n    isComplete,\n    segments,\n    getFadeDuration,\n    getSegmentDelay,\n    reset,\n    startStreaming,\n    pause,\n    resume,\n  }\n}\n\nexport type ResponseStreamProps = {\n  textStream: string | AsyncIterable<string>\n  mode?: Mode\n  speed?: number // 1-100, where 1 is slowest and 100 is fastest\n  className?: string\n  onComplete?: () => void\n  as?: keyof React.JSX.IntrinsicElements // Element type to render\n  fadeDuration?: number // Custom fade duration in ms (overrides speed)\n  segmentDelay?: number // Custom delay between segments in ms (overrides speed)\n  characterChunkSize?: number // Custom characters per frame for typewriter mode (overrides speed)\n}\n\nfunction ResponseStream({\n  textStream,\n  mode = \"typewriter\",\n  speed = 20,\n  className = \"\",\n  onComplete,\n  as = \"div\",\n  fadeDuration,\n  segmentDelay,\n  characterChunkSize,\n}: ResponseStreamProps) {\n  const animationEndRef = useRef<(() => void) | null>(null)\n\n  const {\n    displayedText,\n    isComplete,\n    segments,\n    getFadeDuration,\n    getSegmentDelay,\n  } = useTextStream({\n    textStream,\n    speed,\n    mode,\n    onComplete,\n    fadeDuration,\n    segmentDelay,\n    characterChunkSize,\n  })\n\n  useEffect(() => {\n    animationEndRef.current = onComplete ?? null\n  }, [onComplete])\n\n  const handleLastSegmentAnimationEnd = useCallback(() => {\n    if (animationEndRef.current && isComplete) {\n      animationEndRef.current()\n    }\n  }, [isComplete])\n\n  // fadeStyle is the style for the fade animation\n  const fadeStyle = `\n    @keyframes fadeIn {\n      from { opacity: 0; }\n      to { opacity: 1; }\n    }\n    \n    .fade-segment {\n      display: inline-block;\n      opacity: 0;\n      animation: fadeIn ${getFadeDuration()}ms ease-out forwards;\n    }\n\n    .fade-segment-space {\n      white-space: pre;\n    }\n  `\n\n  const renderContent = () => {\n    switch (mode) {\n      case \"typewriter\":\n        return <>{displayedText}</>\n\n      case \"fade\":\n        return (\n          <>\n            <style>{fadeStyle}</style>\n            <div className=\"relative\">\n              {segments.map((segment, idx) => {\n                const isWhitespace = /^\\s+$/.test(segment.text)\n                const isLastSegment = idx === segments.length - 1\n\n                return (\n                  <span\n                    key={`${segment.text}-${idx}`}\n                    className={cn(\n                      \"fade-segment\",\n                      isWhitespace && \"fade-segment-space\"\n                    )}\n                    style={{\n                      animationDelay: `${idx * getSegmentDelay()}ms`,\n                    }}\n                    onAnimationEnd={\n                      isLastSegment ? handleLastSegmentAnimationEnd : undefined\n                    }\n                  >\n                    {segment.text}\n                  </span>\n                )\n              })}\n            </div>\n          </>\n        )\n\n      default:\n        return <>{displayedText}</>\n    }\n  }\n\n  const Container = as as keyof React.JSX.IntrinsicElements\n\n  return <Container className={className}>{renderContent()}</Container>\n}\n\nexport { useTextStream, ResponseStream }\n"}]}