Files
2026-07-13 12:38:34 +08:00

266 lines
8.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import * as React from "react"
import { useResolvedTerminalTheme } from "@/hooks/use-resolved-terminal-theme"
import { cn } from "@/lib/utils"
/** Claude thinking spinner — cycles in this order (claude.ai / Claude Code). */
export const CLAUDE_THINKING_FRAMES = [
"\u00B7", // middle dot / bullet
"\u273B", // teardrop-spoked asterisk ✻
"\u273D", // heavy teardrop-spoked asterisk ✽
"\u2736", // six pointed black star ✶
"\u2733", // eight spoked asterisk ✳
"\u2722", // four balloon-spoked asterisk ✢
] as const
export type TerminalAsciiSpinnerProps = {
/** Single-character frames cycled in order. */
frames?: ReadonlyArray<string>
/** Milliseconds per frame. */
speed?: number
color?: string
className?: string
}
export function TerminalAsciiSpinner({
frames = CLAUDE_THINKING_FRAMES,
speed = 150,
color = "var(--terminal-teal)",
className,
}: TerminalAsciiSpinnerProps) {
const [step, setStep] = React.useState(0)
const [reduceMotion, setReduceMotion] = React.useState(false)
const frameCount = Math.max(1, frames.length)
React.useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)")
const update = () => setReduceMotion(media.matches)
update()
media.addEventListener("change", update)
return () => media.removeEventListener("change", update)
}, [])
React.useEffect(() => {
if (reduceMotion) return
const interval = window.setInterval(() => {
setStep((current) => (current + 1) % frameCount)
}, Math.max(16, speed))
return () => window.clearInterval(interval)
}, [reduceMotion, speed, frameCount])
const frame = frames[reduceMotion ? 0 : step % frameCount] ?? frames[0] ?? "·"
return (
<span
aria-hidden
className={cn(
"inline-flex w-[1em] shrink-0 items-center justify-center font-mono leading-none",
className
)}
style={{ color }}
>
{frame}
</span>
)
}
// 3x3 grid (row-major indices). The 8 perimeter cells form a clockwise ring
// swept by a comet head with a fading trail; the center (index 4) pulses as a
// core in sync with each revolution.
const DOT_GRID_SIZE = 9
const DOT_RING = [0, 1, 2, 5, 8, 7, 6, 3]
const DOT_CENTER = 4
/** A custom animation frame: opacity (01) per grid cell, row-major (length 9). */
export type TerminalDotFrame = ReadonlyArray<number>
export type TerminalDotMatrixProps = {
tone?: "default" | "active"
/** Milliseconds per step (lower is faster). */
speed?: number
/** Number of cells that fade behind the comet head. (Built-in spin only.) */
trail?: number
/** Pulse the center dot as a core. (Built-in spin only.) */
pulseCenter?: boolean
/** Dot size in pixels. */
dotSize?: number
/** Override the dot color (any CSS color). Defaults to the tone color. */
color?: string
/**
* Custom animation as a list of frames, each an array of opacity values
* (01) for the 3x3 grid in row-major order. Overrides the built-in spin.
*/
frames?: ReadonlyArray<TerminalDotFrame>
className?: string
}
function clampOpacity(value: number | undefined) {
if (value == null || Number.isNaN(value)) return 0
return Math.min(1, Math.max(0, value))
}
export function TerminalDotMatrix({
tone = "default",
speed = 120,
trail = 4,
pulseCenter = true,
dotSize = 2,
color,
frames,
className,
}: TerminalDotMatrixProps) {
const [step, setStep] = React.useState(0)
const [reduceMotion, setReduceMotion] = React.useState(false)
const hasCustomFrames = Boolean(frames && frames.length > 0)
const frameCount = hasCustomFrames ? frames!.length : DOT_RING.length
React.useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)")
const update = () => setReduceMotion(media.matches)
update()
media.addEventListener("change", update)
return () => media.removeEventListener("change", update)
}, [])
React.useEffect(() => {
if (reduceMotion) return
const interval = window.setInterval(() => {
setStep((current) => (current + 1) % frameCount)
}, Math.max(16, speed))
return () => window.clearInterval(interval)
}, [reduceMotion, speed, frameCount])
const dotColor =
color ??
(tone === "active" ? "var(--terminal-blue-bright)" : "var(--terminal-white)")
const opacities = new Array<number>(DOT_GRID_SIZE).fill(0)
if (hasCustomFrames) {
const frame = frames![(reduceMotion ? 0 : step) % frames!.length] ?? []
for (let index = 0; index < DOT_GRID_SIZE; index += 1) {
opacities[index] = clampOpacity(frame[index])
}
} else {
const head = reduceMotion ? 0 : step % DOT_RING.length
const safeTrail = Math.max(1, trail)
DOT_RING.forEach((cellIndex, ringPos) => {
const distance = (head - ringPos + DOT_RING.length) % DOT_RING.length
opacities[cellIndex] = distance < safeTrail ? 1 - distance / safeTrail : 0
})
if (pulseCenter) {
const phase = head / DOT_RING.length
opacities[DOT_CENTER] =
0.3 + 0.45 * (0.5 + 0.5 * Math.cos(2 * Math.PI * phase))
}
}
return (
<span
aria-hidden
className={cn("inline-grid shrink-0 align-middle leading-none", className)}
style={{
gridTemplateColumns: `repeat(3, ${dotSize}px)`,
gridTemplateRows: `repeat(3, ${dotSize}px)`,
gap: 1,
}}
>
{opacities.map((opacity, index) => (
<span
key={index}
className="rounded-full"
style={{
width: dotSize,
height: dotSize,
backgroundColor: dotColor,
opacity,
transition: `opacity ${Math.max(16, speed)}ms linear`,
}}
/>
))}
</span>
)
}
export type ThinkingIndicatorProps = {
label?: string
children?: React.ReactNode
/**
* `auto` — Claude symbol loop inside `.terminal-theme-claude`, dot matrix elsewhere.
* `ascii` — always use the symbol loop. `dots` / `cursor` — force that style.
*/
variant?: "auto" | "dots" | "ascii" | "cursor"
tone?: "default" | "active"
/** Customize the dot-matrix animation (speed, trail, color, etc.). */
dotProps?: Omit<TerminalDotMatrixProps, "tone" | "className">
/** Customize the Claude-style ASCII symbol loop. */
asciiProps?: Omit<TerminalAsciiSpinnerProps, "className">
/**
* SessionContent integration — skip the streaming queue and pin this line at the
* transcript tail (e.g. live thinking). Not read by ThinkingIndicator; SessionContent
* inspects the prop on the child element.
*/
sessionTail?: boolean
className?: string
}
export function ThinkingIndicator({
label,
children,
variant = "auto",
tone = "default",
dotProps,
asciiProps,
sessionTail: _sessionTail,
className,
}: ThinkingIndicatorProps) {
const content = children ?? label
const { ref, isClaude, isCompact } = useResolvedTerminalTheme()
const useAscii = variant === "ascii" || (variant === "auto" && isClaude)
const useCursor = variant === "cursor" || (variant === "auto" && isCompact)
const asciiColor = asciiProps?.color ?? "var(--terminal-teal)"
const textColor = useAscii
? asciiColor
: tone === "active"
? "var(--terminal-white)"
: "var(--terminal-dim)"
return (
<div
ref={ref}
className={cn("flex items-center gap-2.5 text-[12px]", className)}
role="status"
aria-live="polite"
>
{useCursor ? (
<span
aria-hidden
className="terminal-cursor inline-block h-3 w-[0.55em]"
style={{ backgroundColor: "var(--terminal-progress-fill)" }}
/>
) : useAscii ? (
<TerminalAsciiSpinner {...asciiProps} color={asciiColor} />
) : (
<TerminalDotMatrix tone={tone} {...dotProps} />
)}
{content && <span style={{ color: textColor }}>{content}</span>}
</div>
)
}
/** @deprecated Use ThinkingIndicator */
export type BusyIndicatorProps = ThinkingIndicatorProps
/** @deprecated Use ThinkingIndicator */
export function BusyIndicator(props: ThinkingIndicatorProps) {
return <ThinkingIndicator {...props} />
}