shelley: move terminal panel between timeline and status bar with tabbed UI

Philip Zeyliger and Shelley created

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 <shelley@exe.dev>

Change summary

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(-)

Detailed changes

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) => (
-      <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">

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<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",
+      }}
+    />
+  );
+}

ui/src/components/TerminalWidget.tsx 🔗

@@ -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>
-  );
-}

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;
+}