"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 { filterInitialSfHeader } from "@/lib/initial-sf-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 hideInitialSfHeader?: 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 hideInitialSfHeader?: boolean projectCwd?: string onConnectionChange: (connected: boolean) => void } function TerminalInstance({ sessionId, visible, command, commandArgs, isDark, fontSize, hideInitialSfHeader = 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(!hideInitialSfHeader) 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 = !hideInitialSfHeader initialHeaderBufferRef.current = "" }, [hideInitialSfHeader, 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 (hideInitialSfHeader && !initialHeaderSettledRef.current) { initialHeaderBufferRef.current += output const filtered = filterInitialSfHeader(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, hideInitialSfHeader, 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 SF…" : "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, hideInitialSfHeader = false, projectCwd, }: ShellTerminalProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme !== "light" const defaultId = sessionPrefix ?? (command ? "sf-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) => ( )} ))}
)}
) }