"use client" import type { ComponentProps, HTMLAttributes } from "react" import Ansi from "ansi-to-react" import { CheckIcon, CopyIcon, TerminalIcon, Trash2Icon } from "lucide-react" import { useTranslations } from "next-intl" import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { Shimmer } from "./shimmer" interface TerminalContextType { output: string isStreaming: boolean autoScroll: boolean onClear?: () => void } const TerminalContext = createContext({ output: "", isStreaming: false, autoScroll: true, }) function normalizeTerminalOutput(output: string): string { if (!output) return output // Some runtimes deliver ANSI escapes as literal "\\u001b"/"\\x1b". // Decode them so ansi-to-react can apply terminal colors. const hasEscapedAnsi = output.includes("\\u001b") || output.includes("\\u001B") || output.includes("\\x1b") || output.includes("\\x1B") || output.includes("\\u009b") || output.includes("\\u009B") if (!hasEscapedAnsi) return output return output .replace(/\\u001b/gi, "\u001b") .replace(/\\x1b/gi, "\u001b") .replace(/\\u009b/gi, "\u009b") } export type TerminalProps = HTMLAttributes & { output: string isStreaming?: boolean autoScroll?: boolean onClear?: () => void } export function Terminal({ output, isStreaming = false, autoScroll = true, onClear, className, children, ...props }: TerminalProps) { const contextValue = useMemo( () => ({ output, isStreaming, autoScroll, onClear }), [output, isStreaming, autoScroll, onClear] ) return ( {children ?? ( <> {onClear && } > )} ) } export type TerminalHeaderProps = HTMLAttributes export function TerminalHeader({ className, children, ...props }: TerminalHeaderProps) { return ( {children} ) } export type TerminalTitleProps = HTMLAttributes export function TerminalTitle({ className, children, ...props }: TerminalTitleProps) { const t = useTranslations("Folder.chat.terminal") return ( {children ?? t("title")} ) } export type TerminalStatusProps = HTMLAttributes export function TerminalStatus({ className, children, ...props }: TerminalStatusProps) { const t = useTranslations("Folder.chat.terminal") const { isStreaming } = useContext(TerminalContext) if (!isStreaming) { return null } return ( {children ?? {t("running")}} ) } export type TerminalActionsProps = HTMLAttributes export function TerminalActions({ className, children, ...props }: TerminalActionsProps) { return ( {children} ) } export type TerminalCopyButtonProps = ComponentProps & { onCopy?: () => void onError?: (error: Error) => void timeout?: number } export function TerminalCopyButton({ onCopy, onError, timeout = 2000, children, className, ...props }: TerminalCopyButtonProps) { const [isCopied, setIsCopied] = useState(false) const timeoutRef = useRef(0) const { output } = useContext(TerminalContext) const copyToClipboard = useCallback(async () => { if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { onError?.(new Error("Clipboard API not available")) return } try { await navigator.clipboard.writeText(output) setIsCopied(true) onCopy?.() timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout) } catch (error) { onError?.(error as Error) } }, [output, onCopy, onError, timeout]) useEffect( () => () => { window.clearTimeout(timeoutRef.current) }, [] ) const Icon = isCopied ? CheckIcon : CopyIcon return ( {children ?? } ) } export type TerminalClearButtonProps = ComponentProps export function TerminalClearButton({ children, className, ...props }: TerminalClearButtonProps) { const { onClear } = useContext(TerminalContext) if (!onClear) { return null } return ( {children ?? } ) } export type TerminalContentProps = HTMLAttributes export function TerminalContent({ className, children, ...props }: TerminalContentProps) { const { output, isStreaming, autoScroll } = useContext(TerminalContext) const normalizedOutput = useMemo( () => normalizeTerminalOutput(output), [output] ) const containerRef = useRef(null) useEffect(() => { if (autoScroll && containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight } }, [output, autoScroll]) return ( {children ?? ( {normalizedOutput} {isStreaming && ( )} )} ) }
{normalizedOutput} {isStreaming && ( )}