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}