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}