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

307 lines
9.3 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 { IconBolt as Zap } from "nucleo-pixel"
import * as React from "react"
import { useResolvedTerminalTheme } from "@/hooks/use-resolved-terminal-theme"
import { cn } from "@/lib/utils"
/** Default composer meta — lightning level indicator (e.g. max). */
export function InputLevelMeta({ children = "max" }: { children?: React.ReactNode }) {
return (
<span className="inline-flex items-center gap-1">
<Zap className="size-3 shrink-0" aria-hidden />
{children}
</span>
)
}
export type InputProps = {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
onSubmit?: (value: string) => void
prompt?: string
status?: React.ReactNode
/** Footer hint on the left — composer (default) and Claude stacked layouts. */
metaLeft?: React.ReactNode
/** Footer hint on the right — composer (default) and Claude stacked layouts. */
metaRight?: React.ReactNode
placeholder?: string
disabled?: boolean
/** Keep the blinking cursor visible even when unfocused. */
showCursor?: boolean
/**
* `auto` — composer on default, inline box on grok, stacked lines on Claude.
* `inline` — status on the right in a bordered box. `stacked` — always stacked.
*/
layout?: "auto" | "inline" | "stacked"
className?: string
}
function refocusTextarea(textarea: HTMLTextAreaElement | null) {
if (!textarea) return
textarea.focus()
requestAnimationFrame(() => {
textarea.focus()
requestAnimationFrame(() => textarea.focus())
})
}
export const Input = React.forwardRef<HTMLTextAreaElement, InputProps>(function Input(
{
value,
defaultValue = "",
onValueChange,
onSubmit,
prompt,
status,
metaLeft,
metaRight,
placeholder,
disabled = false,
showCursor = false,
layout = "auto",
className,
},
ref
) {
const [internalValue, setInternalValue] = React.useState(defaultValue)
const [focused, setFocused] = React.useState(false)
const [caretLeft, setCaretLeft] = React.useState(0)
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
const mirrorRef = React.useRef<HTMLSpanElement>(null)
const { ref: themeRef, isClaude, isBubble } = useResolvedTerminalTheme()
const currentValue = value ?? internalValue
const stacked = layout === "stacked" || (layout === "auto" && isClaude)
const composer = layout === "auto" && isBubble
const inline = !stacked && !composer
const resolvedPrompt = prompt ?? ""
const footerRight = metaRight ?? (stacked ? status : undefined)
const showComposerMeta =
composer && (metaLeft != null || metaRight != null || status != null)
const showStackedFooter = stacked && (metaLeft != null || footerRight != null)
const textColor = stacked ? "var(--terminal-fg)" : "var(--terminal-white)"
const promptColor = "var(--terminal-teal)"
const cursorColor = stacked ? "var(--terminal-fg)" : "var(--terminal-progress-fill)"
const fieldLineHeight = stacked ? 18 : 16
const cursorHeight = stacked ? 13 : 12
React.useImperativeHandle(ref, () => textareaRef.current as HTMLTextAreaElement)
const syncCaretPosition = React.useCallback(() => {
const textarea = textareaRef.current
const mirror = mirrorRef.current
if (!textarea || !mirror) return
const selectionStart = textarea.selectionStart ?? currentValue.length
mirror.textContent = currentValue.slice(0, selectionStart) || "\u200b"
setCaretLeft(mirror.offsetWidth)
}, [currentValue])
React.useLayoutEffect(() => {
syncCaretPosition()
}, [syncCaretPosition])
const handleChange = (next: string) => {
if (value === undefined) setInternalValue(next)
onValueChange?.(next)
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
if (!currentValue.trim() || disabled) return
onSubmit?.(currentValue)
refocusTextarea(textareaRef.current)
}
}
const field = (
<div
className={cn(
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden",
stacked ? "gap-2.5" : undefined
)}
style={{ color: textColor }}
>
<span
aria-hidden
className="inline-flex shrink-0 items-center"
style={{
color: promptColor,
fontSize: 9,
lineHeight: `${fieldLineHeight}px`,
}}
>
{resolvedPrompt}
</span>
<span
className="relative flex min-w-0 flex-1 items-center overflow-hidden"
style={{ lineHeight: `${fieldLineHeight}px`, minHeight: fieldLineHeight }}
>
<span
ref={mirrorRef}
aria-hidden
className="pointer-events-none invisible absolute top-0 left-0 whitespace-pre text-[11px] leading-4"
/>
<textarea
ref={textareaRef}
value={currentValue}
onChange={(event) => handleChange(event.target.value)}
onKeyDown={handleKeyDown}
onKeyUp={syncCaretPosition}
onSelect={syncCaretPosition}
onClick={syncCaretPosition}
onFocus={() => {
setFocused(true)
syncCaretPosition()
}}
onBlur={() => setFocused(false)}
placeholder={placeholder}
disabled={disabled}
rows={1}
spellCheck={false}
className={cn(
"terminal-input-control block w-full resize-none overflow-hidden border-0 bg-transparent p-0 font-inherit text-[11px] leading-4 caret-transparent outline-none"
)}
style={{
color: textColor,
minHeight: fieldLineHeight,
maxHeight: fieldLineHeight,
}}
/>
{!disabled && (focused || showCursor) ? (
<span
aria-hidden
className="terminal-cursor pointer-events-none absolute top-1/2 inline-block w-[0.55em] shrink-0 -translate-y-1/2"
style={{
left: caretLeft,
height: cursorHeight,
backgroundColor: cursorColor,
}}
/>
) : null}
</span>
{inline && status ? (
<div
className="shrink-0 whitespace-nowrap text-[11px]"
style={{ color: "var(--terminal-dim)" }}
>
{status}
</div>
) : null}
</div>
)
const fieldShell = (
<div
className={cn(
"terminal-input-field flex items-center",
stacked && "terminal-input-field--stacked px-0",
composer && "terminal-input-field--composer terminal-panel gap-3 border px-3 py-2.5 sm:px-4",
inline && "gap-3 rounded border px-3 py-2 sm:px-4"
)}
style={
stacked
? undefined
: {
borderColor: "var(--terminal-border)",
backgroundColor: "var(--terminal-input-bg)",
}
}
>
{field}
</div>
)
return (
<div
ref={themeRef}
className={cn("terminal-input w-full text-[11px]", className)}
onClick={() => textareaRef.current?.focus()}
>
{composer ? (
<div className="terminal-input-composer">
{fieldShell}
{showComposerMeta ? (
<div className="terminal-input-meta-layer flex items-center justify-between gap-2">
<span className="min-w-0 truncate">{metaLeft}</span>
<span className="flex shrink-0 items-center gap-2 whitespace-nowrap">
{metaRight}
{status}
</span>
</div>
) : null}
</div>
) : (
<>
{fieldShell}
{showStackedFooter ? (
<div
className="terminal-input-meta mt-1.5 flex items-center justify-between gap-3 text-[11px] leading-4"
style={{ color: "var(--terminal-dim)" }}
>
<span className="min-w-0 truncate">{metaLeft}</span>
<span className="shrink-0 whitespace-nowrap">{footerRight}</span>
</div>
) : null}
</>
)}
</div>
)
})
/** @deprecated Use Input */
export type ShellInputProps = InputProps
/** @deprecated Use Input */
export const ShellInput = React.forwardRef<HTMLTextAreaElement, InputProps>(
function ShellInput(props, ref) {
return <Input {...props} ref={ref} />
}
)
export type CommandSuggestProps = {
commands: string[]
activeIndex?: number
onSelect?: (command: string) => void
className?: string
}
export function CommandSuggest({
commands,
activeIndex = 0,
onSelect,
className,
}: CommandSuggestProps) {
return (
<div
className={cn("terminal-panel overflow-hidden border font-mono text-xs", className)}
style={{
borderColor: "var(--terminal-border)",
backgroundColor: "var(--terminal-popover)",
}}
>
{commands.map((command, index) => (
<button
key={command}
type="button"
onClick={() => onSelect?.(command)}
className="flex w-full items-center px-3 py-1.5 text-left"
style={{
color:
index === activeIndex ? "var(--terminal-white)" : "var(--terminal-dim)",
backgroundColor:
index === activeIndex ? "var(--terminal-panel-strong)" : "transparent",
}}
>
<span style={{ color: "var(--terminal-green)" }}>/</span>
{command.replace(/^\//, "")}
</button>
))}
</div>
)
}