Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |