TerminalPanel.tsx

  1import React, { useEffect, useRef, useState, useCallback } from "react";
  2import { Terminal } from "@xterm/xterm";
  3import { FitAddon } from "@xterm/addon-fit";
  4import { WebLinksAddon } from "@xterm/addon-web-links";
  5import { isDarkModeActive } from "../services/theme";
  6import "@xterm/xterm/css/xterm.css";
  7
  8function base64ToUint8Array(base64String: string): Uint8Array {
  9  // @ts-expect-error Uint8Array.fromBase64 is a newer API
 10  if (Uint8Array.fromBase64) {
 11    // @ts-expect-error Uint8Array.fromBase64 is a newer API
 12    return Uint8Array.fromBase64(base64String);
 13  }
 14  const binaryString = atob(base64String);
 15  return Uint8Array.from(binaryString, (char) => char.charCodeAt(0));
 16}
 17
 18export interface EphemeralTerminal {
 19  id: string;
 20  command: string;
 21  cwd: string;
 22  createdAt: Date;
 23}
 24
 25interface TerminalPanelProps {
 26  terminals: EphemeralTerminal[];
 27  onClose: (id: string) => void;
 28  onCloseAll: () => void;
 29  onInsertIntoInput?: (text: string) => void;
 30}
 31
 32// Theme colors for xterm.js
 33function getTerminalTheme(isDark: boolean): Record<string, string> {
 34  if (isDark) {
 35    return {
 36      background: "#1a1b26",
 37      foreground: "#c0caf5",
 38      cursor: "#c0caf5",
 39      cursorAccent: "#1a1b26",
 40      selectionBackground: "#364a82",
 41      selectionForeground: "#c0caf5",
 42      black: "#32344a",
 43      red: "#f7768e",
 44      green: "#9ece6a",
 45      yellow: "#e0af68",
 46      blue: "#7aa2f7",
 47      magenta: "#ad8ee6",
 48      cyan: "#449dab",
 49      white: "#9699a8",
 50      brightBlack: "#444b6a",
 51      brightRed: "#ff7a93",
 52      brightGreen: "#b9f27c",
 53      brightYellow: "#ff9e64",
 54      brightBlue: "#7da6ff",
 55      brightMagenta: "#bb9af7",
 56      brightCyan: "#0db9d7",
 57      brightWhite: "#acb0d0",
 58    };
 59  }
 60  return {
 61    background: "#f8f9fa",
 62    foreground: "#383a42",
 63    cursor: "#526eff",
 64    cursorAccent: "#f8f9fa",
 65    selectionBackground: "#bfceff",
 66    selectionForeground: "#383a42",
 67    black: "#383a42",
 68    red: "#e45649",
 69    green: "#50a14f",
 70    yellow: "#c18401",
 71    blue: "#4078f2",
 72    magenta: "#a626a4",
 73    cyan: "#0184bc",
 74    white: "#a0a1a7",
 75    brightBlack: "#4f525e",
 76    brightRed: "#e06c75",
 77    brightGreen: "#98c379",
 78    brightYellow: "#e5c07b",
 79    brightBlue: "#61afef",
 80    brightMagenta: "#c678dd",
 81    brightCyan: "#56b6c2",
 82    brightWhite: "#ffffff",
 83  };
 84}
 85
 86type TermStatus = "connecting" | "running" | "exited" | "error";
 87
 88// SVG icons
 89const CopyIcon = () => (
 90  <svg
 91    width="14"
 92    height="14"
 93    viewBox="0 0 24 24"
 94    fill="none"
 95    stroke="currentColor"
 96    strokeWidth="2"
 97    strokeLinecap="round"
 98    strokeLinejoin="round"
 99  >
100    <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
101    <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
102  </svg>
103);
104
105const CopyAllIcon = () => (
106  <svg
107    width="14"
108    height="14"
109    viewBox="0 0 24 24"
110    fill="none"
111    stroke="currentColor"
112    strokeWidth="2"
113    strokeLinecap="round"
114    strokeLinejoin="round"
115  >
116    <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
117    <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
118    <line x1="12" y1="17" x2="18" y2="17" />
119  </svg>
120);
121
122const InsertIcon = () => (
123  <svg
124    width="14"
125    height="14"
126    viewBox="0 0 24 24"
127    fill="none"
128    stroke="currentColor"
129    strokeWidth="2"
130    strokeLinecap="round"
131    strokeLinejoin="round"
132  >
133    <path d="M12 3v12" />
134    <path d="m8 11 4 4 4-4" />
135    <path d="M4 21h16" />
136  </svg>
137);
138
139const InsertAllIcon = () => (
140  <svg
141    width="14"
142    height="14"
143    viewBox="0 0 24 24"
144    fill="none"
145    stroke="currentColor"
146    strokeWidth="2"
147    strokeLinecap="round"
148    strokeLinejoin="round"
149  >
150    <path d="M12 3v12" />
151    <path d="m8 11 4 4 4-4" />
152    <path d="M4 21h16" />
153    <line x1="4" y1="18" x2="20" y2="18" />
154  </svg>
155);
156
157const CheckIcon = () => (
158  <svg
159    width="14"
160    height="14"
161    viewBox="0 0 24 24"
162    fill="none"
163    stroke="currentColor"
164    strokeWidth="2"
165    strokeLinecap="round"
166    strokeLinejoin="round"
167  >
168    <polyline points="20 6 9 17 4 12" />
169  </svg>
170);
171
172const CloseIcon = () => (
173  <svg
174    width="14"
175    height="14"
176    viewBox="0 0 24 24"
177    fill="none"
178    stroke="currentColor"
179    strokeWidth="2"
180    strokeLinecap="round"
181    strokeLinejoin="round"
182  >
183    <line x1="18" y1="6" x2="6" y2="18" />
184    <line x1="6" y1="6" x2="18" y2="18" />
185  </svg>
186);
187
188function ActionButton({
189  onClick,
190  title,
191  children,
192  feedback,
193}: {
194  onClick: () => void;
195  title: string;
196  children: React.ReactNode;
197  feedback?: boolean;
198}) {
199  return (
200    <button
201      onClick={onClick}
202      title={title}
203      className={`terminal-panel-action-btn${feedback ? " terminal-panel-action-btn-feedback" : ""}`}
204    >
205      {children}
206    </button>
207  );
208}
209
210export default function TerminalPanel({
211  terminals,
212  onClose,
213  onCloseAll,
214  onInsertIntoInput,
215}: TerminalPanelProps) {
216  const [activeTabId, setActiveTabId] = useState<string | null>(null);
217  const [height, setHeight] = useState(300);
218  const [heightLocked, setHeightLocked] = useState(false);
219  const isFirstTerminalRef = useRef(true);
220  const [copyFeedback, setCopyFeedback] = useState<string | null>(null);
221  const [statusMap, setStatusMap] = useState<
222    Map<string, { status: TermStatus; exitCode: number | null; contentLines: number }>
223  >(new Map());
224  const isResizingRef = useRef(false);
225  const startYRef = useRef(0);
226  const startHeightRef = useRef(0);
227
228  // Detect dark mode
229  const [isDark, setIsDark] = useState(isDarkModeActive);
230
231  useEffect(() => {
232    const observer = new MutationObserver(() => {
233      setIsDark(isDarkModeActive());
234    });
235    observer.observe(document.documentElement, {
236      attributes: true,
237      attributeFilter: ["class"],
238    });
239    return () => observer.disconnect();
240  }, []);
241
242  // Auto-select newest tab when a new terminal is added
243  useEffect(() => {
244    if (terminals.length > 0) {
245      const lastTerminal = terminals[terminals.length - 1];
246      setActiveTabId(lastTerminal.id);
247    } else {
248      setActiveTabId(null);
249    }
250  }, [terminals.length]); // eslint-disable-line react-hooks/exhaustive-deps
251
252  // If active tab got closed, switch to the last remaining
253  useEffect(() => {
254    if (activeTabId && !terminals.find((t) => t.id === activeTabId)) {
255      if (terminals.length > 0) {
256        setActiveTabId(terminals[terminals.length - 1].id);
257      } else {
258        setActiveTabId(null);
259      }
260    }
261  }, [terminals, activeTabId]);
262
263  const handleStatusChange = useCallback(
264    (id: string, status: TermStatus, exitCode: number | null, contentLines: number) => {
265      setStatusMap((prev) => {
266        const next = new Map(prev);
267        const existing = next.get(id);
268        // Don't overwrite exit status with ws.onclose
269        if (
270          existing &&
271          existing.status === "exited" &&
272          status === "exited" &&
273          contentLines === -1
274        ) {
275          return prev;
276        }
277        const lines = contentLines === -1 ? existing?.contentLines || 0 : contentLines;
278        next.set(id, {
279          status,
280          exitCode: exitCode ?? existing?.exitCode ?? null,
281          contentLines: lines,
282        });
283        return next;
284      });
285    },
286    [],
287  );
288
289  // Auto-size only for the very first terminal. After that, keep whatever height we have.
290  useEffect(() => {
291    if (heightLocked || !activeTabId) return;
292    if (!isFirstTerminalRef.current) return;
293    const info = statusMap.get(activeTabId);
294    if (!info) return;
295
296    const cellHeight = 17; // approximate
297    const minHeight = 60;
298    const maxHeight = 500;
299    const tabBarHeight = 38;
300
301    if (info.status === "exited" || info.status === "error") {
302      const needed = Math.min(
303        maxHeight,
304        Math.max(minHeight, info.contentLines * cellHeight + tabBarHeight + 16),
305      );
306      setHeight(needed);
307      setHeightLocked(true);
308      isFirstTerminalRef.current = false;
309    } else if (info.status === "running") {
310      // While the first command is still running, grow if needed
311      const needed = Math.min(
312        maxHeight,
313        Math.max(minHeight, info.contentLines * cellHeight + tabBarHeight + 16),
314      );
315      setHeight((prev) => Math.max(prev, needed));
316    }
317  }, [statusMap, activeTabId, heightLocked]);
318
319  // Resize drag
320  const handleResizeMouseDown = useCallback(
321    (e: React.MouseEvent) => {
322      e.preventDefault();
323      isResizingRef.current = true;
324      startYRef.current = e.clientY;
325      startHeightRef.current = height;
326
327      const handleMouseMove = (e: MouseEvent) => {
328        if (!isResizingRef.current) return;
329        // Dragging up increases height
330        const delta = startYRef.current - e.clientY;
331        setHeight(Math.max(80, Math.min(800, startHeightRef.current + delta)));
332        setHeightLocked(true);
333        isFirstTerminalRef.current = false;
334      };
335
336      const handleMouseUp = () => {
337        isResizingRef.current = false;
338        document.removeEventListener("mousemove", handleMouseMove);
339        document.removeEventListener("mouseup", handleMouseUp);
340      };
341
342      document.addEventListener("mousemove", handleMouseMove);
343      document.addEventListener("mouseup", handleMouseUp);
344    },
345    [height],
346  );
347
348  const showFeedback = useCallback((type: string) => {
349    setCopyFeedback(type);
350    setTimeout(() => setCopyFeedback(null), 1500);
351  }, []);
352
353  // Get the xterm instance for the active tab
354  const xtermRegistryRef = useRef<Map<string, Terminal>>(new Map());
355
356  const registerXterm = useCallback((id: string, xterm: Terminal) => {
357    xtermRegistryRef.current.set(id, xterm);
358  }, []);
359
360  const unregisterXterm = useCallback((id: string) => {
361    xtermRegistryRef.current.delete(id);
362  }, []);
363
364  const getBufferText = useCallback(
365    (mode: "screen" | "all"): string => {
366      if (!activeTabId) return "";
367      const xterm = xtermRegistryRef.current.get(activeTabId);
368      if (!xterm) return "";
369
370      const lines: string[] = [];
371      const buffer = xterm.buffer.active;
372
373      if (mode === "screen") {
374        const startRow = buffer.viewportY;
375        for (let i = 0; i < xterm.rows; i++) {
376          const line = buffer.getLine(startRow + i);
377          if (line) lines.push(line.translateToString(true));
378        }
379      } else {
380        for (let i = 0; i < buffer.length; i++) {
381          const line = buffer.getLine(i);
382          if (line) lines.push(line.translateToString(true));
383        }
384      }
385      return lines.join("\n").trimEnd();
386    },
387    [activeTabId],
388  );
389
390  const copyScreen = useCallback(() => {
391    navigator.clipboard.writeText(getBufferText("screen"));
392    showFeedback("copyScreen");
393  }, [getBufferText, showFeedback]);
394
395  const copyAll = useCallback(() => {
396    navigator.clipboard.writeText(getBufferText("all"));
397    showFeedback("copyAll");
398  }, [getBufferText, showFeedback]);
399
400  const insertScreen = useCallback(() => {
401    if (onInsertIntoInput) {
402      onInsertIntoInput(getBufferText("screen"));
403      showFeedback("insertScreen");
404    }
405  }, [getBufferText, onInsertIntoInput, showFeedback]);
406
407  const insertAll = useCallback(() => {
408    if (onInsertIntoInput) {
409      onInsertIntoInput(getBufferText("all"));
410      showFeedback("insertAll");
411    }
412  }, [getBufferText, onInsertIntoInput, showFeedback]);
413
414  const handleCloseActive = useCallback(() => {
415    if (activeTabId) onClose(activeTabId);
416  }, [activeTabId, onClose]);
417
418  if (terminals.length === 0) return null;
419
420  const activeInfo = activeTabId ? statusMap.get(activeTabId) : null;
421
422  // Truncate command for tab label
423  const tabLabel = (cmd: string) => {
424    // Show first word or first 30 chars
425    const firstWord = cmd.split(/\s+/)[0];
426    if (firstWord.length > 30) return firstWord.substring(0, 27) + "...";
427    return firstWord;
428  };
429
430  return (
431    <div className="terminal-panel" style={{ height: `${height}px`, flexShrink: 0 }}>
432      {/* Resize handle at top */}
433      <div className="terminal-panel-resize-handle" onMouseDown={handleResizeMouseDown}>
434        <div className="terminal-panel-resize-grip" />
435      </div>
436
437      {/* Tab bar + actions */}
438      <div className="terminal-panel-header">
439        <div className="terminal-panel-tabs">
440          {terminals.map((t) => {
441            const info = statusMap.get(t.id);
442            const isActive = t.id === activeTabId;
443            return (
444              <div
445                key={t.id}
446                className={`terminal-panel-tab${isActive ? " terminal-panel-tab-active" : ""}`}
447                onClick={() => setActiveTabId(t.id)}
448                title={t.command}
449              >
450                {info?.status === "running" && (
451                  <span className="terminal-panel-tab-indicator terminal-panel-tab-running"></span>
452                )}
453                {info?.status === "exited" && info.exitCode === 0 && (
454                  <span className="terminal-panel-tab-indicator terminal-panel-tab-success"></span>
455                )}
456                {info?.status === "exited" && info.exitCode !== 0 && (
457                  <span className="terminal-panel-tab-indicator terminal-panel-tab-error"></span>
458                )}
459                {info?.status === "error" && (
460                  <span className="terminal-panel-tab-indicator terminal-panel-tab-error"></span>
461                )}
462                <span className="terminal-panel-tab-label">{tabLabel(t.command)}</span>
463                <button
464                  className="terminal-panel-tab-close"
465                  onClick={(e) => {
466                    e.stopPropagation();
467                    onClose(t.id);
468                  }}
469                  title="Close terminal"
470                >
471                  ×
472                </button>
473              </div>
474            );
475          })}
476        </div>
477
478        {/* Action buttons */}
479        <div className="terminal-panel-actions">
480          <ActionButton
481            onClick={copyScreen}
482            title="Copy visible screen"
483            feedback={copyFeedback === "copyScreen"}
484          >
485            {copyFeedback === "copyScreen" ? <CheckIcon /> : <CopyIcon />}
486          </ActionButton>
487          <ActionButton
488            onClick={copyAll}
489            title="Copy all output"
490            feedback={copyFeedback === "copyAll"}
491          >
492            {copyFeedback === "copyAll" ? <CheckIcon /> : <CopyAllIcon />}
493          </ActionButton>
494          {onInsertIntoInput && (
495            <>
496              <ActionButton
497                onClick={insertScreen}
498                title="Insert visible screen into input"
499                feedback={copyFeedback === "insertScreen"}
500              >
501                {copyFeedback === "insertScreen" ? <CheckIcon /> : <InsertIcon />}
502              </ActionButton>
503              <ActionButton
504                onClick={insertAll}
505                title="Insert all output into input"
506                feedback={copyFeedback === "insertAll"}
507              >
508                {copyFeedback === "insertAll" ? <CheckIcon /> : <InsertAllIcon />}
509              </ActionButton>
510            </>
511          )}
512          <div className="terminal-panel-actions-divider" />
513          <ActionButton onClick={handleCloseActive} title="Close active terminal">
514            <CloseIcon />
515          </ActionButton>
516        </div>
517      </div>
518
519      {/* Terminal content area */}
520      <div className="terminal-panel-content">
521        {terminals.map((t) => (
522          <TerminalInstanceWithRegistry
523            key={t.id}
524            term={t}
525            isVisible={t.id === activeTabId}
526            isDark={isDark}
527            onStatusChange={handleStatusChange}
528            onRegister={registerXterm}
529            onUnregister={unregisterXterm}
530          />
531        ))}
532      </div>
533    </div>
534  );
535}
536
537// Wrapper that also registers the xterm instance
538function TerminalInstanceWithRegistry({
539  term,
540  isVisible,
541  isDark,
542  onStatusChange,
543  onRegister,
544  onUnregister,
545}: {
546  term: EphemeralTerminal;
547  isVisible: boolean;
548  isDark: boolean;
549  onStatusChange: (
550    id: string,
551    status: TermStatus,
552    exitCode: number | null,
553    contentLines: number,
554  ) => void;
555  onRegister: (id: string, xterm: Terminal) => void;
556  onUnregister: (id: string) => void;
557}) {
558  const containerRef = useRef<HTMLDivElement>(null);
559  const xtermRef = useRef<Terminal | null>(null);
560  const fitAddonRef = useRef<FitAddon | null>(null);
561  const wsRef = useRef<WebSocket | null>(null);
562
563  useEffect(() => {
564    if (!containerRef.current) return;
565
566    const xterm = new Terminal({
567      cursorBlink: true,
568      fontSize: 13,
569      fontFamily: 'Consolas, "Liberation Mono", Menlo, Courier, monospace',
570      theme: getTerminalTheme(isDark),
571      scrollback: 10000,
572    });
573    xtermRef.current = xterm;
574
575    const fitAddon = new FitAddon();
576    fitAddonRef.current = fitAddon;
577    xterm.loadAddon(fitAddon);
578    xterm.loadAddon(new WebLinksAddon());
579
580    xterm.open(containerRef.current);
581    fitAddon.fit();
582    onRegister(term.id, xterm);
583
584    const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
585    const wsUrl = `${protocol}//${window.location.host}/api/exec-ws?cmd=${encodeURIComponent(term.command)}&cwd=${encodeURIComponent(term.cwd)}`;
586    const ws = new WebSocket(wsUrl);
587    wsRef.current = ws;
588
589    ws.onopen = () => {
590      ws.send(JSON.stringify({ type: "init", cols: xterm.cols, rows: xterm.rows }));
591      onStatusChange(term.id, "running", null, 0);
592    };
593
594    ws.onmessage = (event) => {
595      try {
596        const msg = JSON.parse(event.data);
597        if (msg.type === "output" && msg.data) {
598          xterm.write(base64ToUint8Array(msg.data));
599          const buf = xterm.buffer.active;
600          let lines = 0;
601          for (let i = buf.length - 1; i >= 0; i--) {
602            const line = buf.getLine(i);
603            if (line && line.translateToString(true).trim()) {
604              lines = i + 1;
605              break;
606            }
607          }
608          onStatusChange(term.id, "running", null, lines);
609        } else if (msg.type === "exit") {
610          const code = parseInt(msg.data, 10) || 0;
611          const buf = xterm.buffer.active;
612          let lines = 0;
613          for (let i = buf.length - 1; i >= 0; i--) {
614            const line = buf.getLine(i);
615            if (line && line.translateToString(true).trim()) {
616              lines = i + 1;
617              break;
618            }
619          }
620          onStatusChange(term.id, "exited", code, lines);
621        } else if (msg.type === "error") {
622          xterm.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`);
623          onStatusChange(term.id, "error", null, 0);
624        }
625      } catch (err) {
626        console.error("Failed to parse terminal message:", err);
627      }
628    };
629
630    ws.onerror = (event) => console.error("WebSocket error:", event);
631    ws.onclose = () => {
632      onStatusChange(term.id, "exited", null, -1);
633    };
634
635    xterm.onData((data) => {
636      if (ws.readyState === WebSocket.OPEN) {
637        ws.send(JSON.stringify({ type: "input", data }));
638      }
639    });
640
641    const ro = new ResizeObserver(() => {
642      if (!fitAddonRef.current) return;
643      fitAddonRef.current.fit();
644      if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && xtermRef.current) {
645        wsRef.current.send(
646          JSON.stringify({
647            type: "resize",
648            cols: xtermRef.current.cols,
649            rows: xtermRef.current.rows,
650          }),
651        );
652      }
653    });
654    ro.observe(containerRef.current);
655
656    return () => {
657      ro.disconnect();
658      ws.close();
659      xterm.dispose();
660      onUnregister(term.id);
661    };
662    // eslint-disable-next-line react-hooks/exhaustive-deps
663  }, [term.id, term.command, term.cwd]);
664
665  // Update theme
666  useEffect(() => {
667    if (xtermRef.current) {
668      xtermRef.current.options.theme = getTerminalTheme(isDark);
669    }
670  }, [isDark]);
671
672  // Refit when visibility changes
673  useEffect(() => {
674    if (isVisible && fitAddonRef.current) {
675      setTimeout(() => fitAddonRef.current?.fit(), 20);
676    }
677  }, [isVisible]);
678
679  return (
680    <div
681      ref={containerRef}
682      data-terminal-id={term.id}
683      style={{
684        width: "100%",
685        height: "100%",
686        display: isVisible ? "block" : "none",
687        backgroundColor: isDark ? "#1a1b26" : "#f8f9fa",
688      }}
689    />
690  );
691}