"use client" import type React from "react" import { useState, useEffect, useRef, useCallback } from "react" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Loader2, GripHorizontal, X } from "lucide-react" import { API_PORT } from "@/lib/api-config" import { useIsMobile } from "@/hooks/use-mobile" import WebLinksAddon from "xterm-addon-web-links" interface WebInteraction { type: "yesno" | "menu" | "msgbox" | "input" | "inputbox" id: string title: string message: string options?: Array<{ label: string; value: string }> default?: string } interface ScriptTerminalModalProps { open: boolean onClose: () => void scriptPath: string scriptName: string params?: Record title: string description: string } const processMessageText = (text: string): string => { return text .replace(/\\r\\n/g, "\n") // Windows line endings .replace(/\\n/g, "\n") // Unix line endings .replace(/\n\n+/g, "\n\n") // Multiple newlines to double newline } export default function ScriptTerminalModal({ open, onClose, scriptPath, scriptName, params = {}, title, description, }: ScriptTerminalModalProps) { const termRef = useRef(null) const wsRef = useRef(null) const fitAddonRef = useRef(null) const webLinksAddonRef = useRef(null) const sessionIdRef = useRef(Math.random().toString(36).substring(2, 8)) const [exitCode, setExitCode] = useState(null) const [isComplete, setIsComplete] = useState(false) const [isConnected, setIsConnected] = useState(false) const [currentInteraction, setCurrentInteraction] = useState(null) const [isWaitingNextInteraction, setIsWaitingNextInteraction] = useState(false) const [showingInteraction, setShowingInteraction] = useState(false) const [modalHeight, setModalHeight] = useState(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("scriptModalHeight") return saved ? Number.parseInt(saved) : 600 } return 600 }) const [isResizing, setIsResizing] = useState(false) const resizeHandlersRef = useRef<{ handleMove: ((e: MouseEvent | TouchEvent) => void) | null handleEnd: (() => void) | null }>({ handleMove: null, handleEnd: null }) const isMobile = useIsMobile() const terminalContainerRef = useCallback( (node: HTMLDivElement | null) => { if (!node || !open || termRef.current) { return } console.log("[v0] Terminal container mounted, initializing...") const initializeTerminal = async () => { console.log("[v0] Creating terminal instance...") const fontSize = window.innerWidth < 768 ? 12 : 16 const [Terminal, FitAddon] = await Promise.all([ import("xterm").then((mod) => mod.Terminal), import("xterm-addon-fit").then((mod) => mod.FitAddon), import("xterm/css/xterm.css"), ]).then(([Terminal, FitAddon]) => [Terminal, FitAddon]) const term = new Terminal({ rendererType: "dom", fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace', fontSize: fontSize, lineHeight: 1, cursorBlink: true, scrollback: 2000, disableStdin: false, customGlyphs: true, fontWeight: "500", fontWeightBold: "700", theme: { background: "#000000", foreground: "#ffffff", cursor: "#ffffff", cursorAccent: "#000000", black: "#2e3436", red: "#cc0000", green: "#4e9a06", yellow: "#c4a000", blue: "#3465a4", magenta: "#75507b", cyan: "#06989a", white: "#d3d7cf", brightBlack: "#555753", brightRed: "#ef2929", brightGreen: "#8ae234", brightYellow: "#fce94f", brightBlue: "#729fcf", brightMagenta: "#ad7fa8", brightCyan: "#34e2e2", brightWhite: "#eeeeec", }, }) const fitAddon = new FitAddon() const webLinksAddon = new WebLinksAddon() term.loadAddon(fitAddon) term.loadAddon(webLinksAddon) console.log("[v0] Opening terminal in container...") term.open(node) termRef.current = term fitAddonRef.current = fitAddon webLinksAddonRef.current = webLinksAddon setTimeout(() => { try { fitAddon.fit() console.log("[v0] Terminal fitted, cols:", term.cols, "rows:", term.rows) } catch (err) { console.log("[v0] Fit error:", err) } }, 50) const wsUrl = getScriptWebSocketUrl(sessionIdRef.current) console.log("[v0] Connecting to WebSocket:", wsUrl) const ws = new WebSocket(wsUrl) wsRef.current = ws ws.onopen = () => { console.log("[v0] WebSocket connected!") setIsConnected(true) const initMessage = { script_path: scriptPath, params: { EXECUTION_MODE: "web", ...params, }, } console.log("[v0] Sending init message:", initMessage) ws.send(JSON.stringify(initMessage)) setTimeout(() => { try { fitAddon.fit() const cols = term.cols const rows = term.rows console.log("[v0] Sending resize:", { cols, rows }) ws.send( JSON.stringify({ type: "resize", cols: cols, rows: rows, }), ) } catch (err) { console.log("[v0] Resize error:", err) } }, 100) } ws.onmessage = (event) => { console.log("[v0] WebSocket message received:", event.data.substring(0, 100)) try { const msg = JSON.parse(event.data) if (msg.type === "web_interaction" && msg.interaction) { console.log("[v0] Web interaction detected:", msg.interaction.type) setIsWaitingNextInteraction(false) if (resizeHandlersRef.current.handleMove) { clearTimeout(resizeHandlersRef.current.handleMove) } setCurrentInteraction({ type: msg.interaction.type, id: msg.interaction.id, title: msg.interaction.title || "", message: msg.interaction.message || "", options: msg.interaction.options, default: msg.interaction.default, }) setShowingInteraction(true) return } if (msg.type === "error") { console.log("[v0] Error message:", msg.message) term.writeln(`\x1b[31m${msg.message}\x1b[0m`) return } } catch { // Not JSON, es output normal de terminal } term.write(event.data) setIsWaitingNextInteraction(false) if (resizeHandlersRef.current.handleMove) { clearTimeout(resizeHandlersRef.current.handleMove) } } ws.onerror = (error) => { console.log("[v0] WebSocket error:", error) setIsConnected(false) term.writeln("\x1b[31mWebSocket error occurred\x1b[0m") } ws.onclose = (event) => { console.log("[v0] WebSocket closed:", event.code, event.reason) setIsConnected(false) term.writeln("\x1b[33mConnection closed\x1b[0m") if (!isComplete && event.code !== 1006) { setIsComplete(true) setExitCode(event.code === 1000 ? 0 : 1) } } term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(data) } }) const checkConnectionInterval = setInterval(() => { if (ws) { setIsConnected(ws.readyState === WebSocket.OPEN) } }, 500) let resizeTimeout: NodeJS.Timeout | null = null const resizeObserver = new ResizeObserver(() => { if (resizeTimeout) clearTimeout(resizeTimeout) resizeTimeout = setTimeout(() => { if (fitAddon && term && ws?.readyState === WebSocket.OPEN) { try { fitAddon.fit() ws.send( JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, }), ) } catch (err) { // Ignore } } }, 100) }) resizeObserver.observe(node) return () => { clearInterval(checkConnectionInterval) resizeObserver.disconnect() } } initializeTerminal() }, [open, scriptPath, params], ) useEffect(() => { if (!open) { if (wsRef.current) { wsRef.current.close() wsRef.current = null } if (termRef.current) { termRef.current.dispose() termRef.current = null } if (resizeHandlersRef.current.handleMove) { document.removeEventListener("mousemove", resizeHandlersRef.current.handleMove as any) document.removeEventListener("touchmove", resizeHandlersRef.current.handleMove as any) } if (resizeHandlersRef.current.handleEnd) { document.removeEventListener("mouseup", resizeHandlersRef.current.handleEnd) document.removeEventListener("touchend", resizeHandlersRef.current.handleEnd) } resizeHandlersRef.current = { handleMove: null, handleEnd: null } setModalHeight(() => { if (typeof window !== "undefined") { return Number.parseInt(localStorage.getItem("scriptModalHeight") || "600") } return 600 }) setIsComplete(false) setExitCode(null) setShowingInteraction(false) setCurrentInteraction(null) setIsWaitingNextInteraction(false) setIsConnected(false) } }, [open]) const getScriptWebSocketUrl = (sid: string): string => { if (typeof window === "undefined") { return `ws://localhost:${API_PORT}/ws/script/${sid}` } const { hostname, protocol } = window.location const wsProtocol = protocol === "https:" ? "wss:" : "ws:" return `${wsProtocol}//${hostname}:${API_PORT}/ws/script/${sid}` } const handleInteractionResponse = (value: string) => { if (!wsRef.current || !currentInteraction) { return } if (value === "cancel" || value === "") { setCurrentInteraction(null) setShowingInteraction(false) handleCloseModal() return } const response = JSON.stringify({ type: "interaction_response", id: currentInteraction.id, value: value, }) if (wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(response) } setCurrentInteraction(null) setShowingInteraction(false) setIsWaitingNextInteraction(true) } const handleCloseModal = () => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.close() } if (termRef.current) { termRef.current.dispose() } onClose() } const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => { e.preventDefault() e.stopPropagation() setIsResizing(true) const startY = "clientY" in e ? e.clientY : e.touches[0].clientY const startHeight = modalHeight const handleMove = (moveEvent: MouseEvent | TouchEvent) => { const currentY = moveEvent instanceof MouseEvent ? moveEvent.clientY : moveEvent.touches[0].clientY const deltaY = currentY - startY const newHeight = Math.max(300, Math.min(2400, startHeight + deltaY)) setModalHeight(newHeight) if (fitAddonRef.current && termRef.current && wsRef.current?.readyState === WebSocket.OPEN) { try { setTimeout(() => { fitAddonRef.current.fit() wsRef.current?.send( JSON.stringify({ type: "resize", cols: termRef.current.cols, rows: termRef.current.rows, }), ) }, 10) } catch (err) { // Ignore } } } const handleEnd = () => { setIsResizing(false) if (fitAddonRef.current && termRef.current && wsRef.current?.readyState === WebSocket.OPEN) { try { setTimeout(() => { fitAddonRef.current.fit() wsRef.current?.send( JSON.stringify({ type: "resize", cols: termRef.current.cols, rows: termRef.current.rows, }), ) }, 50) } catch (err) { // Ignore } } localStorage.setItem("scriptModalHeight", modalHeight.toString()) document.removeEventListener("mousemove", handleMove as any) document.removeEventListener("touchmove", handleMove as any) document.removeEventListener("mouseup", handleEnd) document.removeEventListener("touchend", handleEnd) resizeHandlersRef.current = { handleMove: null, handleEnd: null } } resizeHandlersRef.current = { handleMove, handleEnd } document.addEventListener("mousemove", handleMove as any) document.addEventListener("touchmove", handleMove as any, { passive: false }) document.addEventListener("mouseup", handleEnd) document.addEventListener("touchend", handleEnd) } return ( <> e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()} > {scriptName}
{isWaitingNextInteraction && !currentInteraction && (

Processing...

)}
{!isMobile && (
)}
{isConnected ? "Online" : "Offline"}
{showingInteraction && currentInteraction && ( e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()} hideClose > {currentInteraction.title}

{processMessageText(currentInteraction.message)}

{currentInteraction.type === "yesno" && (
)} {currentInteraction.type === "menu" && currentInteraction.options && (
{currentInteraction.options.map((option, index) => ( ))}
)} {(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
handleInteractionResponse(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { handleInteractionResponse(currentInteraction.default || "") } }} placeholder={currentInteraction.default || ""} className="transition-all duration-150" />
)} {currentInteraction.type === "msgbox" && (
)}
)} ) }