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  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  onInsertIntoInput,
213}: TerminalPanelProps) {
214  const [activeTabId, setActiveTabId] = useState<string | null>(null);
215  const [height, setHeight] = useState(300);
216  const [heightLocked, setHeightLocked] = useState(false);
217  const isFirstTerminalRef = useRef(true);
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(isDarkModeActive);
228
229  useEffect(() => {
230    const observer = new MutationObserver(() => {
231      setIsDark(isDarkModeActive());
232    });
233    observer.observe(document.documentElement, {
234      attributes: true,
235      attributeFilter: ["class"],
236    });
237    return () => observer.disconnect();
238  }, []);
239
240  // Auto-select newest tab when a new terminal is added
241  useEffect(() => {
242    if (terminals.length > 0) {
243      const lastTerminal = terminals[terminals.length - 1];
244      setActiveTabId(lastTerminal.id);
245    } else {
246      setActiveTabId(null);
247    }
248  }, [terminals.length]);
249
250  // If active tab got closed, switch to the last remaining
251  useEffect(() => {
252    if (activeTabId && !terminals.find((t) => t.id === activeTabId)) {
253      if (terminals.length > 0) {
254        setActiveTabId(terminals[terminals.length - 1].id);
255      } else {
256        setActiveTabId(null);
257      }
258    }
259  }, [terminals, activeTabId]);
260
261  const handleStatusChange = useCallback(
262    (id: string, status: TermStatus, exitCode: number | null, contentLines: number) => {
263      setStatusMap((prev) => {
264        const next = new Map(prev);
265        const existing = next.get(id);
266        // Don't overwrite exit status with ws.onclose
267        if (
268          existing &&
269          existing.status === "exited" &&
270          status === "exited" &&
271          contentLines === -1
272        ) {
273          return prev;
274        }
275        const lines = contentLines === -1 ? existing?.contentLines || 0 : contentLines;
276        next.set(id, {
277          status,
278          exitCode: exitCode ?? existing?.exitCode ?? null,
279          contentLines: lines,
280        });
281        return next;
282      });
283    },
284    [],
285  );
286
287  // Auto-size only for the very first terminal. After that, keep whatever height we have.
288  useEffect(() => {
289    if (heightLocked || !activeTabId) return;
290    if (!isFirstTerminalRef.current) return;
291    const info = statusMap.get(activeTabId);
292    if (!info) return;
293
294    const cellHeight = 17; // approximate
295    const minHeight = 60;
296    const maxHeight = 500;
297    const tabBarHeight = 38;
298
299    if (info.status === "exited" || info.status === "error") {
300      const needed = Math.min(
301        maxHeight,
302        Math.max(minHeight, info.contentLines * cellHeight + tabBarHeight + 16),
303      );
304      setHeight(needed);
305      setHeightLocked(true);
306      isFirstTerminalRef.current = false;
307    } else if (info.status === "running") {
308      // While the first command is still running, grow if needed
309      const needed = Math.min(
310        maxHeight,
311        Math.max(minHeight, info.contentLines * cellHeight + tabBarHeight + 16),
312      );
313      setHeight((prev) => Math.max(prev, needed));
314    }
315  }, [statusMap, activeTabId, heightLocked]);
316
317  // Resize drag
318  const handleResizeMouseDown = useCallback(
319    (e: React.MouseEvent) => {
320      e.preventDefault();
321      isResizingRef.current = true;
322      startYRef.current = e.clientY;
323      startHeightRef.current = height;
324
325      const handleMouseMove = (e: MouseEvent) => {
326        if (!isResizingRef.current) return;
327        // Dragging up increases height
328        const delta = startYRef.current - e.clientY;
329        setHeight(Math.max(80, Math.min(800, startHeightRef.current + delta)));
330        setHeightLocked(true);
331        isFirstTerminalRef.current = false;
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  // Truncate command for tab label
419  const tabLabel = (cmd: string) => {
420    // Show first word or first 30 chars
421    const firstWord = cmd.split(/\s+/)[0];
422    if (firstWord.length > 30) return firstWord.substring(0, 27) + "...";
423    return firstWord;
424  };
425
426  return (
427    <div className="terminal-panel" style={{ height: `${height}px`, flexShrink: 0 }}>
428      {/* Resize handle at top */}
429      <div className="terminal-panel-resize-handle" onMouseDown={handleResizeMouseDown}>
430        <div className="terminal-panel-resize-grip" />
431      </div>
432
433      {/* Tab bar + actions */}
434      <div className="terminal-panel-header">
435        <div className="terminal-panel-tabs">
436          {terminals.map((t) => {
437            const info = statusMap.get(t.id);
438            const isActive = t.id === activeTabId;
439            return (
440              <div
441                key={t.id}
442                className={`terminal-panel-tab${isActive ? " terminal-panel-tab-active" : ""}`}
443                onClick={() => setActiveTabId(t.id)}
444                title={t.command}
445              >
446                {info?.status === "running" && (
447                  <span className="terminal-panel-tab-indicator terminal-panel-tab-running"></span>
448                )}
449                {info?.status === "exited" && info.exitCode === 0 && (
450                  <span className="terminal-panel-tab-indicator terminal-panel-tab-success"></span>
451                )}
452                {info?.status === "exited" && info.exitCode !== 0 && (
453                  <span className="terminal-panel-tab-indicator terminal-panel-tab-error"></span>
454                )}
455                {info?.status === "error" && (
456                  <span className="terminal-panel-tab-indicator terminal-panel-tab-error"></span>
457                )}
458                <span className="terminal-panel-tab-label">{tabLabel(t.command)}</span>
459                <button
460                  className="terminal-panel-tab-close"
461                  onClick={(e) => {
462                    e.stopPropagation();
463                    onClose(t.id);
464                  }}
465                  title="Close terminal"
466                >
467                  ×
468                </button>
469              </div>
470            );
471          })}
472        </div>
473
474        {/* Action buttons */}
475        <div className="terminal-panel-actions">
476          <ActionButton
477            onClick={copyScreen}
478            title="Copy visible screen"
479            feedback={copyFeedback === "copyScreen"}
480          >
481            {copyFeedback === "copyScreen" ? <CheckIcon /> : <CopyIcon />}
482          </ActionButton>
483          <ActionButton
484            onClick={copyAll}
485            title="Copy all output"
486            feedback={copyFeedback === "copyAll"}
487          >
488            {copyFeedback === "copyAll" ? <CheckIcon /> : <CopyAllIcon />}
489          </ActionButton>
490          {onInsertIntoInput && (
491            <>
492              <ActionButton
493                onClick={insertScreen}
494                title="Insert visible screen into input"
495                feedback={copyFeedback === "insertScreen"}
496              >
497                {copyFeedback === "insertScreen" ? <CheckIcon /> : <InsertIcon />}
498              </ActionButton>
499              <ActionButton
500                onClick={insertAll}
501                title="Insert all output into input"
502                feedback={copyFeedback === "insertAll"}
503              >
504                {copyFeedback === "insertAll" ? <CheckIcon /> : <InsertAllIcon />}
505              </ActionButton>
506            </>
507          )}
508          <div className="terminal-panel-actions-divider" />
509          <ActionButton onClick={handleCloseActive} title="Close active terminal">
510            <CloseIcon />
511          </ActionButton>
512        </div>
513      </div>
514
515      {/* Terminal content area */}
516      <div className="terminal-panel-content">
517        {terminals.map((t) => (
518          <TerminalInstanceWithRegistry
519            key={t.id}
520            term={t}
521            isVisible={t.id === activeTabId}
522            isDark={isDark}
523            onStatusChange={handleStatusChange}
524            onRegister={registerXterm}
525            onUnregister={unregisterXterm}
526          />
527        ))}
528      </div>
529    </div>
530  );
531}
532
533// Wrapper that also registers the xterm instance
534function TerminalInstanceWithRegistry({
535  term,
536  isVisible,
537  isDark,
538  onStatusChange,
539  onRegister,
540  onUnregister,
541}: {
542  term: EphemeralTerminal;
543  isVisible: boolean;
544  isDark: boolean;
545  onStatusChange: (
546    id: string,
547    status: TermStatus,
548    exitCode: number | null,
549    contentLines: number,
550  ) => void;
551  onRegister: (id: string, xterm: Terminal) => void;
552  onUnregister: (id: string) => void;
553}) {
554  const containerRef = useRef<HTMLDivElement>(null);
555  const xtermRef = useRef<Terminal | null>(null);
556  const fitAddonRef = useRef<FitAddon | null>(null);
557  const wsRef = useRef<WebSocket | null>(null);
558
559  useEffect(() => {
560    if (!containerRef.current) return;
561
562    const xterm = new Terminal({
563      cursorBlink: true,
564      fontSize: 13,
565      fontFamily: 'Consolas, "Liberation Mono", Menlo, Courier, monospace',
566      theme: getTerminalTheme(isDark),
567      scrollback: 10000,
568    });
569    xtermRef.current = xterm;
570
571    const fitAddon = new FitAddon();
572    fitAddonRef.current = fitAddon;
573    xterm.loadAddon(fitAddon);
574    xterm.loadAddon(new WebLinksAddon());
575
576    xterm.open(containerRef.current);
577    fitAddon.fit();
578    onRegister(term.id, xterm);
579
580    const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
581    const wsUrl = `${protocol}//${window.location.host}/api/exec-ws?cmd=${encodeURIComponent(term.command)}&cwd=${encodeURIComponent(term.cwd)}`;
582    const ws = new WebSocket(wsUrl);
583    wsRef.current = ws;
584
585    ws.onopen = () => {
586      ws.send(JSON.stringify({ type: "init", cols: xterm.cols, rows: xterm.rows }));
587      onStatusChange(term.id, "running", null, 0);
588    };
589
590    ws.onmessage = (event) => {
591      try {
592        const msg = JSON.parse(event.data);
593        if (msg.type === "output" && msg.data) {
594          xterm.write(base64ToUint8Array(msg.data));
595          const buf = xterm.buffer.active;
596          let lines = 0;
597          for (let i = buf.length - 1; i >= 0; i--) {
598            const line = buf.getLine(i);
599            if (line && line.translateToString(true).trim()) {
600              lines = i + 1;
601              break;
602            }
603          }
604          onStatusChange(term.id, "running", null, lines);
605        } else if (msg.type === "exit") {
606          const code = parseInt(msg.data, 10) || 0;
607          const buf = xterm.buffer.active;
608          let lines = 0;
609          for (let i = buf.length - 1; i >= 0; i--) {
610            const line = buf.getLine(i);
611            if (line && line.translateToString(true).trim()) {
612              lines = i + 1;
613              break;
614            }
615          }
616          onStatusChange(term.id, "exited", code, lines);
617        } else if (msg.type === "error") {
618          xterm.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`);
619          onStatusChange(term.id, "error", null, 0);
620        }
621      } catch (err) {
622        console.error("Failed to parse terminal message:", err);
623      }
624    };
625
626    ws.onerror = (event) => console.error("WebSocket error:", event);
627    ws.onclose = () => {
628      onStatusChange(term.id, "exited", null, -1);
629    };
630
631    xterm.onData((data) => {
632      if (ws.readyState === WebSocket.OPEN) {
633        ws.send(JSON.stringify({ type: "input", data }));
634      }
635    });
636
637    const ro = new ResizeObserver(() => {
638      if (!fitAddonRef.current) return;
639      fitAddonRef.current.fit();
640      if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && xtermRef.current) {
641        wsRef.current.send(
642          JSON.stringify({
643            type: "resize",
644            cols: xtermRef.current.cols,
645            rows: xtermRef.current.rows,
646          }),
647        );
648      }
649    });
650    ro.observe(containerRef.current);
651
652    return () => {
653      ro.disconnect();
654      ws.close();
655      xterm.dispose();
656      onUnregister(term.id);
657    };
658  }, [term.id, term.command, term.cwd]);
659
660  // Update theme
661  useEffect(() => {
662    if (xtermRef.current) {
663      xtermRef.current.options.theme = getTerminalTheme(isDark);
664    }
665  }, [isDark]);
666
667  // Refit when visibility changes
668  useEffect(() => {
669    if (isVisible && fitAddonRef.current) {
670      setTimeout(() => fitAddonRef.current?.fit(), 20);
671    }
672  }, [isVisible]);
673
674  return (
675    <div
676      ref={containerRef}
677      data-terminal-id={term.id}
678      style={{
679        width: "100%",
680        height: "100%",
681        display: isVisible ? "block" : "none",
682        backgroundColor: isDark ? "#1a1b26" : "#f8f9fa",
683      }}
684    />
685  );
686}