claw-web-v2 / client /src /components /ChatInput.tsx
Claw Web
Claw Web v1.0 — AI Agent Web Interface with MiMo-V2-Flash
7540aea
import { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { Send, Square, Slash, ChevronUp } from "lucide-react";
import { SLASH_COMMANDS } from "@shared/claw-types";
interface ChatInputProps {
onSend: (message: string) => void;
onSlashCommand?: (command: string, args: string) => void;
isStreaming: boolean;
onStop: () => void;
disabled?: boolean;
placeholder?: string;
}
export function ChatInput({
onSend,
onSlashCommand,
isStreaming,
onStop,
disabled,
placeholder,
}: ChatInputProps) {
const [value, setValue] = useState("");
const [showCommands, setShowCommands] = useState(false);
const [selectedCommand, setSelectedCommand] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Filter commands based on input
const filteredCommands = value.startsWith("/")
? SLASH_COMMANDS.filter((cmd) =>
cmd.name.toLowerCase().startsWith(value.split(" ")[0].toLowerCase())
)
: [];
useEffect(() => {
setShowCommands(value.startsWith("/") && filteredCommands.length > 0 && !value.includes(" "));
setSelectedCommand(0);
}, [value, filteredCommands.length]);
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px";
}
}, [value]);
const handleSubmit = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
if (trimmed.startsWith("/")) {
const parts = trimmed.split(" ");
const cmd = parts[0];
const args = parts.slice(1).join(" ");
if (onSlashCommand) {
onSlashCommand(cmd, args);
}
} else {
onSend(trimmed);
}
setValue("");
}, [value, disabled, onSend, onSlashCommand]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showCommands) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedCommand((prev) => Math.min(prev + 1, filteredCommands.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedCommand((prev) => Math.max(prev - 1, 0));
return;
}
if (e.key === "Tab" || e.key === "Enter") {
e.preventDefault();
const cmd = filteredCommands[selectedCommand];
if (cmd) {
setValue(cmd.name + " ");
setShowCommands(false);
}
return;
}
if (e.key === "Escape") {
setShowCommands(false);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (isStreaming) return;
handleSubmit();
}
};
// Group commands by category for display
const groupedCommands = filteredCommands.reduce<Record<string, typeof filteredCommands>>((acc, cmd) => {
const cat = cmd.category;
if (!acc[cat]) acc[cat] = [];
acc[cat].push(cmd);
return acc;
}, {});
return (
<div className="relative">
{/* Slash command autocomplete */}
{showCommands && (
<div className="absolute bottom-full left-0 right-0 mb-1 bg-popover border border-border rounded-lg shadow-xl overflow-hidden z-50 max-h-[320px] overflow-y-auto">
{Object.entries(groupedCommands).map(([category, cmds]) => (
<div key={category}>
<div className="px-3 py-1 text-[10px] uppercase tracking-wider text-muted-foreground/60 bg-muted/30 font-semibold">
{category}
</div>
{cmds.map((cmd) => {
const globalIdx = filteredCommands.indexOf(cmd);
return (
<button
key={cmd.name}
className={cn(
"w-full flex items-center gap-3 px-3 py-1.5 text-left transition-colors",
globalIdx === selectedCommand
? "bg-accent text-accent-foreground"
: "hover:bg-accent/50"
)}
onClick={() => {
setValue(cmd.name + " ");
setShowCommands(false);
textareaRef.current?.focus();
}}
>
<Slash className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-xs">{cmd.name}</span>
<span className="text-[11px] text-muted-foreground truncate">{cmd.description}</span>
</button>
);
})}
</div>
))}
</div>
)}
{/* Input area */}
<div className="flex items-end gap-2 bg-secondary/50 border border-border rounded-xl px-3 py-2 focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20 transition-all">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
placeholder ||
(isStreaming
? "Waiting for response..."
: "Message Claw... (/ for commands, Shift+Enter for newline)")
}
disabled={disabled || isStreaming}
rows={1}
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground resize-none outline-none min-h-[24px] max-h-[200px] py-1 font-mono"
/>
{isStreaming ? (
<button
onClick={onStop}
className="shrink-0 size-8 rounded-lg bg-destructive/20 text-destructive hover:bg-destructive/30 flex items-center justify-center transition-colors"
title="Stop generation"
>
<Square className="size-3.5" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={!value.trim() || disabled}
className={cn(
"shrink-0 size-8 rounded-lg flex items-center justify-center transition-all",
value.trim() && !disabled
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground"
)}
title="Send message (Enter)"
>
<ChevronUp className="size-4" />
</button>
)}
</div>
</div>
);
}