import { FitAddon } from "@xterm/addon-fit";import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";import { type ThreadId } from "@t3tools/contracts";import { Terminal, type ITheme } from "@xterm/xterm";import { type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState,} from "react";import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";import { extractTerminalLinks, isTerminalLinkActivation, preferredTerminalEditor, resolvePathLinkTarget,} from "../terminal-links";import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings";import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, MAX_THREAD_TERMINAL_COUNT, type ThreadTerminalGroup,} from "../types";import { readNativeApi } from "~/nativeApi"; const MIN_DRAWER_HEIGHT = 180;const MAX_DRAWER_HEIGHT_RATIO = 0.75; function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO));} function clampDrawerHeight(height: number): number { const safeHeight = Number.isFinite(height) ? height : DEFAULT_THREAD_TERMINAL_HEIGHT; const maxHeight = maxDrawerHeight(); return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight);} function writeSystemMessage(terminal: Terminal, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`);} function terminalThemeFromApp(): ITheme { const isDark = document.documentElement.classList.contains("dark"); const bodyStyles = getComputedStyle(document.body); const background = bodyStyles.backgroundColor || (isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"); const foreground = bodyStyles.color || (isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"); if (isDark) { return { background, foreground, cursor: "rgb(180, 203, 255)", selectionBackground: "rgba(180, 203, 255, 0.25)", scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)", scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)", scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)", black: "rgb(24, 30, 38)", red: "rgb(255, 122, 142)", green: "rgb(134, 231, 149)", yellow: "rgb(244, 205, 114)", blue: "rgb(137, 190, 255)", magenta: "rgb(208, 176, 255)", cyan: "rgb(124, 232, 237)", white: "rgb(210, 218, 230)", brightBlack: "rgb(110, 120, 136)", brightRed: "rgb(255, 168, 180)", brightGreen: "rgb(176, 245, 186)", brightYellow: "rgb(255, 224, 149)", brightBlue: "rgb(174, 210, 255)", brightMagenta: "rgb(229, 203, 255)", brightCyan: "rgb(167, 244, 247)", brightWhite: "rgb(244, 247, 252)", }; } return { background, foreground, cursor: "rgb(38, 56, 78)", selectionBackground: "rgba(37, 63, 99, 0.2)", scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)", scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)", scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)", black: "rgb(44, 53, 66)", red: "rgb(191, 70, 87)", green: "rgb(60, 126, 86)", yellow: "rgb(146, 112, 35)", blue: "rgb(72, 102, 163)", magenta: "rgb(132, 86, 149)", cyan: "rgb(53, 127, 141)", white: "rgb(210, 215, 223)", brightBlack: "rgb(112, 123, 140)", brightRed: "rgb(212, 95, 112)", brightGreen: "rgb(85, 148, 111)", brightYellow: "rgb(173, 133, 45)", brightBlue: "rgb(91, 124, 194)", brightMagenta: "rgb(153, 107, 172)", brightCyan: "rgb(70, 149, 164)", brightWhite: "rgb(236, 240, 246)", };} interface TerminalViewportProps { threadId: ThreadId; terminalId: string; cwd: string; runtimeEnv?: Record<string, string>; onSessionExited: () => void; focusRequestId: number; autoFocus: boolean; resizeEpoch: number; drawerHeight: number;} function TerminalViewport({ threadId, terminalId, cwd, runtimeEnv, onSessionExited, focusRequestId, autoFocus, resizeEpoch, drawerHeight,}: TerminalViewportProps) { const containerRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<Terminal | null>(null); const fitAddonRef = useRef<FitAddon | null>(null); const onSessionExitedRef = useRef(onSessionExited); const hasHandledExitRef = useRef(false); useEffect(() => { onSessionExitedRef.current = onSessionExited; }, [onSessionExited]); useEffect(() => { const mount = containerRef.current; if (!mount) return; let disposed = false; const fitAddon = new FitAddon(); const terminal = new Terminal({ cursorBlink: true, lineHeight: 1.2, fontSize: 12, scrollback: 5_000, fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', theme: terminalThemeFromApp(), }); terminal.loadAddon(fitAddon); terminal.open(mount); fitAddon.fit(); terminalRef.current = terminal; fitAddonRef.current = fitAddon; const api = readNativeApi(); if (!api) return; const sendTerminalInput = async (data: string, fallbackError: string) => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; try { await api.terminal.write({ threadId, terminalId, data }); } catch (error) { writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); } }; terminal.attachCustomKeyEventHandler((event) => { const navigationData = terminalNavigationShortcutData(event); if (navigationData !== null) { event.preventDefault(); event.stopPropagation(); void sendTerminalInput(navigationData, "Failed to move cursor"); return false; } if (!isTerminalClearShortcut(event)) return true; event.preventDefault(); event.stopPropagation(); void sendTerminalInput("\u000c", "Failed to clear terminal"); return false; }); const terminalLinksDisposable = terminal.registerLinkProvider({ provideLinks: (bufferLineNumber, callback) => { const activeTerminal = terminalRef.current; if (!activeTerminal) { callback(undefined); return; } const line = activeTerminal.buffer.active.getLine(bufferLineNumber - 1); if (!line) { callback(undefined); return; } const lineText = line.translateToString(true); const matches = extractTerminalLinks(lineText); if (matches.length === 0) { callback(undefined); return; } callback( matches.map((match) => ({ text: match.text, range: { start: { x: match.start + 1, y: bufferLineNumber }, end: { x: match.end, y: bufferLineNumber }, }, activate: (event: MouseEvent) => { if (!isTerminalLinkActivation(event)) return; const latestTerminal = terminalRef.current; if (!latestTerminal) return; if (match.kind === "url") { void api.shell.openExternal(match.text).catch((error) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open link", ); }); return; } const target = resolvePathLinkTarget(match.text, cwd); void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open path", ); }); }, })), ); }, }); const inputDisposable = terminal.onData((data) => { void api.terminal .write({ threadId, terminalId, data }) .catch((err) => writeSystemMessage( terminal, err instanceof Error ? err.message : "Terminal write failed", ), ); }); const themeObserver = new MutationObserver(() => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; activeTerminal.options.theme = terminalThemeFromApp(); activeTerminal.refresh(0, activeTerminal.rows - 1); }); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"], }); const openTerminal = async () => { try { const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; if (!activeTerminal || !activeFitAddon) return; activeFitAddon.fit(); const snapshot = await api.terminal.open({ threadId, terminalId, cwd, cols: activeTerminal.cols, rows: activeTerminal.rows, ...(runtimeEnv ? { env: runtimeEnv } : {}), }); if (disposed) return; activeTerminal.write("\u001bc"); if (snapshot.history.length > 0) { activeTerminal.write(snapshot.history); } if (autoFocus) { window.requestAnimationFrame(() => { activeTerminal.focus(); }); } } catch (err) { if (disposed) return; writeSystemMessage( terminal, err instanceof Error ? err.message : "Failed to open terminal", ); } }; const unsubscribe = api?.terminal.onEvent((event) => { if (event.threadId !== threadId || event.terminalId !== terminalId) return; const activeTerminal = terminalRef.current; if (!activeTerminal) return; if (event.type === "output") { activeTerminal.write(event.data); return; } if (event.type === "started" || event.type === "restarted") { hasHandledExitRef.current = false; activeTerminal.write("\u001bc"); if (event.snapshot.history.length > 0) { activeTerminal.write(event.snapshot.history); } return; } if (event.type === "cleared") { activeTerminal.clear(); activeTerminal.write("\u001bc"); return; } if (event.type === "error") { writeSystemMessage(activeTerminal, event.message); return; } if (event.type === "exited") { const details = [ typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, ] .filter((value): value is string => value !== null) .join(", "); writeSystemMessage( activeTerminal, details.length > 0 ? `Process exited (${details})` : "Process exited", ); if (hasHandledExitRef.current) { return; } hasHandledExitRef.current = true; window.setTimeout(() => { if (!hasHandledExitRef.current) { return; } onSessionExitedRef.current(); }, 0); } }); const fitTimer = window.setTimeout(() => { const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; if (!activeTerminal || !activeFitAddon) return; const wasAtBottom = activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; activeFitAddon.fit(); if (wasAtBottom) { activeTerminal.scrollToBottom(); } void api.terminal .resize({ threadId, terminalId, cols: activeTerminal.cols, rows: activeTerminal.rows, }) .catch(() => undefined); }, 30); void openTerminal(); return () => { disposed = true; window.clearTimeout(fitTimer); unsubscribe(); inputDisposable.dispose(); terminalLinksDisposable.dispose(); themeObserver.disconnect(); terminalRef.current = null; fitAddonRef.current = null; terminal.dispose(); }; // autoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, runtimeEnv, terminalId, threadId]); useEffect(() => { if (!autoFocus) return; const terminal = terminalRef.current; if (!terminal) return; const frame = window.requestAnimationFrame(() => { terminal.focus(); }); return () => { window.cancelAnimationFrame(frame); }; }, [autoFocus, focusRequestId]); useEffect(() => { const api = readNativeApi(); const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; if (!api || !terminal || !fitAddon) return; const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; const frame = window.requestAnimationFrame(() => { fitAddon.fit(); if (wasAtBottom) { terminal.scrollToBottom(); } void api.terminal .resize({ threadId, terminalId, cols: terminal.cols, rows: terminal.rows, }) .catch(() => undefined); }); return () => { window.cancelAnimationFrame(frame); }; }, [drawerHeight, resizeEpoch, terminalId, threadId]); return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />;} interface ThreadTerminalDrawerProps { threadId: ThreadId; cwd: string; runtimeEnv?: Record<string, string>; height: number; terminalIds: string[]; activeTerminalId: string; terminalGroups: ThreadTerminalGroup[]; activeTerminalGroupId: string; focusRequestId: number; onSplitTerminal: () => void; onNewTerminal: () => void; splitShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; closeShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void;} interface TerminalActionButtonProps { label: string; className: string; onClick: () => void; children: ReactNode;} function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { return ( <Popover> <PopoverTrigger openOnHover render={<button type="button" className={className} onClick={onClick} aria-label={label} />} > {children} </PopoverTrigger> <PopoverPopup tooltipStyle side="bottom" sideOffset={6} align="center" className="pointer-events-none select-none" > {label} </PopoverPopup> </Popover> );} export default function ThreadTerminalDrawer({ threadId, cwd, runtimeEnv, height, terminalIds, activeTerminalId, terminalGroups, activeTerminalGroupId, focusRequestId, onSplitTerminal, onNewTerminal, splitShortcutLabel, newShortcutLabel, closeShortcutLabel, onActiveTerminalChange,Lines 1–500 of 968. Load more
import { FitAddon } from "@xterm/addon-fit"; import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; import { type ThreadId } from "@t3tools/contracts"; import { Terminal, type ITheme } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { extractTerminalLinks, isTerminalLinkActivation, preferredTerminalEditor, resolvePathLinkTarget, } from "../terminal-links"; import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, MAX_THREAD_TERMINAL_COUNT, type ThreadTerminalGroup, } from "../types"; import { readNativeApi } from "~/nativeApi"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO)); } function clampDrawerHeight(height: number): number { const safeHeight = Number.isFinite(height) ? height : DEFAULT_THREAD_TERMINAL_HEIGHT; const maxHeight = maxDrawerHeight(); return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight); } function writeSystemMessage(terminal: Terminal, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } function terminalThemeFromApp(): ITheme { const isDark = document.documentElement.classList.contains("dark"); const bodyStyles = getComputedStyle(document.body); const background = bodyStyles.backgroundColor || (isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"); const foreground = bodyStyles.color || (isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"); if (isDark) { return { background, foreground, cursor: "rgb(180, 203, 255)", selectionBackground: "rgba(180, 203, 255, 0.25)", scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)", scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)", scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)", black: "rgb(24, 30, 38)", red: "rgb(255, 122, 142)", green: "rgb(134, 231, 149)", yellow: "rgb(244, 205, 114)", blue: "rgb(137, 190, 255)", magenta: "rgb(208, 176, 255)", cyan: "rgb(124, 232, 237)", white: "rgb(210, 218, 230)", brightBlack: "rgb(110, 120, 136)", brightRed: "rgb(255, 168, 180)", brightGreen: "rgb(176, 245, 186)", brightYellow: "rgb(255, 224, 149)", brightBlue: "rgb(174, 210, 255)", brightMagenta: "rgb(229, 203, 255)", brightCyan: "rgb(167, 244, 247)", brightWhite: "rgb(244, 247, 252)", }; } return { background, foreground, cursor: "rgb(38, 56, 78)", selectionBackground: "rgba(37, 63, 99, 0.2)", scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)", scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)", scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)", black: "rgb(44, 53, 66)", red: "rgb(191, 70, 87)", green: "rgb(60, 126, 86)", yellow: "rgb(146, 112, 35)", blue: "rgb(72, 102, 163)", magenta: "rgb(132, 86, 149)", cyan: "rgb(53, 127, 141)", white: "rgb(210, 215, 223)", brightBlack: "rgb(112, 123, 140)", brightRed: "rgb(212, 95, 112)", brightGreen: "rgb(85, 148, 111)", brightYellow: "rgb(173, 133, 45)", brightBlue: "rgb(91, 124, 194)", brightMagenta: "rgb(153, 107, 172)", brightCyan: "rgb(70, 149, 164)", brightWhite: "rgb(236, 240, 246)", }; } interface TerminalViewportProps { threadId: ThreadId; terminalId: string; cwd: string; runtimeEnv?: Record<string, string>; onSessionExited: () => void; focusRequestId: number; autoFocus: boolean; resizeEpoch: number; drawerHeight: number; } function TerminalViewport({ threadId, terminalId, cwd, runtimeEnv, onSessionExited, focusRequestId, autoFocus, resizeEpoch, drawerHeight, }: TerminalViewportProps) { const containerRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<Terminal | null>(null); const fitAddonRef = useRef<FitAddon | null>(null); const onSessionExitedRef = useRef(onSessionExited); const hasHandledExitRef = useRef(false); useEffect(() => { onSessionExitedRef.current = onSessionExited; }, [onSessionExited]); useEffect(() => { const mount = containerRef.current; if (!mount) return; let disposed = false; const fitAddon = new FitAddon(); const terminal = new Terminal({ cursorBlink: true, lineHeight: 1.2, fontSize: 12, scrollback: 5_000, fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', theme: terminalThemeFromApp(), }); terminal.loadAddon(fitAddon); terminal.open(mount); fitAddon.fit(); terminalRef.current = terminal; fitAddonRef.current = fitAddon; const api = readNativeApi(); if (!api) return; const sendTerminalInput = async (data: string, fallbackError: string) => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; try { await api.terminal.write({ threadId, terminalId, data }); } catch (error) { writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); } }; terminal.attachCustomKeyEventHandler((event) => { const navigationData = terminalNavigationShortcutData(event); if (navigationData !== null) { event.preventDefault(); event.stopPropagation(); void sendTerminalInput(navigationData, "Failed to move cursor"); return false; } if (!isTerminalClearShortcut(event)) return true; event.preventDefault(); event.stopPropagation(); void sendTerminalInput("\u000c", "Failed to clear terminal"); return false; }); const terminalLinksDisposable = terminal.registerLinkProvider({ provideLinks: (bufferLineNumber, callback) => { const activeTerminal = terminalRef.current; if (!activeTerminal) { callback(undefined); return; } const line = activeTerminal.buffer.active.getLine(bufferLineNumber - 1); if (!line) { callback(undefined); return; } const lineText = line.translateToString(true); const matches = extractTerminalLinks(lineText); if (matches.length === 0) { callback(undefined); return; } callback( matches.map((match) => ({ text: match.text, range: { start: { x: match.start + 1, y: bufferLineNumber }, end: { x: match.end, y: bufferLineNumber }, }, activate: (event: MouseEvent) => { if (!isTerminalLinkActivation(event)) return; const latestTerminal = terminalRef.current; if (!latestTerminal) return; if (match.kind === "url") { void api.shell.openExternal(match.text).catch((error) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open link", ); }); return; } const target = resolvePathLinkTarget(match.text, cwd); void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open path", ); }); }, })), ); }, }); const inputDisposable = terminal.onData((data) => { void api.terminal .write({ threadId, terminalId, data }) .catch((err) => writeSystemMessage( terminal, err instanceof Error ? err.message : "Terminal write failed", ), ); }); const themeObserver = new MutationObserver(() => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; activeTerminal.options.theme = terminalThemeFromApp(); activeTerminal.refresh(0, activeTerminal.rows - 1); }); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"], }); const openTerminal = async () => { try { const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; if (!activeTerminal || !activeFitAddon) return; activeFitAddon.fit(); const snapshot = await api.terminal.open({ threadId, terminalId, cwd, cols: activeTerminal.cols, rows: activeTerminal.rows, ...(runtimeEnv ? { env: runtimeEnv } : {}), }); if (disposed) return; activeTerminal.write("\u001bc"); if (snapshot.history.length > 0) { activeTerminal.write(snapshot.history); } if (autoFocus) { window.requestAnimationFrame(() => { activeTerminal.focus(); }); } } catch (err) { if (disposed) return; writeSystemMessage( terminal, err instanceof Error ? err.message : "Failed to open terminal", ); } }; const unsubscribe = api?.terminal.onEvent((event) => { if (event.threadId !== threadId || event.terminalId !== terminalId) return; const activeTerminal = terminalRef.current; if (!activeTerminal) return; if (event.type === "output") { activeTerminal.write(event.data); return; } if (event.type === "started" || event.type === "restarted") { hasHandledExitRef.current = false; activeTerminal.write("\u001bc"); if (event.snapshot.history.length > 0) { activeTerminal.write(event.snapshot.history); } return; } if (event.type === "cleared") { activeTerminal.clear(); activeTerminal.write("\u001bc"); return; } if (event.type === "error") { writeSystemMessage(activeTerminal, event.message); return; } if (event.type === "exited") { const details = [ typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, ] .filter((value): value is string => value !== null) .join(", "); writeSystemMessage( activeTerminal, details.length > 0 ? `Process exited (${details})` : "Process exited", ); if (hasHandledExitRef.current) { return; } hasHandledExitRef.current = true; window.setTimeout(() => { if (!hasHandledExitRef.current) { return; } onSessionExitedRef.current(); }, 0); } }); const fitTimer = window.setTimeout(() => { const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; if (!activeTerminal || !activeFitAddon) return; const wasAtBottom = activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; activeFitAddon.fit(); if (wasAtBottom) { activeTerminal.scrollToBottom(); } void api.terminal .resize({ threadId, terminalId, cols: activeTerminal.cols, rows: activeTerminal.rows, }) .catch(() => undefined); }, 30); void openTerminal(); return () => { disposed = true; window.clearTimeout(fitTimer); unsubscribe(); inputDisposable.dispose(); terminalLinksDisposable.dispose(); themeObserver.disconnect(); terminalRef.current = null; fitAddonRef.current = null; terminal.dispose(); }; // autoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, runtimeEnv, terminalId, threadId]); useEffect(() => { if (!autoFocus) return; const terminal = terminalRef.current; if (!terminal) return; const frame = window.requestAnimationFrame(() => { terminal.focus(); }); return () => { window.cancelAnimationFrame(frame); }; }, [autoFocus, focusRequestId]); useEffect(() => { const api = readNativeApi(); const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; if (!api || !terminal || !fitAddon) return; const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; const frame = window.requestAnimationFrame(() => { fitAddon.fit(); if (wasAtBottom) { terminal.scrollToBottom(); } void api.terminal .resize({ threadId, terminalId, cols: terminal.cols, rows: terminal.rows, }) .catch(() => undefined); }); return () => { window.cancelAnimationFrame(frame); }; }, [drawerHeight, resizeEpoch, terminalId, threadId]); return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />; } interface ThreadTerminalDrawerProps { threadId: ThreadId; cwd: string; runtimeEnv?: Record<string, string>; height: number; terminalIds: string[]; activeTerminalId: string; terminalGroups: ThreadTerminalGroup[]; activeTerminalGroupId: string; focusRequestId: number; onSplitTerminal: () => void; onNewTerminal: () => void; splitShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; closeShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; } interface TerminalActionButtonProps { label: string; className: string; onClick: () => void; children: ReactNode; } function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { return ( <Popover> <PopoverTrigger openOnHover render={<button type="button" className={className} onClick={onClick} aria-label={label} />} > {children} </PopoverTrigger> <PopoverPopup tooltipStyle side="bottom" sideOffset={6} align="center" className="pointer-events-none select-none" > {label} </PopoverPopup> </Popover> ); } export default function ThreadTerminalDrawer({ threadId, cwd, runtimeEnv, height, terminalIds, activeTerminalId, terminalGroups, activeTerminalGroupId, focusRequestId, onSplitTerminal, onNewTerminal, splitShortcutLabel, newShortcutLabel, closeShortcutLabel, onActiveTerminalChange,