diff --git a/web/components/gsd/shell-terminal.tsx b/web/components/gsd/shell-terminal.tsx index 5560cb071..b2a3b29fc 100644 --- a/web/components/gsd/shell-terminal.tsx +++ b/web/components/gsd/shell-terminal.tsx @@ -74,7 +74,7 @@ const XTERM_LIGHT_THEME = { black: "#1a1a1a", red: "#dc2626", green: "#16a34a", - yellow: "#ca8a04", + yellow: "#a16207", blue: "#2563eb", magenta: "#9333ea", cyan: "#0891b2", @@ -82,7 +82,7 @@ const XTERM_LIGHT_THEME = { brightBlack: "#71717a", brightRed: "#ef4444", brightGreen: "#22c55e", - brightYellow: "#eab308", + brightYellow: "#92400e", brightBlue: "#3b82f6", brightMagenta: "#a855f7", brightCyan: "#06b6d4", diff --git a/web/components/gsd/tempCodeRunnerFile.tsx b/web/components/gsd/tempCodeRunnerFile.tsx new file mode 100644 index 000000000..637f4b60e --- /dev/null +++ b/web/components/gsd/tempCodeRunnerFile.tsx @@ -0,0 +1,784 @@ +"use client" + +import { useEffect, useRef, useCallback, useState } from "react" +import { useTheme } from "next-themes" +import { Plus, X, TerminalSquare, Loader2, ImagePlus } from "lucide-react" +import { cn } from "@/lib/utils" +import { validateImageFile } from "@/lib/image-utils" +import { filterInitialGsdHeader } from "@/lib/initial-gsd-header-filter" +import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url" +import { authFetch, appendAuthParam } from "@/lib/auth" +import "@xterm/xterm/css/xterm.css" + +type XTerminal = import("@xterm/xterm").Terminal +type XFitAddon = import("@xterm/addon-fit").FitAddon + +const MIN_TERMINAL_ATTACH_WIDTH = 180 +const MIN_TERMINAL_ATTACH_HEIGHT = 120 +const MIN_TERMINAL_ATTACH_COLS = 20 +const MIN_TERMINAL_ATTACH_ROWS = 8 + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface TerminalTab { + id: string + label: string + connected: boolean +} + +interface ShellTerminalProps { + className?: string + command?: string + commandArgs?: string[] + sessionPrefix?: string + hideSidebar?: boolean + fontSize?: number + hideInitialGsdHeader?: boolean + projectCwd?: string +} + +// ─── xterm themes ───────────────────────────────────────────────────────────── + +const XTERM_DARK_THEME = { + background: "#0a0a0a", + foreground: "#e4e4e7", + cursor: "#e4e4e7", + cursorAccent: "#0a0a0a", + selectionBackground: "#27272a", + selectionForeground: "#e4e4e7", + black: "#18181b", + red: "#ef4444", + green: "#22c55e", + yellow: "#eab308", + blue: "#3b82f6", + magenta: "#a855f7", + cyan: "#06b6d4", + white: "#e4e4e7", + brightBlack: "#52525b", + brightRed: "#f87171", + brightGreen: "#4ade80", + brightYellow: "#facc15", + brightBlue: "#60a5fa", + brightMagenta: "#c084fc", + brightCyan: "#22d3ee", + brightWhite: "#fafafa", +} as const + +const XTERM_LIGHT_THEME = { + background: "#f5f5f5", + foreground: "#1a1a1a", + cursor: "#1a1a1a", + cursorAccent: "#f5f5f5", + selectionBackground: "#d4d4d8", + selectionForeground: "#1a1a1a", + black: "#1a1a1a", + red: "#dc2626", + green: "#16a34a", + yellow: "#ca8a04", + blue: "#2563eb", + magenta: "#9333ea", + cyan: "#0891b2", + white: "#e4e4e7", + brightBlack: "#71717a", + brightRed: "#ef4444", + brightGreen: "#22c55e", + brightYellow: "#eab308", + brightBlue: "#3b82f6", + brightMagenta: "#a855f7", + brightCyan: "#06b6d4", + brightWhite: "#fafafa", +} as const + +function getXtermTheme(isDark: boolean) { + return isDark ? XTERM_DARK_THEME : XTERM_LIGHT_THEME +} + +function getXtermOptions(isDark: boolean, fontSize?: number) { + return { + cursorBlink: true, + cursorStyle: "bar" as const, + fontSize: fontSize ?? 13, + fontFamily: + "'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, 'Courier New', monospace", + lineHeight: 1.35, + letterSpacing: 0, + theme: getXtermTheme(isDark), + allowProposedApi: true, + scrollback: 10000, + convertEol: false, + } +} + +function getRenderableTerminalSize(container: HTMLDivElement | null, terminal: XTerminal | null): { cols: number; rows: number } | null { + if (!container || !terminal) return null + + const rect = container.getBoundingClientRect() + if (rect.width < MIN_TERMINAL_ATTACH_WIDTH || rect.height < MIN_TERMINAL_ATTACH_HEIGHT) { + return null + } + + if (terminal.cols < MIN_TERMINAL_ATTACH_COLS || terminal.rows < MIN_TERMINAL_ATTACH_ROWS) { + return null + } + + return { cols: terminal.cols, rows: terminal.rows } +} + +async function settleTerminalLayout( + container: HTMLDivElement | null, + terminal: XTerminal | null, + fitAddon: XFitAddon | null, + isDisposed: () => boolean, +): Promise<{ cols: number; rows: number } | null> { + if (typeof document !== "undefined" && "fonts" in document) { + try { + await Promise.race([ + document.fonts.ready, + new Promise((resolve) => setTimeout(resolve, 1000)), + ]) + } catch { + // Ignore font loading failures and fall through to repeated fit attempts. + } + } + + for (let attempt = 0; attempt < 12; attempt++) { + if (isDisposed()) return null + + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()) + }) + + if (isDisposed()) return null + + try { + fitAddon?.fit() + } catch { + /* hidden or detached */ + } + + const size = getRenderableTerminalSize(container, terminal) + if (size) { + return size + } + + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + return getRenderableTerminalSize(container, terminal) +} + +function deriveCommandLabel(command?: string): string { + if (!command?.trim()) return "zsh" + const token = command.trim().split(/\s+/)[0] || command + const normalized = token.replace(/\\/g, "/") + const parts = normalized.split("/") + return parts[parts.length - 1] || token +} + +// ─── Single terminal instance (internal) ────────────────────────────────────── + +interface TerminalInstanceProps { + sessionId: string + visible: boolean + command?: string + commandArgs?: string[] + isDark: boolean + fontSize?: number + hideInitialGsdHeader?: boolean + projectCwd?: string + onConnectionChange: (connected: boolean) => void +} + +function TerminalInstance({ + sessionId, + visible, + command, + commandArgs, + isDark, + fontSize, + hideInitialGsdHeader = false, + projectCwd, + onConnectionChange, +}: TerminalInstanceProps) { + const containerRef = useRef(null) + const termRef = useRef(null) + const fitAddonRef = useRef(null) + const eventSourceRef = useRef(null) + const inputQueueRef = useRef([]) + const flushingRef = useRef(false) + const resizeTimeoutRef = useRef | null>(null) + const onConnectionChangeRef = useRef(onConnectionChange) + const initialHeaderSettledRef = useRef(!hideInitialGsdHeader) + const initialHeaderBufferRef = useRef("") + const commandArgsKey = (commandArgs ?? []).join("\u0000") + const [hasOutput, setHasOutput] = useState(false) + + const sendResize = useCallback( + (cols: number, rows: number) => { + if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current) + resizeTimeoutRef.current = setTimeout(() => { + void authFetch(buildProjectPath("/api/terminal/resize", projectCwd), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: sessionId, cols, rows }), + }) + }, 100) + }, + [projectCwd, sessionId], + ) + + const flushInputQueue = useCallback(async () => { + if (flushingRef.current) return + flushingRef.current = true + while (inputQueueRef.current.length > 0) { + const data = inputQueueRef.current.shift()! + try { + await authFetch(buildProjectPath("/api/terminal/input", projectCwd), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: sessionId, data }), + }) + } catch { + inputQueueRef.current.unshift(data) + break + } + } + flushingRef.current = false + }, [projectCwd, sessionId]) + + const sendInput = useCallback( + (data: string) => { + inputQueueRef.current.push(data) + void flushInputQueue() + }, + [flushInputQueue], + ) + + useEffect(() => { + onConnectionChangeRef.current = onConnectionChange + }, [onConnectionChange]) + + useEffect(() => { + initialHeaderSettledRef.current = !hideInitialGsdHeader + initialHeaderBufferRef.current = "" + }, [hideInitialGsdHeader, sessionId]) + + // Update xterm theme when isDark changes + useEffect(() => { + if (termRef.current) { + termRef.current.options.theme = getXtermTheme(isDark) + } + }, [isDark]) + + // Update xterm font size when fontSize changes + useEffect(() => { + if (termRef.current) { + termRef.current.options.fontSize = fontSize ?? 13 + try { + fitAddonRef.current?.fit() + if (termRef.current) { + sendResize(termRef.current.cols, termRef.current.rows) + } + } catch { + /* not visible yet */ + } + } + }, [fontSize, sendResize]) + + // Re-fit when visibility changes + useEffect(() => { + if (visible && fitAddonRef.current && termRef.current) { + // Small delay to let the DOM settle + const t = setTimeout(() => { + try { + fitAddonRef.current?.fit() + if (termRef.current) { + sendResize(termRef.current.cols, termRef.current.rows) + } + } catch { + /* not visible yet */ + } + }, 50) + return () => clearTimeout(t) + } + }, [visible, sendResize]) + + useEffect(() => { + if (!containerRef.current) return + + let disposed = false + let terminal: XTerminal | null = null + let fitAddon: XFitAddon | null = null + let resizeObserver: ResizeObserver | null = null + + const init = async () => { + const [{ Terminal }, { FitAddon }] = await Promise.all([ + import("@xterm/xterm"), + import("@xterm/addon-fit"), + ]) + + if (disposed) return + + terminal = new Terminal(getXtermOptions(isDark, fontSize)) + fitAddon = new FitAddon() + terminal.loadAddon(fitAddon) + terminal.open(containerRef.current!) + + termRef.current = terminal + fitAddonRef.current = fitAddon + + await settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed) + if (disposed) return + + terminal.onData((data) => sendInput(data)) + terminal.onBinary((data) => sendInput(data)) + + // SSE stream + const streamUrl = buildProjectAbsoluteUrl( + "/api/terminal/stream", + window.location.origin, + projectCwd, + ) + streamUrl.searchParams.set("id", sessionId) + if (command) streamUrl.searchParams.set("command", command) + for (const arg of commandArgs ?? []) { + streamUrl.searchParams.append("arg", arg) + } + const es = new EventSource(appendAuthParam(streamUrl.toString())) + eventSourceRef.current = es + + es.onmessage = (event) => { + try { + const msg = JSON.parse(event.data) as { + type: string + data?: string + } + if (msg.type === "connected") { + onConnectionChangeRef.current(true) + void settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed).then((size) => { + if (!size) return + sendResize(size.cols, size.rows) + }) + } else if (msg.type === "output" && msg.data) { + let output = msg.data + + if (hideInitialGsdHeader && !initialHeaderSettledRef.current) { + initialHeaderBufferRef.current += output + const filtered = filterInitialGsdHeader(initialHeaderBufferRef.current) + + if (filtered.status === "needs-more") { + return + } + + initialHeaderSettledRef.current = true + initialHeaderBufferRef.current = "" + output = filtered.text + } + + if (output) { + terminal?.write(output) + setHasOutput(true) + } + } + } catch { + /* malformed */ + } + } + + es.onerror = () => onConnectionChangeRef.current(false) + + // Resize observer + resizeObserver = new ResizeObserver(() => { + if (disposed) return + try { + fitAddon?.fit() + if (terminal) sendResize(terminal.cols, terminal.rows) + } catch { + /* not visible */ + } + }) + resizeObserver.observe(containerRef.current!) + } + + void init() + + return () => { + disposed = true + if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current) + eventSourceRef.current?.close() + eventSourceRef.current = null + resizeObserver?.disconnect() + terminal?.dispose() + termRef.current = null + fitAddonRef.current = null + } + }, [sessionId, command, commandArgs, commandArgsKey, fontSize, hideInitialGsdHeader, isDark, projectCwd, sendInput, sendResize]) + + // Focus on click + const wrapperRef = useRef(null) + const handleClick = useCallback(() => { + termRef.current?.focus() + }, []) + + // Shift+Enter → newline (native DOM, capture phase) + // xterm.js sends \r for both Enter and Shift+Enter. The pi TUI editor + // recognizes \n (LF) as "insert newline". + useEffect(() => { + const el = wrapperRef.current + if (!el) return + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) { + e.preventDefault() + e.stopPropagation() + sendInput("\n") + } + } + + el.addEventListener("keydown", onKeyDown, true) + return () => el.removeEventListener("keydown", onKeyDown, true) + }, [sendInput]) + + // Auto-focus when this tab becomes visible + useEffect(() => { + if (visible) { + // Small delay to let layout settle + const t = setTimeout(() => termRef.current?.focus(), 80) + return () => clearTimeout(t) + } + }, [visible]) + + return ( +
+ {/* Loading overlay — visible until first output arrives */} + {!hasOutput && ( +
+ + + {command ? "Starting GSD…" : "Connecting…"} + +
+ )} +
+
+ ) +} + +// ─── Image upload helpers ───────────────────────────────────────────────────── + +const ALLOWED_IMAGE_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +]) + +/** + * Upload an image file to the server's temp directory and inject the `@filepath` + * text into the PTY session's stdin. + * + * Observability: + * - console.warn on client-side validation failure + * - console.error on upload or inject failure + */ +async function uploadAndInjectImage(file: File, sessionId: string, projectCwd?: string): Promise { + // Client-side validation + const validation = validateImageFile(file) + if (!validation.valid) { + console.warn("[terminal-upload] validation failed:", validation.error) + return + } + + // Upload to temp dir + const formData = new FormData() + formData.append("file", file) + + let uploadPath: string + try { + const res = await authFetch(buildProjectPath("/api/terminal/upload", projectCwd), { + method: "POST", + body: formData, + }) + const data = await res.json() as { ok?: boolean; path?: string; error?: string } + if (!res.ok || !data.path) { + console.error("[terminal-upload] upload failed:", data.error ?? `HTTP ${res.status}`) + return + } + uploadPath = data.path + } catch (err) { + console.error("[terminal-upload] upload request failed:", err) + return + } + + // Inject @filepath into PTY stdin + try { + const res = await authFetch(buildProjectPath("/api/terminal/input", projectCwd), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: sessionId, data: `@${uploadPath} ` }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) as { error?: string } + console.error("[terminal-upload] inject failed:", data.error ?? `HTTP ${res.status}`) + } + } catch (err) { + console.error("[terminal-upload] inject request failed:", err) + } +} + +// ─── Multi-instance terminal panel ──────────────────────────────────────────── + +export function ShellTerminal({ + className, + command, + commandArgs, + sessionPrefix, + hideSidebar = false, + fontSize, + hideInitialGsdHeader = false, + projectCwd, +}: ShellTerminalProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme !== "light" + const defaultId = sessionPrefix ?? (command ? "gsd-default" : "default") + const commandLabel = deriveCommandLabel(command) + const [tabs, setTabs] = useState([ + { id: defaultId, label: commandLabel, connected: false }, + ]) + const [activeTabId, setActiveTabId] = useState(defaultId) + const [isDragOver, setIsDragOver] = useState(false) + const terminalAreaRef = useRef(null) + + // ── Drag-and-drop handlers (native DOM, capture phase) ────────────────── + // React synthetic events don't reliably fire through xterm's internal DOM. + // Native capture-phase listeners intercept before xterm can swallow them — + // same pattern used for paste below. + + useEffect(() => { + const el = terminalAreaRef.current + if (!el) return + + let counter = 0 + + const onDragEnter = (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + counter += 1 + if (counter === 1) setIsDragOver(true) + } + + const onDragOver = (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + } + + const onDragLeave = (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + counter -= 1 + if (counter <= 0) { + counter = 0 + setIsDragOver(false) + } + } + + const onDrop = (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + counter = 0 + setIsDragOver(false) + + if (!activeTabId) return + const files = Array.from(e.dataTransfer?.files ?? []) + const imageFile = files.find((f) => ALLOWED_IMAGE_TYPES.has(f.type)) + if (imageFile) { + void uploadAndInjectImage(imageFile, activeTabId, projectCwd) + } + } + + el.addEventListener("dragenter", onDragEnter, true) + el.addEventListener("dragover", onDragOver, true) + el.addEventListener("dragleave", onDragLeave, true) + el.addEventListener("drop", onDrop, true) + return () => { + el.removeEventListener("dragenter", onDragEnter, true) + el.removeEventListener("dragover", onDragOver, true) + el.removeEventListener("dragleave", onDragLeave, true) + el.removeEventListener("drop", onDrop, true) + } + }, [activeTabId, projectCwd]) + + // ── Paste handler for images ────────────────────────────────────────────── + + useEffect(() => { + const el = terminalAreaRef.current + if (!el) return + + const handlePaste = (e: ClipboardEvent) => { + if (!e.clipboardData) return + const files = Array.from(e.clipboardData.files) + const imageFile = files.find((f) => ALLOWED_IMAGE_TYPES.has(f.type)) + if (imageFile) { + e.preventDefault() + e.stopPropagation() + if (activeTabId) { + void uploadAndInjectImage(imageFile, activeTabId, projectCwd) + } + } + // If no image files, don't prevent default — let xterm.js handle text paste + } + + el.addEventListener("paste", handlePaste, true) // capture phase to fire before xterm + return () => el.removeEventListener("paste", handlePaste, true) + }, [activeTabId, projectCwd]) + + const createTab = useCallback(async () => { + try { + const res = await authFetch(buildProjectPath("/api/terminal/sessions", projectCwd), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(command ? { command } : {}), + }) + const data = (await res.json()) as { id: string } + const newTab: TerminalTab = { + id: data.id, + label: commandLabel, + connected: false, + } + setTabs((prev) => [...prev, newTab]) + setActiveTabId(data.id) + } catch { + /* network error */ + } + }, [command, commandLabel, projectCwd]) + + const closeTab = useCallback( + (id: string) => { + // Don't close the last tab + if (tabs.length <= 1) return + const deleteUrl = buildProjectAbsoluteUrl("/api/terminal/sessions", window.location.origin, projectCwd) + deleteUrl.searchParams.set("id", id) + void authFetch(deleteUrl.toString(), { + method: "DELETE", + }) + const remaining = tabs.filter((t) => t.id !== id) + setTabs(remaining) + if (activeTabId === id) { + setActiveTabId(remaining[remaining.length - 1]?.id ?? defaultId) + } + }, + [tabs, activeTabId, defaultId, projectCwd], + ) + + const updateConnection = useCallback( + (id: string, connected: boolean) => { + setTabs((prev) => + prev.map((t) => (t.id === id ? { ...t, connected } : t)), + ) + }, + [], + ) + + return ( +
+ {/* Terminal area — receives drag/drop and paste for images */} +
+ {tabs.map((tab) => ( + updateConnection(tab.id, c)} + /> + ))} + + {/* Drop overlay */} + {isDragOver && ( +
+ + Drop image here +
+ )} +
+ + {!hideSidebar && ( +
+ {/* New terminal button */} + + +
+ + {/* Tab list */} +
+ {tabs.map((tab, index) => ( + + )} + + ))} +
+
+ )} +
+ ) +}