claw-web-v2 / client /src /components /TerminalPanel.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 {
X,
Terminal as TerminalIcon,
Trash2,
Copy,
Check,
Loader2,
} from "lucide-react";
interface TerminalLine {
id: number;
type: "input" | "output" | "error" | "info";
text: string;
timestamp: number;
}
interface TerminalPanelProps {
open: boolean;
onClose: () => void;
}
export function TerminalPanel({ open, onClose }: TerminalPanelProps) {
const [lines, setLines] = useState<TerminalLine[]>([
{
id: 0,
type: "info",
text: "Claw Terminal v1.0 — Type commands below. Use Ctrl+C to cancel.",
timestamp: Date.now(),
},
]);
const [input, setInput] = useState("");
const [isRunning, setIsRunning] = useState(false);
const [cwd, setCwd] = useState("/home/ubuntu");
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [copied, setCopied] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const lineIdRef = useRef(1);
// Auto-scroll to bottom
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [lines]);
// Focus input when panel opens
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
const addLine = useCallback(
(type: TerminalLine["type"], text: string) => {
const id = lineIdRef.current++;
setLines((prev) => [...prev, { id, type, text, timestamp: Date.now() }]);
},
[]
);
const executeCommand = useCallback(
async (cmd: string) => {
if (!cmd.trim()) return;
// Add to history
setHistory((prev) => [...prev.filter((h) => h !== cmd), cmd]);
setHistoryIndex(-1);
// Show the command
addLine("input", `${cwd}$ ${cmd}`);
// Handle built-in commands
if (cmd.trim() === "clear") {
setLines([]);
return;
}
if (cmd.trim().startsWith("cd ")) {
const target = cmd.trim().slice(3).trim();
// Resolve path
let newPath: string;
if (target === "~" || target === "") {
newPath = "/home/ubuntu";
} else if (target === "..") {
newPath = cwd.split("/").slice(0, -1).join("/") || "/";
} else if (target.startsWith("/")) {
newPath = target;
} else {
newPath = `${cwd}/${target}`.replace(/\/+/g, "/");
}
setCwd(newPath);
addLine("info", `Changed directory to ${newPath}`);
return;
}
setIsRunning(true);
try {
const response = await fetch("/api/chat/slash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
command: "/terminal",
args: `${cwd} ${cmd}`,
}),
});
const data = await response.json();
if (data.result) {
const outputLines = data.result.split("\n");
for (const line of outputLines) {
if (line.startsWith("STDERR:")) {
addLine("error", line.replace("STDERR: ", ""));
} else {
addLine("output", line);
}
}
}
if (data.error) {
addLine("error", data.error);
}
} catch (err: any) {
addLine("error", `Network error: ${err.message}`);
} finally {
setIsRunning(false);
}
},
[cwd, addLine]
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isRunning) {
executeCommand(input);
setInput("");
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (history.length > 0) {
const newIndex =
historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1);
setHistoryIndex(newIndex);
setInput(history[newIndex]);
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (historyIndex >= 0) {
const newIndex = historyIndex + 1;
if (newIndex >= history.length) {
setHistoryIndex(-1);
setInput("");
} else {
setHistoryIndex(newIndex);
setInput(history[newIndex]);
}
}
} else if (e.key === "c" && e.ctrlKey) {
if (isRunning) {
addLine("error", "^C");
setIsRunning(false);
}
}
};
const copyOutput = () => {
const text = lines.map((l) => l.text).join("\n");
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const clearTerminal = () => {
setLines([
{
id: lineIdRef.current++,
type: "info",
text: "Terminal cleared.",
timestamp: Date.now(),
},
]);
};
if (!open) return null;
return (
<div
className="fixed bottom-0 left-0 right-0 z-40 bg-background border-t border-border shadow-2xl"
style={{ height: "40vh" }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/30">
<div className="flex items-center gap-2">
<TerminalIcon className="size-4 text-green-400" />
<span className="text-xs font-semibold">Terminal</span>
<span className="text-[10px] text-muted-foreground font-mono">
{cwd}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={copyOutput}
className="size-6 rounded hover:bg-accent flex items-center justify-center text-muted-foreground hover:text-foreground"
title="Copy output"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
<button
onClick={clearTerminal}
className="size-6 rounded hover:bg-accent flex items-center justify-center text-muted-foreground hover:text-foreground"
title="Clear"
>
<Trash2 className="size-3.5" />
</button>
<button
onClick={onClose}
className="size-6 rounded hover:bg-accent flex items-center justify-center text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Terminal output */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-3 font-mono text-xs leading-relaxed"
style={{ height: "calc(40vh - 80px)" }}
onClick={() => inputRef.current?.focus()}
>
{lines.map((line) => (
<div
key={line.id}
className={cn(
"whitespace-pre-wrap break-all",
line.type === "input" && "text-green-400 font-bold",
line.type === "output" && "text-foreground/80",
line.type === "error" && "text-red-400",
line.type === "info" && "text-cyan-400/70 italic"
)}
>
{line.text}
</div>
))}
</div>
{/* Input line */}
<div className="flex items-center gap-2 px-3 py-2 border-t border-border bg-background">
<span className="text-green-400 font-mono text-xs font-bold shrink-0">
{cwd.replace("/home/ubuntu", "~")}$
</span>
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isRunning}
placeholder={isRunning ? "Running..." : "Type a command..."}
className="flex-1 bg-transparent text-xs font-mono text-foreground outline-none placeholder:text-muted-foreground"
autoFocus
/>
{isRunning && (
<Loader2 className="size-3.5 animate-spin text-primary shrink-0" />
)}
</div>
</div>
);
}