Detailed changes
@@ -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) => (
- <TerminalWidget
- key={terminal.id}
- command={terminal.command}
- cwd={terminal.cwd}
- onInsertIntoInput={handleInsertFromTerminal}
- onClose={() => 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 (
<div className="empty-state">
@@ -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 && <SystemPromptView key="system-prompt" message={systemMessage} />,
...rendered,
- ...terminalElements,
];
};
@@ -1682,6 +1656,14 @@ function ChatInterface({
)}
</div>
+ {/* Terminal Panel - between messages and status bar */}
+ <TerminalPanel
+ terminals={ephemeralTerminals}
+ onClose={(id) => setEphemeralTerminals((prev) => prev.filter((t) => t.id !== id))}
+ onCloseAll={() => setEphemeralTerminals([])}
+ onInsertIntoInput={handleInsertFromTerminal}
+ />
+
{/* Unified Status Bar */}
<div className="status-bar">
<div className="status-bar-content">
@@ -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<string, string> {
+ 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 = () => (
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
+ </svg>
+);
+
+const CopyAllIcon = () => (
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
+ <line x1="12" y1="17" x2="18" y2="17" />
+ </svg>
+);
+
+const InsertIcon = () => (
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <path d="M12 3v12" />
+ <path d="m8 11 4 4 4-4" />
+ <path d="M4 21h16" />
+ </svg>
+);
+
+const InsertAllIcon = () => (
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <path d="M12 3v12" />
+ <path d="m8 11 4 4 4-4" />
+ <path d="M4 21h16" />
+ <line x1="4" y1="18" x2="20" y2="18" />
+ </svg>
+);
+
+const CheckIcon = () => (
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <polyline points="20 6 9 17 4 12" />
+ </svg>
+);
+
+const CloseIcon = () => (
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <line x1="18" y1="6" x2="6" y2="18" />
+ <line x1="6" y1="6" x2="18" y2="18" />
+ </svg>
+);
+
+function ActionButton({
+ onClick,
+ title,
+ children,
+ feedback,
+}: {
+ onClick: () => void;
+ title: string;
+ children: React.ReactNode;
+ feedback?: boolean;
+}) {
+ return (
+ <button
+ onClick={onClick}
+ title={title}
+ className={`terminal-panel-action-btn${feedback ? " terminal-panel-action-btn-feedback" : ""}`}
+ >
+ {children}
+ </button>
+ );
+}
+
+export default function TerminalPanel({
+ terminals,
+ onClose,
+ onCloseAll,
+ onInsertIntoInput,
+}: TerminalPanelProps) {
+ const [activeTabId, setActiveTabId] = useState<string | null>(null);
+ const [height, setHeight] = useState(300);
+ const [userResized, setUserResized] = useState(false);
+ const [copyFeedback, setCopyFeedback] = useState<string | null>(null);
+ const [statusMap, setStatusMap] = useState<
+ Map<string, { status: TermStatus; exitCode: number | null; contentLines: number }>
+ >(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<Map<string, Terminal>>(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 (
+ <div className="terminal-panel" style={{ height: `${height}px`, flexShrink: 0 }}>
+ {/* Resize handle at top */}
+ <div className="terminal-panel-resize-handle" onMouseDown={handleResizeMouseDown}>
+ <div className="terminal-panel-resize-grip" />
+ </div>
+
+ {/* Tab bar + actions */}
+ <div className="terminal-panel-header">
+ <div className="terminal-panel-tabs">
+ {terminals.map((t) => {
+ const info = statusMap.get(t.id);
+ const isActive = t.id === activeTabId;
+ return (
+ <div
+ key={t.id}
+ className={`terminal-panel-tab${isActive ? " terminal-panel-tab-active" : ""}`}
+ onClick={() => setActiveTabId(t.id)}
+ title={t.command}
+ >
+ {info?.status === "running" && (
+ <span className="terminal-panel-tab-indicator terminal-panel-tab-running">●</span>
+ )}
+ {info?.status === "exited" && info.exitCode === 0 && (
+ <span className="terminal-panel-tab-indicator terminal-panel-tab-success">✓</span>
+ )}
+ {info?.status === "exited" && info.exitCode !== 0 && (
+ <span className="terminal-panel-tab-indicator terminal-panel-tab-error">✗</span>
+ )}
+ {info?.status === "error" && (
+ <span className="terminal-panel-tab-indicator terminal-panel-tab-error">✗</span>
+ )}
+ <span className="terminal-panel-tab-label">{tabLabel(t.command)}</span>
+ <button
+ className="terminal-panel-tab-close"
+ onClick={(e) => {
+ e.stopPropagation();
+ onClose(t.id);
+ }}
+ title="Close terminal"
+ >
+ ×
+ </button>
+ </div>
+ );
+ })}
+ </div>
+
+ {/* Action buttons */}
+ <div className="terminal-panel-actions">
+ <ActionButton
+ onClick={copyScreen}
+ title="Copy visible screen"
+ feedback={copyFeedback === "copyScreen"}
+ >
+ {copyFeedback === "copyScreen" ? <CheckIcon /> : <CopyIcon />}
+ </ActionButton>
+ <ActionButton
+ onClick={copyAll}
+ title="Copy all output"
+ feedback={copyFeedback === "copyAll"}
+ >
+ {copyFeedback === "copyAll" ? <CheckIcon /> : <CopyAllIcon />}
+ </ActionButton>
+ {onInsertIntoInput && (
+ <>
+ <ActionButton
+ onClick={insertScreen}
+ title="Insert visible screen into input"
+ feedback={copyFeedback === "insertScreen"}
+ >
+ {copyFeedback === "insertScreen" ? <CheckIcon /> : <InsertIcon />}
+ </ActionButton>
+ <ActionButton
+ onClick={insertAll}
+ title="Insert all output into input"
+ feedback={copyFeedback === "insertAll"}
+ >
+ {copyFeedback === "insertAll" ? <CheckIcon /> : <InsertAllIcon />}
+ </ActionButton>
+ </>
+ )}
+ <div className="terminal-panel-actions-divider" />
+ <ActionButton onClick={handleCloseActive} title="Close active terminal">
+ <CloseIcon />
+ </ActionButton>
+ </div>
+ </div>
+
+ {/* Terminal content area */}
+ <div className="terminal-panel-content">
+ {terminals.map((t) => (
+ <TerminalInstanceWithRegistry
+ key={t.id}
+ term={t}
+ isVisible={t.id === activeTabId}
+ isDark={isDark}
+ onStatusChange={handleStatusChange}
+ onRegister={registerXterm}
+ onUnregister={unregisterXterm}
+ />
+ ))}
+ </div>
+ </div>
+ );
+}
+
+// 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<HTMLDivElement>(null);
+ const xtermRef = useRef<Terminal | null>(null);
+ const fitAddonRef = useRef<FitAddon | null>(null);
+ const wsRef = useRef<WebSocket | null>(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 (
+ <div
+ ref={containerRef}
+ data-terminal-id={term.id}
+ style={{
+ width: "100%",
+ height: "100%",
+ display: isVisible ? "block" : "none",
+ backgroundColor: isDark ? "#1a1b26" : "#f8f9fa",
+ }}
+ />
+ );
+}
@@ -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<string, string> {
- 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 (
- <button
- onClick={onClick}
- title={title}
- style={{
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- width: "24px",
- height: "24px",
- borderRadius: "4px",
- border: "none",
- background: feedback ? "var(--success-bg)" : "transparent",
- cursor: "pointer",
- color: feedback ? "var(--success-text)" : "var(--text-secondary)",
- transition: "background-color 0.15s, color 0.15s",
- }}
- onMouseEnter={(e) => {
- if (!feedback) {
- e.currentTarget.style.backgroundColor = "var(--bg-tertiary)";
- }
- }}
- onMouseLeave={(e) => {
- if (!feedback) {
- e.currentTarget.style.backgroundColor = "transparent";
- }
- }}
- >
- {children}
- </button>
- );
-}
-
-// SVG icons
-const CopyIcon = () => (
- <svg
- width="14"
- height="14"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- >
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
- </svg>
-);
-
-const CheckIcon = () => (
- <svg
- width="14"
- height="14"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- >
- <polyline points="20 6 9 17 4 12" />
- </svg>
-);
-
-const InsertIcon = () => (
- <svg
- width="14"
- height="14"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- >
- <path d="M12 3v12" />
- <path d="m8 11 4 4 4-4" />
- <path d="M4 21h16" />
- </svg>
-);
-
-const CloseIcon = () => (
- <svg
- width="14"
- height="14"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- >
- <line x1="18" y1="6" x2="6" y2="18" />
- <line x1="6" y1="6" x2="18" y2="18" />
- </svg>
-);
-
-export default function TerminalWidget({
- command,
- cwd,
- onInsertIntoInput,
- onClose,
-}: TerminalWidgetProps) {
- const terminalRef = useRef<HTMLDivElement>(null);
- const xtermRef = useRef<Terminal | null>(null);
- const fitAddonRef = useRef<FitAddon | null>(null);
- const wsRef = useRef<WebSocket | null>(null);
- const [status, setStatus] = useState<"connecting" | "running" | "exited" | "error">("connecting");
- const [exitCode, setExitCode] = useState<number | null>(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<string | null>(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 (
- <div className="terminal-widget" style={{ marginBottom: "1rem" }}>
- {/* Header */}
- <div
- className="terminal-widget-header"
- style={{
- display: "flex",
- alignItems: "center",
- justifyContent: "space-between",
- padding: "6px 12px",
- backgroundColor: "var(--bg-secondary)",
- borderRadius: "8px 8px 0 0",
- border: "1px solid var(--border)",
- borderBottom: "none",
- }}
- >
- <div style={{ display: "flex", alignItems: "center", gap: "8px", flex: 1, minWidth: 0 }}>
- <svg
- width="14"
- height="14"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- style={{ flexShrink: 0, color: "var(--text-secondary)" }}
- >
- <polyline points="4 17 10 11 4 5" />
- <line x1="12" y1="19" x2="20" y2="19" />
- </svg>
- <code
- style={{
- fontSize: "12px",
- fontFamily: 'Consolas, "Liberation Mono", Menlo, monospace',
- color: "var(--text-primary)",
- overflow: "hidden",
- textOverflow: "ellipsis",
- whiteSpace: "nowrap",
- }}
- >
- {command}
- </code>
- {status === "running" && (
- <span
- style={{
- fontSize: "11px",
- color: "var(--success-text)",
- fontWeight: 500,
- flexShrink: 0,
- }}
- >
- ● running
- </span>
- )}
- {status === "exited" && (
- <span
- style={{
- fontSize: "11px",
- color: exitCode === 0 ? "var(--success-text)" : "var(--error-text)",
- fontWeight: 500,
- flexShrink: 0,
- }}
- >
- exit {exitCode}
- </span>
- )}
- {status === "error" && (
- <span
- style={{
- fontSize: "11px",
- color: "var(--error-text)",
- fontWeight: 500,
- flexShrink: 0,
- }}
- >
- ● error
- </span>
- )}
- </div>
-
- {/* Action buttons - styled like MessageActionBar */}
- <div
- style={{
- display: "flex",
- gap: "2px",
- background: "var(--bg-base)",
- border: "1px solid var(--border)",
- borderRadius: "4px",
- padding: "2px",
- }}
- >
- <ActionButton
- onClick={copyScreen}
- title="Copy visible screen to clipboard"
- feedback={copyFeedback === "copyScreen"}
- >
- {copyFeedback === "copyScreen" ? <CheckIcon /> : <CopyIcon />}
- </ActionButton>
- <ActionButton
- onClick={copyScrollback}
- title="Copy all output to clipboard"
- feedback={copyFeedback === "copyAll"}
- >
- {copyFeedback === "copyAll" ? (
- <CheckIcon />
- ) : (
- <svg
- width="14"
- height="14"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- >
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
- <line x1="12" y1="17" x2="18" y2="17" />
- </svg>
- )}
- </ActionButton>
- {onInsertIntoInput && (
- <>
- <ActionButton
- onClick={handleInsertScreen}
- title="Insert visible screen into message input"
- feedback={copyFeedback === "insertScreen"}
- >
- {copyFeedback === "insertScreen" ? <CheckIcon /> : <InsertIcon />}
- </ActionButton>
- <ActionButton
- onClick={handleInsertScrollback}
- title="Insert all output into message input"
- feedback={copyFeedback === "insertAll"}
- >
- {copyFeedback === "insertAll" ? (
- <CheckIcon />
- ) : (
- <svg
- width="14"
- height="14"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- >
- <path d="M12 3v12" />
- <path d="m8 11 4 4 4-4" />
- <path d="M4 21h16" />
- <line x1="4" y1="18" x2="20" y2="18" />
- </svg>
- )}
- </ActionButton>
- </>
- )}
- <div
- style={{
- width: "1px",
- background: "var(--border)",
- margin: "2px 2px",
- }}
- />
- <ActionButton onClick={handleClose} title="Close terminal and kill process">
- <CloseIcon />
- </ActionButton>
- </div>
- </div>
-
- {/* Terminal container */}
- <div
- ref={terminalRef}
- style={{
- height: `${height}px`,
- backgroundColor: isDark ? "#1a1b26" : "#f8f9fa",
- border: "1px solid var(--border)",
- borderTop: "none",
- borderBottom: "none",
- overflow: "hidden",
- }}
- />
-
- {/* Resize handle */}
- <div
- onMouseDown={handleMouseDown}
- style={{
- height: "8px",
- cursor: "ns-resize",
- backgroundColor: "var(--bg-secondary)",
- border: "1px solid var(--border)",
- borderTop: "none",
- borderRadius: "0 0 8px 8px",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <div
- style={{
- width: "40px",
- height: "3px",
- backgroundColor: "var(--text-tertiary)",
- borderRadius: "2px",
- }}
- />
- </div>
- </div>
- );
-}
@@ -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;
+}