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}