From 9c98c542fcaaac969fa17034557bd3102b3533b3 Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Sun, 8 Feb 2026 05:16:26 +0000 Subject: [PATCH] shelley: move terminal panel between timeline and status bar with tabbed UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt: In a new worktree, change how the terminal that happens when you do !bash in shelley works. Instead of going in the timeline view, make the terminal appear between timeline view and the Ready bar. And if you need multiple terminals, make it tabbed. So !bash opens up one, and it's got an (x) to close and all the cut/paste buttons. If there's another !ls or whatever, then a second tab opens up, and the user can flip between them. We can make the height adjustable, but if the terminal output is short, we should continue squishing it down just like we do today. Replace inline TerminalWidget (rendered in the message timeline) with a new TerminalPanel component that sits between the messages area and the status bar. Key changes: - Terminals from !commands now appear in a persistent panel at the bottom of the screen, between the message timeline and the Ready/status bar - Multiple terminals are tabbed: each !command opens a new tab - Tabs show command name, status indicator (● running, ✓ success, ✗ error), and an × close button - Panel auto-sizes to fit short output (e.g., !ls, !pwd shrink down) - Running terminals (e.g., !bash) grow the panel as output arrives - Resize handle at the top allows manual height adjustment by dragging - Action buttons (copy screen, copy all, insert screen, insert all, close) are in the header bar - Tab switching preserves terminal state and output - Dark/light theme support with matching xterm.js colors - Removed TerminalWidget.tsx (fully replaced by TerminalPanel.tsx) Co-authored-by: Shelley --- ui/src/components/ChatInterface.tsx | 38 +- ui/src/components/TerminalPanel.tsx | 689 +++++++++++++++++++++++++ ui/src/components/TerminalWidget.tsx | 726 --------------------------- ui/src/styles.css | 188 +++++++ 4 files changed, 887 insertions(+), 754 deletions(-) create mode 100644 ui/src/components/TerminalPanel.tsx delete mode 100644 ui/src/components/TerminalWidget.tsx 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; +}