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}