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