diff --git a/ui/src/components/ChatInterface.tsx b/ui/src/components/ChatInterface.tsx index 30ba8375787b9080d04b0e144fd5a0aeef85a8d2..2617225e0c3a14a131abc71281add338c1eb08a9 100644 --- a/ui/src/components/ChatInterface.tsx +++ b/ui/src/components/ChatInterface.tsx @@ -27,18 +27,10 @@ import SubagentTool from "./SubagentTool"; import OutputIframeTool from "./OutputIframeTool"; import DirectoryPickerModal from "./DirectoryPickerModal"; import { useVersionChecker } from "./VersionChecker"; -import TerminalWidget from "./TerminalWidget"; +import TerminalPanel, { EphemeralTerminal } from "./TerminalPanel"; import ModelPicker from "./ModelPicker"; import SystemPromptView from "./SystemPromptView"; -// Ephemeral terminal instance (not persisted to database) -interface EphemeralTerminal { - id: string; - command: string; - cwd: string; - createdAt: Date; -} - interface ContextUsageBarProps { contextWindowSize: number; maxContextTokens: number; @@ -1279,18 +1271,7 @@ function ChatInterface({ }; const renderMessages = () => { - // Build ephemeral terminal elements first - they should always render - const terminalElements = ephemeralTerminals.map((terminal) => ( - setEphemeralTerminals((prev) => prev.filter((t) => t.id !== terminal.id))} - /> - )); - - if (messages.length === 0 && ephemeralTerminals.length === 0) { + if (messages.length === 0) { const proxyURL = `https://${hostname}/`; return (
@@ -1338,11 +1319,6 @@ function ChatInterface({ ); } - // If we have terminals but no messages, just show terminals - if (messages.length === 0) { - return terminalElements; - } - const coalescedItems = processMessages(); const rendered = coalescedItems.map((item, index) => { @@ -1381,11 +1357,9 @@ function ChatInterface({ // Find system message to render at the top const systemMessage = messages.find((m) => m.type === "system"); - // Append ephemeral terminals at the end return [ systemMessage && , ...rendered, - ...terminalElements, ]; }; @@ -1682,6 +1656,14 @@ function ChatInterface({ )}
+ {/* Terminal Panel - between messages and status bar */} + setEphemeralTerminals((prev) => prev.filter((t) => t.id !== id))} + onCloseAll={() => setEphemeralTerminals([])} + onInsertIntoInput={handleInsertFromTerminal} + /> + {/* Unified Status Bar */}
diff --git a/ui/src/components/TerminalPanel.tsx b/ui/src/components/TerminalPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..966cab9068b98a18f2ccdc48df0560a02bf105f3 --- /dev/null +++ b/ui/src/components/TerminalPanel.tsx @@ -0,0 +1,689 @@ +import React, { useEffect, useRef, useState, useCallback } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import "@xterm/xterm/css/xterm.css"; + +function base64ToUint8Array(base64String: string): Uint8Array { + // @ts-expect-error Uint8Array.fromBase64 is a newer API + if (Uint8Array.fromBase64) { + // @ts-expect-error Uint8Array.fromBase64 is a newer API + return Uint8Array.fromBase64(base64String); + } + const binaryString = atob(base64String); + return Uint8Array.from(binaryString, (char) => char.charCodeAt(0)); +} + +export interface EphemeralTerminal { + id: string; + command: string; + cwd: string; + createdAt: Date; +} + +interface TerminalPanelProps { + terminals: EphemeralTerminal[]; + onClose: (id: string) => void; + onCloseAll: () => void; + onInsertIntoInput?: (text: string) => void; +} + +// Theme colors for xterm.js +function getTerminalTheme(isDark: boolean): Record { + if (isDark) { + return { + background: "#1a1b26", + foreground: "#c0caf5", + cursor: "#c0caf5", + cursorAccent: "#1a1b26", + selectionBackground: "#364a82", + selectionForeground: "#c0caf5", + black: "#32344a", + red: "#f7768e", + green: "#9ece6a", + yellow: "#e0af68", + blue: "#7aa2f7", + magenta: "#ad8ee6", + cyan: "#449dab", + white: "#9699a8", + brightBlack: "#444b6a", + brightRed: "#ff7a93", + brightGreen: "#b9f27c", + brightYellow: "#ff9e64", + brightBlue: "#7da6ff", + brightMagenta: "#bb9af7", + brightCyan: "#0db9d7", + brightWhite: "#acb0d0", + }; + } + return { + background: "#f8f9fa", + foreground: "#383a42", + cursor: "#526eff", + cursorAccent: "#f8f9fa", + selectionBackground: "#bfceff", + selectionForeground: "#383a42", + black: "#383a42", + red: "#e45649", + green: "#50a14f", + yellow: "#c18401", + blue: "#4078f2", + magenta: "#a626a4", + cyan: "#0184bc", + white: "#a0a1a7", + brightBlack: "#4f525e", + brightRed: "#e06c75", + brightGreen: "#98c379", + brightYellow: "#e5c07b", + brightBlue: "#61afef", + brightMagenta: "#c678dd", + brightCyan: "#56b6c2", + brightWhite: "#ffffff", + }; +} + +type TermStatus = "connecting" | "running" | "exited" | "error"; + +// SVG icons +const CopyIcon = () => ( + + + + +); + +const CopyAllIcon = () => ( + + + + + +); + +const InsertIcon = () => ( + + + + + +); + +const InsertAllIcon = () => ( + + + + + + +); + +const CheckIcon = () => ( + + + +); + +const CloseIcon = () => ( + + + + +); + +function ActionButton({ + onClick, + title, + children, + feedback, +}: { + onClick: () => void; + title: string; + children: React.ReactNode; + feedback?: boolean; +}) { + return ( + + ); +} + +export default function TerminalPanel({ + terminals, + onClose, + onCloseAll, + onInsertIntoInput, +}: TerminalPanelProps) { + const [activeTabId, setActiveTabId] = useState(null); + const [height, setHeight] = useState(300); + const [userResized, setUserResized] = useState(false); + const [copyFeedback, setCopyFeedback] = useState(null); + const [statusMap, setStatusMap] = useState< + Map + >(new Map()); + const isResizingRef = useRef(false); + const startYRef = useRef(0); + const startHeightRef = useRef(0); + + // Detect dark mode + const [isDark, setIsDark] = useState( + document.documentElement.getAttribute("data-theme") === "dark", + ); + + useEffect(() => { + const observer = new MutationObserver(() => { + setIsDark(document.documentElement.getAttribute("data-theme") === "dark"); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, []); + + // Auto-select newest tab when a new terminal is added + useEffect(() => { + if (terminals.length > 0) { + const lastTerminal = terminals[terminals.length - 1]; + setActiveTabId(lastTerminal.id); + setUserResized(false); // Reset so new terminal auto-sizes + } else { + setActiveTabId(null); + } + }, [terminals.length]); // eslint-disable-line react-hooks/exhaustive-deps + + // If active tab got closed, switch to the last remaining + useEffect(() => { + if (activeTabId && !terminals.find((t) => t.id === activeTabId)) { + if (terminals.length > 0) { + setActiveTabId(terminals[terminals.length - 1].id); + } else { + setActiveTabId(null); + } + } + }, [terminals, activeTabId]); + + const handleStatusChange = useCallback( + (id: string, status: TermStatus, exitCode: number | null, contentLines: number) => { + setStatusMap((prev) => { + const next = new Map(prev); + const existing = next.get(id); + // Don't overwrite exit status with ws.onclose + if ( + existing && + existing.status === "exited" && + status === "exited" && + contentLines === -1 + ) { + return prev; + } + const lines = contentLines === -1 ? existing?.contentLines || 0 : contentLines; + next.set(id, { + status, + exitCode: exitCode ?? existing?.exitCode ?? null, + contentLines: lines, + }); + return next; + }); + }, + [], + ); + + // Auto-size based on content when the active terminal exits or has short output + useEffect(() => { + if (userResized || !activeTabId) return; + const info = statusMap.get(activeTabId); + if (!info) return; + + const cellHeight = 17; // approximate + const minHeight = 60; + const maxHeight = 500; + const tabBarHeight = 38; + + if (info.status === "exited" || info.status === "error") { + // Shrink to fit content + const needed = Math.min( + maxHeight, + Math.max(minHeight, info.contentLines * cellHeight + tabBarHeight + 16), + ); + setHeight(needed); + } else if (info.status === "running") { + // While running, grow if needed but don't shrink + const needed = Math.min( + maxHeight, + Math.max(minHeight, info.contentLines * cellHeight + tabBarHeight + 16), + ); + setHeight((prev) => Math.max(prev, needed)); + } + }, [statusMap, activeTabId, userResized]); + + // Resize drag + const handleResizeMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + isResizingRef.current = true; + startYRef.current = e.clientY; + startHeightRef.current = height; + + const handleMouseMove = (e: MouseEvent) => { + if (!isResizingRef.current) return; + // Dragging up increases height + const delta = startYRef.current - e.clientY; + setHeight(Math.max(80, Math.min(800, startHeightRef.current + delta))); + setUserResized(true); + }; + + const handleMouseUp = () => { + isResizingRef.current = false; + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }, + [height], + ); + + const showFeedback = useCallback((type: string) => { + setCopyFeedback(type); + setTimeout(() => setCopyFeedback(null), 1500); + }, []); + + // Get the xterm instance for the active tab + const xtermRegistryRef = useRef>(new Map()); + + const registerXterm = useCallback((id: string, xterm: Terminal) => { + xtermRegistryRef.current.set(id, xterm); + }, []); + + const unregisterXterm = useCallback((id: string) => { + xtermRegistryRef.current.delete(id); + }, []); + + const getBufferText = useCallback( + (mode: "screen" | "all"): string => { + if (!activeTabId) return ""; + const xterm = xtermRegistryRef.current.get(activeTabId); + if (!xterm) return ""; + + const lines: string[] = []; + const buffer = xterm.buffer.active; + + if (mode === "screen") { + const startRow = buffer.viewportY; + for (let i = 0; i < xterm.rows; i++) { + const line = buffer.getLine(startRow + i); + if (line) lines.push(line.translateToString(true)); + } + } else { + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + if (line) lines.push(line.translateToString(true)); + } + } + return lines.join("\n").trimEnd(); + }, + [activeTabId], + ); + + const copyScreen = useCallback(() => { + navigator.clipboard.writeText(getBufferText("screen")); + showFeedback("copyScreen"); + }, [getBufferText, showFeedback]); + + const copyAll = useCallback(() => { + navigator.clipboard.writeText(getBufferText("all")); + showFeedback("copyAll"); + }, [getBufferText, showFeedback]); + + const insertScreen = useCallback(() => { + if (onInsertIntoInput) { + onInsertIntoInput(getBufferText("screen")); + showFeedback("insertScreen"); + } + }, [getBufferText, onInsertIntoInput, showFeedback]); + + const insertAll = useCallback(() => { + if (onInsertIntoInput) { + onInsertIntoInput(getBufferText("all")); + showFeedback("insertAll"); + } + }, [getBufferText, onInsertIntoInput, showFeedback]); + + const handleCloseActive = useCallback(() => { + if (activeTabId) onClose(activeTabId); + }, [activeTabId, onClose]); + + if (terminals.length === 0) return null; + + const activeInfo = activeTabId ? statusMap.get(activeTabId) : null; + + // Truncate command for tab label + const tabLabel = (cmd: string) => { + // Show first word or first 30 chars + const firstWord = cmd.split(/\s+/)[0]; + if (firstWord.length > 30) return firstWord.substring(0, 27) + "..."; + return firstWord; + }; + + return ( +
+ {/* Resize handle at top */} +
+
+
+ + {/* Tab bar + actions */} +
+
+ {terminals.map((t) => { + const info = statusMap.get(t.id); + const isActive = t.id === activeTabId; + return ( +
setActiveTabId(t.id)} + title={t.command} + > + {info?.status === "running" && ( + + )} + {info?.status === "exited" && info.exitCode === 0 && ( + + )} + {info?.status === "exited" && info.exitCode !== 0 && ( + + )} + {info?.status === "error" && ( + + )} + {tabLabel(t.command)} + +
+ ); + })} +
+ + {/* Action buttons */} +
+ + {copyFeedback === "copyScreen" ? : } + + + {copyFeedback === "copyAll" ? : } + + {onInsertIntoInput && ( + <> + + {copyFeedback === "insertScreen" ? : } + + + {copyFeedback === "insertAll" ? : } + + + )} +
+ + + +
+
+ + {/* Terminal content area */} +
+ {terminals.map((t) => ( + + ))} +
+
+ ); +} + +// Wrapper that also registers the xterm instance +function TerminalInstanceWithRegistry({ + term, + isVisible, + isDark, + onStatusChange, + onRegister, + onUnregister, +}: { + term: EphemeralTerminal; + isVisible: boolean; + isDark: boolean; + onStatusChange: ( + id: string, + status: TermStatus, + exitCode: number | null, + contentLines: number, + ) => void; + onRegister: (id: string, xterm: Terminal) => void; + onUnregister: (id: string) => void; +}) { + const containerRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const wsRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + const xterm = new Terminal({ + cursorBlink: true, + fontSize: 13, + fontFamily: 'Consolas, "Liberation Mono", Menlo, Courier, monospace', + theme: getTerminalTheme(isDark), + scrollback: 10000, + }); + xtermRef.current = xterm; + + const fitAddon = new FitAddon(); + fitAddonRef.current = fitAddon; + xterm.loadAddon(fitAddon); + xterm.loadAddon(new WebLinksAddon()); + + xterm.open(containerRef.current); + fitAddon.fit(); + onRegister(term.id, xterm); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/api/exec-ws?cmd=${encodeURIComponent(term.command)}&cwd=${encodeURIComponent(term.cwd)}`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + ws.send(JSON.stringify({ type: "init", cols: xterm.cols, rows: xterm.rows })); + onStatusChange(term.id, "running", null, 0); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === "output" && msg.data) { + xterm.write(base64ToUint8Array(msg.data)); + const buf = xterm.buffer.active; + let lines = 0; + for (let i = buf.length - 1; i >= 0; i--) { + const line = buf.getLine(i); + if (line && line.translateToString(true).trim()) { + lines = i + 1; + break; + } + } + onStatusChange(term.id, "running", null, lines); + } else if (msg.type === "exit") { + const code = parseInt(msg.data, 10) || 0; + const buf = xterm.buffer.active; + let lines = 0; + for (let i = buf.length - 1; i >= 0; i--) { + const line = buf.getLine(i); + if (line && line.translateToString(true).trim()) { + lines = i + 1; + break; + } + } + onStatusChange(term.id, "exited", code, lines); + } else if (msg.type === "error") { + xterm.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`); + onStatusChange(term.id, "error", null, 0); + } + } catch (err) { + console.error("Failed to parse terminal message:", err); + } + }; + + ws.onerror = (event) => console.error("WebSocket error:", event); + ws.onclose = () => { + onStatusChange(term.id, "exited", null, -1); + }; + + xterm.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "input", data })); + } + }); + + const ro = new ResizeObserver(() => { + if (!fitAddonRef.current) return; + fitAddonRef.current.fit(); + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && xtermRef.current) { + wsRef.current.send( + JSON.stringify({ + type: "resize", + cols: xtermRef.current.cols, + rows: xtermRef.current.rows, + }), + ); + } + }); + ro.observe(containerRef.current); + + return () => { + ro.disconnect(); + ws.close(); + xterm.dispose(); + onUnregister(term.id); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [term.id, term.command, term.cwd]); + + // Update theme + useEffect(() => { + if (xtermRef.current) { + xtermRef.current.options.theme = getTerminalTheme(isDark); + } + }, [isDark]); + + // Refit when visibility changes + useEffect(() => { + if (isVisible && fitAddonRef.current) { + setTimeout(() => fitAddonRef.current?.fit(), 20); + } + }, [isVisible]); + + return ( +
+ ); +} diff --git a/ui/src/components/TerminalWidget.tsx b/ui/src/components/TerminalWidget.tsx deleted file mode 100644 index 223c258e99bfbd3ece325330b883701947217248..0000000000000000000000000000000000000000 --- a/ui/src/components/TerminalWidget.tsx +++ /dev/null @@ -1,726 +0,0 @@ -import React, { useEffect, useRef, useState, useCallback } from "react"; -import { Terminal } from "@xterm/xterm"; -import { FitAddon } from "@xterm/addon-fit"; -import { WebLinksAddon } from "@xterm/addon-web-links"; -import "@xterm/xterm/css/xterm.css"; - -function base64ToUint8Array(base64String: string): Uint8Array { - // This isn't yet available in Chrome, but Safari has it! - // @ts-expect-error Uint8Array.fromBase64 is a newer API - if (Uint8Array.fromBase64) { - // @ts-expect-error Uint8Array.fromBase64 is a newer API - return Uint8Array.fromBase64(base64String); - } - const binaryString = atob(base64String); - return Uint8Array.from(binaryString, (char) => char.charCodeAt(0)); -} - -interface TerminalWidgetProps { - command: string; - cwd: string; - onInsertIntoInput?: (text: string) => void; - onClose?: () => void; -} - -// Theme colors for xterm.js -function getTerminalTheme(isDark: boolean): Record { - if (isDark) { - return { - background: "#1a1b26", - foreground: "#c0caf5", - cursor: "#c0caf5", - cursorAccent: "#1a1b26", - selectionBackground: "#364a82", - selectionForeground: "#c0caf5", - black: "#32344a", - red: "#f7768e", - green: "#9ece6a", - yellow: "#e0af68", - blue: "#7aa2f7", - magenta: "#ad8ee6", - cyan: "#449dab", - white: "#9699a8", - brightBlack: "#444b6a", - brightRed: "#ff7a93", - brightGreen: "#b9f27c", - brightYellow: "#ff9e64", - brightBlue: "#7da6ff", - brightMagenta: "#bb9af7", - brightCyan: "#0db9d7", - brightWhite: "#acb0d0", - }; - } - // Light theme - return { - background: "#f8f9fa", - foreground: "#383a42", - cursor: "#526eff", - cursorAccent: "#f8f9fa", - selectionBackground: "#bfceff", - selectionForeground: "#383a42", - black: "#383a42", - red: "#e45649", - green: "#50a14f", - yellow: "#c18401", - blue: "#4078f2", - magenta: "#a626a4", - cyan: "#0184bc", - white: "#a0a1a7", - brightBlack: "#4f525e", - brightRed: "#e06c75", - brightGreen: "#98c379", - brightYellow: "#e5c07b", - brightBlue: "#61afef", - brightMagenta: "#c678dd", - brightCyan: "#56b6c2", - brightWhite: "#ffffff", - }; -} - -// Reusable icon button component matching MessageActionBar style -function ActionButton({ - onClick, - title, - children, - feedback, -}: { - onClick: () => void; - title: string; - children: React.ReactNode; - feedback?: boolean; -}) { - return ( - - ); -} - -// SVG icons -const CopyIcon = () => ( - - - - -); - -const CheckIcon = () => ( - - - -); - -const InsertIcon = () => ( - - - - - -); - -const CloseIcon = () => ( - - - - -); - -export default function TerminalWidget({ - command, - cwd, - onInsertIntoInput, - onClose, -}: TerminalWidgetProps) { - const terminalRef = useRef(null); - const xtermRef = useRef(null); - const fitAddonRef = useRef(null); - const wsRef = useRef(null); - const [status, setStatus] = useState<"connecting" | "running" | "exited" | "error">("connecting"); - const [exitCode, setExitCode] = useState(null); - const [height, setHeight] = useState(300); - const [autoSized, setAutoSized] = useState(false); - const isResizingRef = useRef(false); - const startYRef = useRef(0); - const startHeightRef = useRef(0); - const lineCountRef = useRef(0); - const [copyFeedback, setCopyFeedback] = useState(null); - - // Detect dark mode - const isDarkMode = () => { - return document.documentElement.getAttribute("data-theme") === "dark"; - }; - - const [isDark, setIsDark] = useState(isDarkMode); - - // Watch for theme changes - useEffect(() => { - const observer = new MutationObserver(() => { - const newIsDark = isDarkMode(); - setIsDark(newIsDark); - if (xtermRef.current) { - xtermRef.current.options.theme = getTerminalTheme(newIsDark); - } - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["data-theme"], - }); - - return () => observer.disconnect(); - }, []); - - // Show copy feedback briefly - const showFeedback = useCallback((type: string) => { - setCopyFeedback(type); - setTimeout(() => setCopyFeedback(null), 1500); - }, []); - - // Copy screen content (visible area) - const copyScreen = useCallback(() => { - if (!xtermRef.current) return; - const term = xtermRef.current; - const lines: string[] = []; - const buffer = term.buffer.active; - const startRow = buffer.viewportY; - for (let i = 0; i < term.rows; i++) { - const line = buffer.getLine(startRow + i); - if (line) { - lines.push(line.translateToString(true)); - } - } - const text = lines.join("\n").trimEnd(); - navigator.clipboard.writeText(text); - showFeedback("copyScreen"); - }, [showFeedback]); - - // Copy scrollback buffer (entire history) - const copyScrollback = useCallback(() => { - if (!xtermRef.current) return; - const term = xtermRef.current; - const lines: string[] = []; - const buffer = term.buffer.active; - for (let i = 0; i < buffer.length; i++) { - const line = buffer.getLine(i); - if (line) { - lines.push(line.translateToString(true)); - } - } - const text = lines.join("\n").trimEnd(); - navigator.clipboard.writeText(text); - showFeedback("copyAll"); - }, [showFeedback]); - - // Insert into input - const handleInsertScreen = useCallback(() => { - if (!xtermRef.current || !onInsertIntoInput) return; - const term = xtermRef.current; - const lines: string[] = []; - const buffer = term.buffer.active; - const startRow = buffer.viewportY; - for (let i = 0; i < term.rows; i++) { - const line = buffer.getLine(startRow + i); - if (line) { - lines.push(line.translateToString(true)); - } - } - const text = lines.join("\n").trimEnd(); - onInsertIntoInput(text); - showFeedback("insertScreen"); - }, [onInsertIntoInput, showFeedback]); - - const handleInsertScrollback = useCallback(() => { - if (!xtermRef.current || !onInsertIntoInput) return; - const term = xtermRef.current; - const lines: string[] = []; - const buffer = term.buffer.active; - for (let i = 0; i < buffer.length; i++) { - const line = buffer.getLine(i); - if (line) { - lines.push(line.translateToString(true)); - } - } - const text = lines.join("\n").trimEnd(); - onInsertIntoInput(text); - showFeedback("insertAll"); - }, [onInsertIntoInput, showFeedback]); - - // Close handler - kills the websocket/process - const handleClose = useCallback(() => { - if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.close(); - } - if (onClose) { - onClose(); - } - }, [onClose]); - - // Resize handling - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - isResizingRef.current = true; - startYRef.current = e.clientY; - startHeightRef.current = height; - - const handleMouseMove = (e: MouseEvent) => { - if (!isResizingRef.current) return; - const delta = e.clientY - startYRef.current; - const newHeight = Math.max(80, Math.min(800, startHeightRef.current + delta)); - setHeight(newHeight); - setAutoSized(false); // User manually resized, disable auto-sizing - }; - - const handleMouseUp = () => { - isResizingRef.current = false; - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - // Refit terminal after resize - if (fitAddonRef.current) { - fitAddonRef.current.fit(); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }, - [height], - ); - - // Auto-size terminal based on content when process exits - const autoSizeTerminal = useCallback(() => { - if (!xtermRef.current || autoSized) return; - - const term = xtermRef.current; - const buffer = term.buffer.active; - - // Count actual content lines (non-empty from the end) - let contentLines = 0; - for (let i = buffer.length - 1; i >= 0; i--) { - const line = buffer.getLine(i); - if (line && line.translateToString(true).trim()) { - contentLines = i + 1; - break; - } - } - - // Get actual cell dimensions from xterm - // @ts-expect-error - accessing private _core for accurate measurements - const core = term._core; - const cellHeight = core?._renderService?.dimensions?.css?.cell?.height || 17; - - // Minimal padding for the terminal area - const minHeight = 34; // ~2 lines minimum - const maxHeight = 400; - - // Calculate exact height needed for content lines - const neededHeight = Math.min( - maxHeight, - Math.max(minHeight, Math.ceil(contentLines * cellHeight) + 4), - ); - - setHeight(neededHeight); - setAutoSized(true); - - // Refit after height change - setTimeout(() => fitAddonRef.current?.fit(), 20); - }, [autoSized]); - - useEffect(() => { - if (!terminalRef.current) return; - - // Create terminal - const term = new Terminal({ - cursorBlink: true, - fontSize: 13, - fontFamily: 'Consolas, "Liberation Mono", Menlo, Courier, monospace', - theme: getTerminalTheme(isDark), - scrollback: 10000, - }); - xtermRef.current = term; - - // Add fit addon - const fitAddon = new FitAddon(); - fitAddonRef.current = fitAddon; - term.loadAddon(fitAddon); - - // Add web links addon - const webLinksAddon = new WebLinksAddon(); - term.loadAddon(webLinksAddon); - - // Open terminal in DOM - term.open(terminalRef.current); - fitAddon.fit(); - - // Connect websocket - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${protocol}//${window.location.host}/api/exec-ws?cmd=${encodeURIComponent(command)}&cwd=${encodeURIComponent(cwd)}`; - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => { - // Send init message with terminal size - ws.send( - JSON.stringify({ - type: "init", - cols: term.cols, - rows: term.rows, - }), - ); - setStatus("running"); - }; - - ws.onmessage = (event) => { - try { - const msg = JSON.parse(event.data); - if (msg.type === "output" && msg.data) { - term.write(base64ToUint8Array(msg.data)); - // Track line count for auto-sizing - lineCountRef.current = term.buffer.active.length; - } else if (msg.type === "exit") { - const code = parseInt(msg.data, 10) || 0; - setExitCode(code); - setStatus("exited"); - } else if (msg.type === "error") { - term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`); - setStatus("error"); - } - } catch (err) { - console.error("Failed to parse terminal message:", err); - } - }; - - ws.onerror = (event) => { - console.error("WebSocket error:", event); - }; - - ws.onclose = () => { - setStatus((currentStatus) => { - if (currentStatus === "exited") return currentStatus; - return "exited"; - }); - }; - - // Handle terminal input - term.onData((data) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "input", data })); - } - }); - - // Handle resize - const resizeObserver = new ResizeObserver(() => { - fitAddon.fit(); - if (ws.readyState === WebSocket.OPEN) { - ws.send( - JSON.stringify({ - type: "resize", - cols: term.cols, - rows: term.rows, - }), - ); - } - }); - resizeObserver.observe(terminalRef.current); - - return () => { - resizeObserver.disconnect(); - ws.close(); - term.dispose(); - }; - }, [command, cwd]); // Only recreate on command/cwd change, not on isDark change - - // Auto-size when process exits - useEffect(() => { - if (status === "exited" || status === "error") { - // Small delay to ensure all output is written - setTimeout(autoSizeTerminal, 100); - } - }, [status, autoSizeTerminal]); - - // Update theme when isDark changes without recreating terminal - useEffect(() => { - if (xtermRef.current) { - xtermRef.current.options.theme = getTerminalTheme(isDark); - } - }, [isDark]); - - // Fit terminal when height changes - useEffect(() => { - if (fitAddonRef.current) { - setTimeout(() => fitAddonRef.current?.fit(), 10); - } - }, [height]); - - return ( -
- {/* Header */} -
-
- - - - - - {command} - - {status === "running" && ( - - ● running - - )} - {status === "exited" && ( - - exit {exitCode} - - )} - {status === "error" && ( - - ● error - - )} -
- - {/* Action buttons - styled like MessageActionBar */} -
- - {copyFeedback === "copyScreen" ? : } - - - {copyFeedback === "copyAll" ? ( - - ) : ( - - - - - - )} - - {onInsertIntoInput && ( - <> - - {copyFeedback === "insertScreen" ? : } - - - {copyFeedback === "insertAll" ? ( - - ) : ( - - - - - - - )} - - - )} -
- - - -
-
- - {/* Terminal container */} -
- - {/* Resize handle */} -
-
-
-
- ); -} diff --git a/ui/src/styles.css b/ui/src/styles.css index ede174c8fd18d51e4563daf0f9c49d64651043a5..3ec3f4470d41dde01d8aa931d5f021191a48ba10 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -4808,3 +4808,191 @@ kbd { .dark .system-prompt-text { background: var(--gray-900); } + +/* ===== Terminal Panel ===== */ +.terminal-panel { + display: flex; + flex-direction: column; + border-top: 1px solid var(--border); + background: var(--bg-secondary); + min-height: 80px; + max-height: 800px; + overflow: hidden; +} + +.terminal-panel-resize-handle { + height: 6px; + cursor: ns-resize; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + user-select: none; +} + +.terminal-panel-resize-handle:hover { + background: var(--bg-tertiary); +} + +.terminal-panel-resize-grip { + width: 40px; + height: 3px; + background: var(--text-tertiary); + border-radius: 2px; +} + +.terminal-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px; + height: 34px; + flex-shrink: 0; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + gap: 8px; +} + +.terminal-panel-tabs { + display: flex; + align-items: center; + gap: 2px; + overflow-x: auto; + flex: 1; + min-width: 0; + scrollbar-width: none; +} + +.terminal-panel-tabs::-webkit-scrollbar { + display: none; +} + +.terminal-panel-tab { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-family: Consolas, "Liberation Mono", Menlo, monospace; + color: var(--text-secondary); + white-space: nowrap; + user-select: none; + border: 1px solid transparent; + transition: + background-color 0.1s, + color 0.1s; +} + +.terminal-panel-tab:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.terminal-panel-tab-active { + background: var(--bg-base); + color: var(--text-primary); + border-color: var(--border); +} + +.terminal-panel-tab-indicator { + font-size: 10px; + line-height: 1; +} + +.terminal-panel-tab-running { + color: var(--success-text); +} + +.terminal-panel-tab-success { + color: var(--success-text); +} + +.terminal-panel-tab-error { + color: var(--error-text); +} + +.terminal-panel-tab-label { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; +} + +.terminal-panel-tab-close { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + background: none; + cursor: pointer; + color: var(--text-tertiary); + font-size: 14px; + line-height: 1; + border-radius: 3px; + padding: 0; +} + +.terminal-panel-tab-close:hover { + background: var(--bg-tertiary); + color: var(--error-text); +} + +.terminal-panel-actions { + display: flex; + align-items: center; + gap: 2px; + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px; + flex-shrink: 0; +} + +.terminal-panel-actions-divider { + width: 1px; + height: 16px; + background: var(--border); + margin: 0 2px; +} + +.terminal-panel-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; + border: none; + background: transparent; + cursor: pointer; + color: var(--text-secondary); + transition: + background-color 0.15s, + color 0.15s; +} + +.terminal-panel-action-btn:hover { + background: var(--bg-tertiary); +} + +.terminal-panel-action-btn-feedback { + background: var(--success-bg); + color: var(--success-text); +} + +.terminal-panel-content { + flex: 1; + min-height: 0; + overflow: hidden; + position: relative; +} + +.terminal-panel-content > div { + position: absolute; + inset: 0; +}