TerminalWidget.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
  7interface TerminalWidgetProps {
  8  command: string;
  9  cwd: string;
 10  onInsertIntoInput?: (text: string) => void;
 11  onClose?: () => void;
 12}
 13
 14// Theme colors for xterm.js
 15function getTerminalTheme(isDark: boolean): Record<string, string> {
 16  if (isDark) {
 17    return {
 18      background: "#1a1b26",
 19      foreground: "#c0caf5",
 20      cursor: "#c0caf5",
 21      cursorAccent: "#1a1b26",
 22      selectionBackground: "#364a82",
 23      selectionForeground: "#c0caf5",
 24      black: "#32344a",
 25      red: "#f7768e",
 26      green: "#9ece6a",
 27      yellow: "#e0af68",
 28      blue: "#7aa2f7",
 29      magenta: "#ad8ee6",
 30      cyan: "#449dab",
 31      white: "#9699a8",
 32      brightBlack: "#444b6a",
 33      brightRed: "#ff7a93",
 34      brightGreen: "#b9f27c",
 35      brightYellow: "#ff9e64",
 36      brightBlue: "#7da6ff",
 37      brightMagenta: "#bb9af7",
 38      brightCyan: "#0db9d7",
 39      brightWhite: "#acb0d0",
 40    };
 41  }
 42  // Light theme
 43  return {
 44    background: "#f8f9fa",
 45    foreground: "#383a42",
 46    cursor: "#526eff",
 47    cursorAccent: "#f8f9fa",
 48    selectionBackground: "#bfceff",
 49    selectionForeground: "#383a42",
 50    black: "#383a42",
 51    red: "#e45649",
 52    green: "#50a14f",
 53    yellow: "#c18401",
 54    blue: "#4078f2",
 55    magenta: "#a626a4",
 56    cyan: "#0184bc",
 57    white: "#a0a1a7",
 58    brightBlack: "#4f525e",
 59    brightRed: "#e06c75",
 60    brightGreen: "#98c379",
 61    brightYellow: "#e5c07b",
 62    brightBlue: "#61afef",
 63    brightMagenta: "#c678dd",
 64    brightCyan: "#56b6c2",
 65    brightWhite: "#ffffff",
 66  };
 67}
 68
 69// Reusable icon button component matching MessageActionBar style
 70function ActionButton({
 71  onClick,
 72  title,
 73  children,
 74  feedback,
 75}: {
 76  onClick: () => void;
 77  title: string;
 78  children: React.ReactNode;
 79  feedback?: boolean;
 80}) {
 81  return (
 82    <button
 83      onClick={onClick}
 84      title={title}
 85      style={{
 86        display: "flex",
 87        alignItems: "center",
 88        justifyContent: "center",
 89        width: "24px",
 90        height: "24px",
 91        borderRadius: "4px",
 92        border: "none",
 93        background: feedback ? "var(--success-bg)" : "transparent",
 94        cursor: "pointer",
 95        color: feedback ? "var(--success-text)" : "var(--text-secondary)",
 96        transition: "background-color 0.15s, color 0.15s",
 97      }}
 98      onMouseEnter={(e) => {
 99        if (!feedback) {
100          e.currentTarget.style.backgroundColor = "var(--bg-tertiary)";
101        }
102      }}
103      onMouseLeave={(e) => {
104        if (!feedback) {
105          e.currentTarget.style.backgroundColor = "transparent";
106        }
107      }}
108    >
109      {children}
110    </button>
111  );
112}
113
114// SVG icons
115const CopyIcon = () => (
116  <svg
117    width="14"
118    height="14"
119    viewBox="0 0 24 24"
120    fill="none"
121    stroke="currentColor"
122    strokeWidth="2"
123    strokeLinecap="round"
124    strokeLinejoin="round"
125  >
126    <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
127    <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
128  </svg>
129);
130
131const CheckIcon = () => (
132  <svg
133    width="14"
134    height="14"
135    viewBox="0 0 24 24"
136    fill="none"
137    stroke="currentColor"
138    strokeWidth="2"
139    strokeLinecap="round"
140    strokeLinejoin="round"
141  >
142    <polyline points="20 6 9 17 4 12" />
143  </svg>
144);
145
146const InsertIcon = () => (
147  <svg
148    width="14"
149    height="14"
150    viewBox="0 0 24 24"
151    fill="none"
152    stroke="currentColor"
153    strokeWidth="2"
154    strokeLinecap="round"
155    strokeLinejoin="round"
156  >
157    <path d="M12 3v12" />
158    <path d="m8 11 4 4 4-4" />
159    <path d="M4 21h16" />
160  </svg>
161);
162
163const CloseIcon = () => (
164  <svg
165    width="14"
166    height="14"
167    viewBox="0 0 24 24"
168    fill="none"
169    stroke="currentColor"
170    strokeWidth="2"
171    strokeLinecap="round"
172    strokeLinejoin="round"
173  >
174    <line x1="18" y1="6" x2="6" y2="18" />
175    <line x1="6" y1="6" x2="18" y2="18" />
176  </svg>
177);
178
179export default function TerminalWidget({
180  command,
181  cwd,
182  onInsertIntoInput,
183  onClose,
184}: TerminalWidgetProps) {
185  const terminalRef = useRef<HTMLDivElement>(null);
186  const xtermRef = useRef<Terminal | null>(null);
187  const fitAddonRef = useRef<FitAddon | null>(null);
188  const wsRef = useRef<WebSocket | null>(null);
189  const [status, setStatus] = useState<"connecting" | "running" | "exited" | "error">("connecting");
190  const [exitCode, setExitCode] = useState<number | null>(null);
191  const [height, setHeight] = useState(300);
192  const [autoSized, setAutoSized] = useState(false);
193  const isResizingRef = useRef(false);
194  const startYRef = useRef(0);
195  const startHeightRef = useRef(0);
196  const lineCountRef = useRef(0);
197  const [copyFeedback, setCopyFeedback] = useState<string | null>(null);
198
199  // Detect dark mode
200  const isDarkMode = () => {
201    return document.documentElement.getAttribute("data-theme") === "dark";
202  };
203
204  const [isDark, setIsDark] = useState(isDarkMode);
205
206  // Watch for theme changes
207  useEffect(() => {
208    const observer = new MutationObserver(() => {
209      const newIsDark = isDarkMode();
210      setIsDark(newIsDark);
211      if (xtermRef.current) {
212        xtermRef.current.options.theme = getTerminalTheme(newIsDark);
213      }
214    });
215
216    observer.observe(document.documentElement, {
217      attributes: true,
218      attributeFilter: ["data-theme"],
219    });
220
221    return () => observer.disconnect();
222  }, []);
223
224  // Show copy feedback briefly
225  const showFeedback = useCallback((type: string) => {
226    setCopyFeedback(type);
227    setTimeout(() => setCopyFeedback(null), 1500);
228  }, []);
229
230  // Copy screen content (visible area)
231  const copyScreen = useCallback(() => {
232    if (!xtermRef.current) return;
233    const term = xtermRef.current;
234    const lines: string[] = [];
235    const buffer = term.buffer.active;
236    const startRow = buffer.viewportY;
237    for (let i = 0; i < term.rows; i++) {
238      const line = buffer.getLine(startRow + i);
239      if (line) {
240        lines.push(line.translateToString(true));
241      }
242    }
243    const text = lines.join("\n").trimEnd();
244    navigator.clipboard.writeText(text);
245    showFeedback("copyScreen");
246  }, [showFeedback]);
247
248  // Copy scrollback buffer (entire history)
249  const copyScrollback = useCallback(() => {
250    if (!xtermRef.current) return;
251    const term = xtermRef.current;
252    const lines: string[] = [];
253    const buffer = term.buffer.active;
254    for (let i = 0; i < buffer.length; i++) {
255      const line = buffer.getLine(i);
256      if (line) {
257        lines.push(line.translateToString(true));
258      }
259    }
260    const text = lines.join("\n").trimEnd();
261    navigator.clipboard.writeText(text);
262    showFeedback("copyAll");
263  }, [showFeedback]);
264
265  // Insert into input
266  const handleInsertScreen = useCallback(() => {
267    if (!xtermRef.current || !onInsertIntoInput) return;
268    const term = xtermRef.current;
269    const lines: string[] = [];
270    const buffer = term.buffer.active;
271    const startRow = buffer.viewportY;
272    for (let i = 0; i < term.rows; i++) {
273      const line = buffer.getLine(startRow + i);
274      if (line) {
275        lines.push(line.translateToString(true));
276      }
277    }
278    const text = lines.join("\n").trimEnd();
279    onInsertIntoInput(text);
280    showFeedback("insertScreen");
281  }, [onInsertIntoInput, showFeedback]);
282
283  const handleInsertScrollback = useCallback(() => {
284    if (!xtermRef.current || !onInsertIntoInput) return;
285    const term = xtermRef.current;
286    const lines: string[] = [];
287    const buffer = term.buffer.active;
288    for (let i = 0; i < buffer.length; i++) {
289      const line = buffer.getLine(i);
290      if (line) {
291        lines.push(line.translateToString(true));
292      }
293    }
294    const text = lines.join("\n").trimEnd();
295    onInsertIntoInput(text);
296    showFeedback("insertAll");
297  }, [onInsertIntoInput, showFeedback]);
298
299  // Close handler - kills the websocket/process
300  const handleClose = useCallback(() => {
301    if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
302      wsRef.current.close();
303    }
304    if (onClose) {
305      onClose();
306    }
307  }, [onClose]);
308
309  // Resize handling
310  const handleMouseDown = useCallback(
311    (e: React.MouseEvent) => {
312      e.preventDefault();
313      isResizingRef.current = true;
314      startYRef.current = e.clientY;
315      startHeightRef.current = height;
316
317      const handleMouseMove = (e: MouseEvent) => {
318        if (!isResizingRef.current) return;
319        const delta = e.clientY - startYRef.current;
320        const newHeight = Math.max(80, Math.min(800, startHeightRef.current + delta));
321        setHeight(newHeight);
322        setAutoSized(false); // User manually resized, disable auto-sizing
323      };
324
325      const handleMouseUp = () => {
326        isResizingRef.current = false;
327        document.removeEventListener("mousemove", handleMouseMove);
328        document.removeEventListener("mouseup", handleMouseUp);
329        // Refit terminal after resize
330        if (fitAddonRef.current) {
331          fitAddonRef.current.fit();
332        }
333      };
334
335      document.addEventListener("mousemove", handleMouseMove);
336      document.addEventListener("mouseup", handleMouseUp);
337    },
338    [height],
339  );
340
341  // Auto-size terminal based on content when process exits
342  const autoSizeTerminal = useCallback(() => {
343    if (!xtermRef.current || autoSized) return;
344
345    const term = xtermRef.current;
346    const buffer = term.buffer.active;
347
348    // Count actual content lines (non-empty from the end)
349    let contentLines = 0;
350    for (let i = buffer.length - 1; i >= 0; i--) {
351      const line = buffer.getLine(i);
352      if (line && line.translateToString(true).trim()) {
353        contentLines = i + 1;
354        break;
355      }
356    }
357
358    // Get actual cell dimensions from xterm
359    // @ts-expect-error - accessing private _core for accurate measurements
360    const core = term._core;
361    const cellHeight = core?._renderService?.dimensions?.css?.cell?.height || 17;
362
363    // Minimal padding for the terminal area
364    const minHeight = 34; // ~2 lines minimum
365    const maxHeight = 400;
366
367    // Calculate exact height needed for content lines
368    const neededHeight = Math.min(
369      maxHeight,
370      Math.max(minHeight, Math.ceil(contentLines * cellHeight) + 4),
371    );
372
373    setHeight(neededHeight);
374    setAutoSized(true);
375
376    // Refit after height change
377    setTimeout(() => fitAddonRef.current?.fit(), 20);
378  }, [autoSized]);
379
380  useEffect(() => {
381    if (!terminalRef.current) return;
382
383    // Create terminal
384    const term = new Terminal({
385      cursorBlink: true,
386      fontSize: 13,
387      fontFamily: 'Consolas, "Liberation Mono", Menlo, Courier, monospace',
388      theme: getTerminalTheme(isDark),
389      scrollback: 10000,
390    });
391    xtermRef.current = term;
392
393    // Add fit addon
394    const fitAddon = new FitAddon();
395    fitAddonRef.current = fitAddon;
396    term.loadAddon(fitAddon);
397
398    // Add web links addon
399    const webLinksAddon = new WebLinksAddon();
400    term.loadAddon(webLinksAddon);
401
402    // Open terminal in DOM
403    term.open(terminalRef.current);
404    fitAddon.fit();
405
406    // Connect websocket
407    const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
408    const wsUrl = `${protocol}//${window.location.host}/api/exec-ws?cmd=${encodeURIComponent(command)}&cwd=${encodeURIComponent(cwd)}`;
409    const ws = new WebSocket(wsUrl);
410    wsRef.current = ws;
411
412    ws.onopen = () => {
413      // Send init message with terminal size
414      ws.send(
415        JSON.stringify({
416          type: "init",
417          cols: term.cols,
418          rows: term.rows,
419        }),
420      );
421      setStatus("running");
422    };
423
424    ws.onmessage = (event) => {
425      try {
426        const msg = JSON.parse(event.data);
427        if (msg.type === "output" && msg.data) {
428          // Decode base64 data
429          const decoded = atob(msg.data);
430          term.write(decoded);
431          // Track line count for auto-sizing
432          lineCountRef.current = term.buffer.active.length;
433        } else if (msg.type === "exit") {
434          const code = parseInt(msg.data, 10) || 0;
435          setExitCode(code);
436          setStatus("exited");
437        } else if (msg.type === "error") {
438          term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`);
439          setStatus("error");
440        }
441      } catch (err) {
442        console.error("Failed to parse terminal message:", err);
443      }
444    };
445
446    ws.onerror = (event) => {
447      console.error("WebSocket error:", event);
448    };
449
450    ws.onclose = () => {
451      setStatus((currentStatus) => {
452        if (currentStatus === "exited") return currentStatus;
453        return "exited";
454      });
455    };
456
457    // Handle terminal input
458    term.onData((data) => {
459      if (ws.readyState === WebSocket.OPEN) {
460        ws.send(JSON.stringify({ type: "input", data }));
461      }
462    });
463
464    // Handle resize
465    const resizeObserver = new ResizeObserver(() => {
466      fitAddon.fit();
467      if (ws.readyState === WebSocket.OPEN) {
468        ws.send(
469          JSON.stringify({
470            type: "resize",
471            cols: term.cols,
472            rows: term.rows,
473          }),
474        );
475      }
476    });
477    resizeObserver.observe(terminalRef.current);
478
479    return () => {
480      resizeObserver.disconnect();
481      ws.close();
482      term.dispose();
483    };
484  }, [command, cwd]); // Only recreate on command/cwd change, not on isDark change
485
486  // Auto-size when process exits
487  useEffect(() => {
488    if (status === "exited" || status === "error") {
489      // Small delay to ensure all output is written
490      setTimeout(autoSizeTerminal, 100);
491    }
492  }, [status, autoSizeTerminal]);
493
494  // Update theme when isDark changes without recreating terminal
495  useEffect(() => {
496    if (xtermRef.current) {
497      xtermRef.current.options.theme = getTerminalTheme(isDark);
498    }
499  }, [isDark]);
500
501  // Fit terminal when height changes
502  useEffect(() => {
503    if (fitAddonRef.current) {
504      setTimeout(() => fitAddonRef.current?.fit(), 10);
505    }
506  }, [height]);
507
508  return (
509    <div className="terminal-widget" style={{ marginBottom: "1rem" }}>
510      {/* Header */}
511      <div
512        className="terminal-widget-header"
513        style={{
514          display: "flex",
515          alignItems: "center",
516          justifyContent: "space-between",
517          padding: "6px 12px",
518          backgroundColor: "var(--bg-secondary)",
519          borderRadius: "8px 8px 0 0",
520          border: "1px solid var(--border)",
521          borderBottom: "none",
522        }}
523      >
524        <div style={{ display: "flex", alignItems: "center", gap: "8px", flex: 1, minWidth: 0 }}>
525          <svg
526            width="14"
527            height="14"
528            viewBox="0 0 24 24"
529            fill="none"
530            stroke="currentColor"
531            strokeWidth="2"
532            style={{ flexShrink: 0, color: "var(--text-secondary)" }}
533          >
534            <polyline points="4 17 10 11 4 5" />
535            <line x1="12" y1="19" x2="20" y2="19" />
536          </svg>
537          <code
538            style={{
539              fontSize: "12px",
540              fontFamily: 'Consolas, "Liberation Mono", Menlo, monospace',
541              color: "var(--text-primary)",
542              overflow: "hidden",
543              textOverflow: "ellipsis",
544              whiteSpace: "nowrap",
545            }}
546          >
547            {command}
548          </code>
549          {status === "running" && (
550            <span
551              style={{
552                fontSize: "11px",
553                color: "var(--success-text)",
554                fontWeight: 500,
555                flexShrink: 0,
556              }}
557            >
558               running
559            </span>
560          )}
561          {status === "exited" && (
562            <span
563              style={{
564                fontSize: "11px",
565                color: exitCode === 0 ? "var(--success-text)" : "var(--error-text)",
566                fontWeight: 500,
567                flexShrink: 0,
568              }}
569            >
570              exit {exitCode}
571            </span>
572          )}
573          {status === "error" && (
574            <span
575              style={{
576                fontSize: "11px",
577                color: "var(--error-text)",
578                fontWeight: 500,
579                flexShrink: 0,
580              }}
581            >
582               error
583            </span>
584          )}
585        </div>
586
587        {/* Action buttons - styled like MessageActionBar */}
588        <div
589          style={{
590            display: "flex",
591            gap: "2px",
592            background: "var(--bg-base)",
593            border: "1px solid var(--border)",
594            borderRadius: "4px",
595            padding: "2px",
596          }}
597        >
598          <ActionButton
599            onClick={copyScreen}
600            title="Copy visible screen to clipboard"
601            feedback={copyFeedback === "copyScreen"}
602          >
603            {copyFeedback === "copyScreen" ? <CheckIcon /> : <CopyIcon />}
604          </ActionButton>
605          <ActionButton
606            onClick={copyScrollback}
607            title="Copy all output to clipboard"
608            feedback={copyFeedback === "copyAll"}
609          >
610            {copyFeedback === "copyAll" ? (
611              <CheckIcon />
612            ) : (
613              <svg
614                width="14"
615                height="14"
616                viewBox="0 0 24 24"
617                fill="none"
618                stroke="currentColor"
619                strokeWidth="2"
620                strokeLinecap="round"
621                strokeLinejoin="round"
622              >
623                <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
624                <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
625                <line x1="12" y1="17" x2="18" y2="17" />
626              </svg>
627            )}
628          </ActionButton>
629          {onInsertIntoInput && (
630            <>
631              <ActionButton
632                onClick={handleInsertScreen}
633                title="Insert visible screen into message input"
634                feedback={copyFeedback === "insertScreen"}
635              >
636                {copyFeedback === "insertScreen" ? <CheckIcon /> : <InsertIcon />}
637              </ActionButton>
638              <ActionButton
639                onClick={handleInsertScrollback}
640                title="Insert all output into message input"
641                feedback={copyFeedback === "insertAll"}
642              >
643                {copyFeedback === "insertAll" ? (
644                  <CheckIcon />
645                ) : (
646                  <svg
647                    width="14"
648                    height="14"
649                    viewBox="0 0 24 24"
650                    fill="none"
651                    stroke="currentColor"
652                    strokeWidth="2"
653                    strokeLinecap="round"
654                    strokeLinejoin="round"
655                  >
656                    <path d="M12 3v12" />
657                    <path d="m8 11 4 4 4-4" />
658                    <path d="M4 21h16" />
659                    <line x1="4" y1="18" x2="20" y2="18" />
660                  </svg>
661                )}
662              </ActionButton>
663            </>
664          )}
665          <div
666            style={{
667              width: "1px",
668              background: "var(--border)",
669              margin: "2px 2px",
670            }}
671          />
672          <ActionButton onClick={handleClose} title="Close terminal and kill process">
673            <CloseIcon />
674          </ActionButton>
675        </div>
676      </div>
677
678      {/* Terminal container */}
679      <div
680        ref={terminalRef}
681        style={{
682          height: `${height}px`,
683          backgroundColor: isDark ? "#1a1b26" : "#f8f9fa",
684          border: "1px solid var(--border)",
685          borderTop: "none",
686          borderBottom: "none",
687          overflow: "hidden",
688        }}
689      />
690
691      {/* Resize handle */}
692      <div
693        onMouseDown={handleMouseDown}
694        style={{
695          height: "8px",
696          cursor: "ns-resize",
697          backgroundColor: "var(--bg-secondary)",
698          border: "1px solid var(--border)",
699          borderTop: "none",
700          borderRadius: "0 0 8px 8px",
701          display: "flex",
702          alignItems: "center",
703          justifyContent: "center",
704        }}
705      >
706        <div
707          style={{
708            width: "40px",
709            height: "3px",
710            backgroundColor: "var(--text-tertiary)",
711            borderRadius: "2px",
712          }}
713        />
714      </div>
715    </div>
716  );
717}