import { FitAddon } from "@xterm/addon-fit"; import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; import { type ResolvedKeybindingsConfig, type ScopedThreadRef, type TerminalAttachStreamEvent, type TerminalSessionSnapshot, type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { Terminal, type ITheme } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { openInPreferredEditor } from "../editorPreferences"; import { collectWrappedTerminalLinkLine, extractTerminalLinks, isTerminalLinkActivation, resolvePathLinkTarget, resolveWrappedTerminalLinkRange, wrappedTerminalLinkRangeIntersectsBufferLine, } from "../terminal-links"; import { isDiffToggleShortcut, isTerminalClearShortcut, isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, terminalDeleteShortcutData, terminalNavigationShortcutData, } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "../types"; import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { attachTerminalSession } from "../terminalSessionState"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; 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 writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnapshot): void { terminal.write("\u001bc"); if (snapshot.history.length > 0) { terminal.write(snapshot.history); } } function fitTerminalSafely(fitAddon: FitAddon): boolean { try { fitAddon.fit(); return true; } catch { return false; } } function runtimeEnvSignature(runtimeEnv: Record | undefined): string { if (!runtimeEnv) return ""; return JSON.stringify( Object.entries(runtimeEnv) .filter(([key, value]) => key.length > 0 && typeof value === "string") .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)), ); } function normalizeComputedColor(value: string | null | undefined, fallback: string): string { const normalizedValue = value?.trim().toLowerCase(); if ( !normalizedValue || normalizedValue === "transparent" || normalizedValue === "rgba(0, 0, 0, 0)" || normalizedValue === "rgba(0 0 0 / 0)" ) { return fallback; } return value ?? fallback; } function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { const isDark = document.documentElement.classList.contains("dark"); const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; const drawerSurface = mountElement?.closest(".thread-terminal-drawer") ?? document.querySelector(".thread-terminal-drawer") ?? document.body; const drawerStyles = getComputedStyle(drawerSurface); const bodyStyles = getComputedStyle(document.body); const background = normalizeComputedColor( drawerStyles.backgroundColor, normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), ); const foreground = normalizeComputedColor( drawerStyles.color, normalizeComputedColor(bodyStyles.color, fallbackForeground), ); 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)", }; } function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { return null; } const range = selection.getRangeAt(0); const commonAncestor = range.commonAncestorContainer; const selectionRoot = commonAncestor instanceof Element ? commonAncestor : commonAncestor.parentElement; if (!(selectionRoot instanceof Element) || !mountElement.contains(selectionRoot)) { return null; } const rects = Array.from(range.getClientRects()).filter( (rect) => rect.width > 0 || rect.height > 0, ); if (rects.length > 0) { return rects[rects.length - 1] ?? null; } const boundingRect = range.getBoundingClientRect(); return boundingRect.width > 0 || boundingRect.height > 0 ? boundingRect : null; } export function resolveTerminalSelectionActionPosition(options: { bounds: { left: number; top: number; width: number; height: number }; selectionRect: { right: number; bottom: number } | null; pointer: { x: number; y: number } | null; viewport?: { width: number; height: number } | null; }): { x: number; y: number } { const { bounds, selectionRect, pointer, viewport } = options; const viewportWidth = viewport?.width ?? (typeof window === "undefined" ? bounds.left + bounds.width + 8 : window.innerWidth); const viewportHeight = viewport?.height ?? (typeof window === "undefined" ? bounds.top + bounds.height + 8 : window.innerHeight); const drawerLeft = Math.round(bounds.left); const drawerTop = Math.round(bounds.top); const drawerRight = Math.round(bounds.left + bounds.width); const drawerBottom = Math.round(bounds.top + bounds.height); const preferredX = selectionRect !== null ? Math.round(selectionRect.right) : pointer === null ? Math.round(bounds.left + bounds.width - 140) : Math.max(drawerLeft, Math.min(Math.round(pointer.x), drawerRight)); const preferredY = selectionRect !== null ? Math.round(selectionRect.bottom + 4) : pointer === null ? Math.round(bounds.top + 12) : Math.max(drawerTop, Math.min(Math.round(pointer.y), drawerBottom)); return { x: Math.max(8, Math.min(preferredX, Math.max(viewportWidth - 8, 8))), y: Math.max(8, Math.min(preferredY, Math.max(viewportHeight - 8, 8))), }; } export function terminalSelectionActionDelayForClickCount(clickCount: number): number { return clickCount >= 2 ? MULTI_CLICK_SELECTION_ACTION_DELAY_MS : 0; } export function shouldHandleTerminalSelectionMouseUp( selectionGestureActive: boolean, button: number, ): boolean { return selectionGestureActive && button === 0; } interface TerminalViewportProps { threadRef: ScopedThreadRef; threadId: ThreadId; terminalId: string; terminalLabel: string; cwd: string; worktreePath?: string | null; runtimeEnv?: Record; onSessionExited: () => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; resizeEpoch: number; drawerHeight: number; keybindings: ResolvedKeybindingsConfig; } interface TerminalLaunchLocation { readonly cwd: string; readonly worktreePath?: string | null; readonly runtimeEnv?: Record; } export function TerminalViewport({ threadRef, threadId, terminalId, terminalLabel, cwd, worktreePath, runtimeEnv, onSessionExited, onAddTerminalContext, focusRequestId, autoFocus, resizeEpoch, drawerHeight, keybindings, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); const environmentId = threadRef.environmentId; const hasHandledExitRef = useRef(false); const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); const selectionActionRequestIdRef = useRef(0); const selectionActionOpenRef = useRef(false); const selectionActionTimerRef = useRef(null); const keybindingsRef = useRef(keybindings); const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]); const handleSessionExited = useEffectEvent(() => { onSessionExited(); }); const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); useEffect(() => { keybindingsRef.current = keybindings; }, [keybindings]); useEffect(() => { const mount = containerRef.current; if (!mount) return; let disposed = false; const api = readEnvironmentApi(environmentId); const localApi = readLocalApi(); if (!api) return; 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(mount), }); terminal.loadAddon(fitAddon); terminal.open(mount); fitTerminalSafely(fitAddon); terminalRef.current = terminal; fitAddonRef.current = fitAddon; const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; if (selectionActionTimerRef.current !== null) { window.clearTimeout(selectionActionTimerRef.current); selectionActionTimerRef.current = null; } }; const readSelectionAction = (): { position: { x: number; y: number }; selection: TerminalContextSelection; } | null => { const activeTerminal = terminalRef.current; const mountElement = containerRef.current; if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { return null; } const selectionText = activeTerminal.getSelection(); const selectionPosition = activeTerminal.getSelectionPosition(); const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); if (!selectionPosition || normalizedText.length === 0) { return null; } const lineStart = selectionPosition.start.y + 1; const lineCount = normalizedText.split("\n").length; const lineEnd = Math.max(lineStart, lineStart + lineCount - 1); const bounds = mountElement.getBoundingClientRect(); const selectionRect = getTerminalSelectionRect(mountElement); const position = resolveTerminalSelectionActionPosition({ bounds, selectionRect: selectionRect === null ? null : { right: selectionRect.right, bottom: selectionRect.bottom }, pointer: selectionPointerRef.current, }); return { position, selection: { terminalId, terminalLabel: readTerminalLabel(), lineStart, lineEnd, text: normalizedText, }, }; }; const showSelectionAction = async () => { if (!localApi) { clearSelectionAction(); return; } if (selectionActionOpenRef.current) { return; } const nextAction = readSelectionAction(); if (!nextAction) { clearSelectionAction(); return; } const requestId = ++selectionActionRequestIdRef.current; selectionActionOpenRef.current = true; try { const clicked = await localApi.contextMenu.show( [{ id: "add-to-chat", label: "Add to chat" }], nextAction.position, ); if (requestId !== selectionActionRequestIdRef.current || clicked !== "add-to-chat") { return; } handleAddTerminalContext(nextAction.selection); terminalRef.current?.clearSelection(); terminalRef.current?.focus(); } finally { selectionActionOpenRef.current = false; } }; 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 currentKeybindings = keybindingsRef.current; const options = { context: { terminalFocus: true, terminalOpen: true } }; if ( isTerminalToggleShortcut(event, currentKeybindings, options) || isTerminalSplitShortcut(event, currentKeybindings, options) || isTerminalNewShortcut(event, currentKeybindings, options) || isTerminalCloseShortcut(event, currentKeybindings, options) || isDiffToggleShortcut(event, currentKeybindings, options) ) { return false; } const navigationData = terminalNavigationShortcutData(event); if (navigationData !== null) { event.preventDefault(); event.stopPropagation(); void sendTerminalInput(navigationData, "Failed to move cursor"); return false; } const deleteData = terminalDeleteShortcutData(event); if (deleteData !== null) { event.preventDefault(); event.stopPropagation(); void sendTerminalInput(deleteData, "Failed to delete terminal input"); 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 wrappedLine = collectWrappedTerminalLinkLine(bufferLineNumber, (bufferLineIndex) => activeTerminal.buffer.active.getLine(bufferLineIndex), ); if (!wrappedLine) { callback(undefined); return; } const links = extractTerminalLinks(wrappedLine.text) .map((match) => ({ match, range: resolveWrappedTerminalLinkRange(wrappedLine, match), })) .filter(({ range }) => wrappedTerminalLinkRangeIntersectsBufferLine(range, bufferLineNumber), ); if (links.length === 0) { callback(undefined); return; } callback( links.map(({ match, range }) => ({ text: match.text, range, activate: (event: MouseEvent) => { if (!isTerminalLinkActivation(event)) return; const latestTerminal = terminalRef.current; if (!latestTerminal) return; if (!localApi) { writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); return; } if (match.kind === "url") { void localApi.shell.openExternal(match.text).catch((error: unknown) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open link", ); }); return; } const target = resolvePathLinkTarget(match.text, cwd); void openInPreferredEditor(localApi, target).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 selectionDisposable = terminal.onSelectionChange(() => { if (terminalRef.current?.hasSelection()) { return; } clearSelectionAction(); }); const handleMouseUp = (event: MouseEvent) => { const shouldHandle = shouldHandleTerminalSelectionMouseUp( selectionGestureActiveRef.current, event.button, ); selectionGestureActiveRef.current = false; if (!shouldHandle) { return; } selectionPointerRef.current = { x: event.clientX, y: event.clientY }; const delay = terminalSelectionActionDelayForClickCount(event.detail); selectionActionTimerRef.current = window.setTimeout(() => { selectionActionTimerRef.current = null; window.requestAnimationFrame(() => { void showSelectionAction(); }); }, delay); }; const handlePointerDown = (event: PointerEvent) => { clearSelectionAction(); selectionGestureActiveRef.current = event.button === 0; }; window.addEventListener("mouseup", handleMouseUp); mount.addEventListener("pointerdown", handlePointerDown); const themeObserver = new MutationObserver(() => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; activeTerminal.options.theme = terminalThemeFromApp(containerRef.current); activeTerminal.refresh(0, activeTerminal.rows - 1); }); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"], }); const applyAttachEvent = (event: TerminalAttachStreamEvent) => { const activeTerminal = terminalRef.current; if (!activeTerminal) { return; } if (event.type === "activity") { return; } if (event.type === "snapshot") { hasHandledExitRef.current = false; clearSelectionAction(); writeTerminalSnapshot(activeTerminal, event.snapshot); return; } if (event.type === "output") { activeTerminal.write(event.data); clearSelectionAction(); return; } if (event.type === "restarted") { hasHandledExitRef.current = false; clearSelectionAction(); writeTerminalSnapshot(activeTerminal, event.snapshot); return; } if (event.type === "cleared") { clearSelectionAction(); activeTerminal.clear(); activeTerminal.write("\u001bc"); return; } if (event.type === "error") { writeSystemMessage(activeTerminal, event.message); return; } if (event.type === "closed") { writeSystemMessage(activeTerminal, "Terminal closed"); } else { 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; } handleSessionExited(); }, 0); }; let unsubscribeAttach: (() => void) | null = null; const attachTerminal = () => { const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; if (!activeTerminal || !activeFitAddon) return; fitTerminalSafely(activeFitAddon); unsubscribeAttach = attachTerminalSession({ environmentId, client: api, terminal: { threadId, terminalId, cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), cols: activeTerminal.cols, rows: activeTerminal.rows, ...(runtimeEnv ? { env: runtimeEnv } : {}), }, onEvent: (event) => { if (disposed) return; applyAttachEvent(event); }, onSnapshot: () => { if (disposed) return; if (autoFocus) { window.requestAnimationFrame(() => { activeTerminal.focus(); }); } }, }); }; 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; fitTerminalSafely(activeFitAddon); if (wasAtBottom) { activeTerminal.scrollToBottom(); } void api.terminal .resize({ threadId, terminalId, cols: activeTerminal.cols, rows: activeTerminal.rows, }) .catch(() => undefined); }, 30); attachTerminal(); return () => { disposed = true; unsubscribeAttach?.(); unsubscribeAttach = null; window.clearTimeout(fitTimer); inputDisposable.dispose(); selectionDisposable.dispose(); terminalLinksDisposable.dispose(); if (selectionActionTimerRef.current !== null) { window.clearTimeout(selectionActionTimerRef.current); } window.removeEventListener("mouseup", handleMouseUp); mount.removeEventListener("pointerdown", handlePointerDown); 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, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); 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 = readEnvironmentApi(environmentId); 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(() => { fitTerminalSafely(fitAddon); if (wasAtBottom) { terminal.scrollToBottom(); } void api.terminal .resize({ threadId, terminalId, cols: terminal.cols, rows: terminal.rows, }) .catch(() => undefined); }); return () => { window.cancelAnimationFrame(frame); }; }, [drawerHeight, environmentId, resizeEpoch, terminalId, threadId]); return (
); } interface ThreadTerminalDrawerProps { threadRef: ScopedThreadRef; threadId: ThreadId; cwd: string; worktreePath?: string | null; runtimeEnv?: Record; visible?: boolean; 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; onAddTerminalContext: (selection: TerminalContextSelection) => void; keybindings: ResolvedKeybindingsConfig; /** Prefer server-provided tab titles when present (e.g. active subprocess name). */ terminalLabelsById?: ReadonlyMap; /** Prefer per-session launch locations when the server already knows a terminal. */ terminalLaunchLocationsById?: ReadonlyMap; } interface TerminalActionButtonProps { label: string; className: string; onClick: () => void; children: ReactNode; } function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { return ( } > {children} {label} ); } export default function ThreadTerminalDrawer({ threadRef, threadId, cwd, worktreePath, runtimeEnv, visible = true, height, terminalIds, activeTerminalId, terminalGroups, activeTerminalGroupId, focusRequestId, onSplitTerminal, onNewTerminal, splitShortcutLabel, newShortcutLabel, closeShortcutLabel, onActiveTerminalChange, onCloseTerminal, onHeightChange, onAddTerminalContext, keybindings, terminalLabelsById, terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const [resizeEpoch, setResizeEpoch] = useState(0); const drawerHeightRef = useRef(drawerHeight); const lastSyncedHeightRef = useRef(clampDrawerHeight(height)); const onHeightChangeRef = useRef(onHeightChange); const resizeStateRef = useRef<{ pointerId: number; startY: number; startHeight: number; } | null>(null); const didResizeDuringDragRef = useRef(false); const normalizedTerminalIds = useMemo(() => { const normalizedIds: string[] = []; const seen = new Set(); for (const id of terminalIds) { const trimmedId = id.trim(); if (trimmedId.length === 0 || seen.has(trimmedId)) continue; seen.add(trimmedId); normalizedIds.push(trimmedId); } return normalizedIds; }, [terminalIds]); const resolvedActiveTerminalId = normalizedTerminalIds.length === 0 ? "" : normalizedTerminalIds.includes(activeTerminalId) ? activeTerminalId : (normalizedTerminalIds[0] ?? ""); const resolvedTerminalGroups = useMemo(() => { if (normalizedTerminalIds.length === 0) { return []; } const validTerminalIdSet = new Set(normalizedTerminalIds); const assignedTerminalIds = new Set(); const usedGroupIds = new Set(); const nextGroups: ThreadTerminalGroup[] = []; const assignUniqueGroupId = (groupId: string): string => { if (!usedGroupIds.has(groupId)) { usedGroupIds.add(groupId); return groupId; } let suffix = 2; while (usedGroupIds.has(`${groupId}-${suffix}`)) { suffix += 1; } const uniqueGroupId = `${groupId}-${suffix}`; usedGroupIds.add(uniqueGroupId); return uniqueGroupId; }; for (const terminalGroup of terminalGroups) { const nextTerminalIds: string[] = []; const seenGroupTerminalIds = new Set(); for (const id of terminalGroup.terminalIds) { const terminalId = id.trim(); if (terminalId.length === 0) continue; if (seenGroupTerminalIds.has(terminalId)) continue; seenGroupTerminalIds.add(terminalId); if (!validTerminalIdSet.has(terminalId)) continue; if (assignedTerminalIds.has(terminalId)) continue; nextTerminalIds.push(terminalId); } if (nextTerminalIds.length === 0) continue; for (const terminalId of nextTerminalIds) { assignedTerminalIds.add(terminalId); } const baseGroupId = terminalGroup.id.trim().length > 0 ? terminalGroup.id.trim() : `group-${nextTerminalIds[0] ?? normalizedTerminalIds[0] ?? ""}`; nextGroups.push({ id: assignUniqueGroupId(baseGroupId), terminalIds: nextTerminalIds, }); } for (const terminalId of normalizedTerminalIds) { if (assignedTerminalIds.has(terminalId)) continue; nextGroups.push({ id: assignUniqueGroupId(`group-${terminalId}`), terminalIds: [terminalId], }); } const terminalOrderIndex = new Map( normalizedTerminalIds.map((id, index) => [id, index] as const), ); nextGroups.sort((left, right) => { const rank = (ids: readonly string[]) => Math.min(...ids.map((id) => terminalOrderIndex.get(id) ?? Number.POSITIVE_INFINITY)); return rank(left.terminalIds) - rank(right.terminalIds); }); return nextGroups; }, [normalizedTerminalIds, terminalGroups]); const resolvedActiveGroupIndex = useMemo(() => { const indexById = resolvedTerminalGroups.findIndex( (terminalGroup) => terminalGroup.id === activeTerminalGroupId, ); if (indexById >= 0) return indexById; const indexByTerminal = resolvedTerminalGroups.findIndex((terminalGroup) => terminalGroup.terminalIds.includes(resolvedActiveTerminalId), ); return indexByTerminal >= 0 ? indexByTerminal : 0; }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]); const visibleTerminalIds = resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); const hasTerminalSidebar = normalizedTerminalIds.length > 1; const isSplitView = visibleTerminalIds.length > 1; const showGroupHeaders = resolvedTerminalGroups.length > 1 || resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; const terminalLabelById = useMemo(() => { const next = new Map(); for (const terminalId of normalizedTerminalIds) { next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId)); } return next; }, [normalizedTerminalIds, terminalLabelsById]); const resolveTerminalLaunchLocation = useCallback( (terminalId: string): TerminalLaunchLocation => { return ( terminalLaunchLocationsById?.get(terminalId) ?? { cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), ...(runtimeEnv ? { runtimeEnv } : {}), } ); }, [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], ); const splitTerminalActionLabel = hasReachedSplitLimit ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` : splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"; const newTerminalActionLabel = newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"; const closeTerminalActionLabel = closeShortcutLabel ? `Close Terminal (${closeShortcutLabel})` : "Close Terminal"; const onSplitTerminalAction = useCallback(() => { if (hasReachedSplitLimit) return; onSplitTerminal(); }, [hasReachedSplitLimit, onSplitTerminal]); const onNewTerminalAction = useCallback(() => { onNewTerminal(); }, [onNewTerminal]); useEffect(() => { onHeightChangeRef.current = onHeightChange; }, [onHeightChange]); useEffect(() => { drawerHeightRef.current = drawerHeight; }, [drawerHeight]); const syncHeight = useCallback((nextHeight: number) => { const clampedHeight = clampDrawerHeight(nextHeight); if (lastSyncedHeightRef.current === clampedHeight) return; lastSyncedHeightRef.current = clampedHeight; onHeightChangeRef.current(clampedHeight); }, []); useEffect(() => { const clampedHeight = clampDrawerHeight(height); setDrawerHeight(clampedHeight); drawerHeightRef.current = clampedHeight; lastSyncedHeightRef.current = clampedHeight; }, [height, threadId]); const handleResizePointerDown = useCallback((event: ReactPointerEvent) => { if (event.button !== 0) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); didResizeDuringDragRef.current = false; resizeStateRef.current = { pointerId: event.pointerId, startY: event.clientY, startHeight: drawerHeightRef.current, }; }, []); const handleResizePointerMove = useCallback((event: ReactPointerEvent) => { const resizeState = resizeStateRef.current; if (!resizeState || resizeState.pointerId !== event.pointerId) return; event.preventDefault(); const clampedHeight = clampDrawerHeight( resizeState.startHeight + (resizeState.startY - event.clientY), ); if (clampedHeight === drawerHeightRef.current) { return; } didResizeDuringDragRef.current = true; drawerHeightRef.current = clampedHeight; setDrawerHeight(clampedHeight); }, []); const handleResizePointerEnd = useCallback( (event: ReactPointerEvent) => { const resizeState = resizeStateRef.current; if (!resizeState || resizeState.pointerId !== event.pointerId) return; resizeStateRef.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } if (!didResizeDuringDragRef.current) { return; } syncHeight(drawerHeightRef.current); setResizeEpoch((value) => value + 1); }, [syncHeight], ); useEffect(() => { if (!visible) { return; } const onWindowResize = () => { const clampedHeight = clampDrawerHeight(drawerHeightRef.current); const changed = clampedHeight !== drawerHeightRef.current; if (changed) { setDrawerHeight(clampedHeight); drawerHeightRef.current = clampedHeight; } if (!resizeStateRef.current) { syncHeight(clampedHeight); } setResizeEpoch((value) => value + 1); }; window.addEventListener("resize", onWindowResize); return () => { window.removeEventListener("resize", onWindowResize); }; }, [syncHeight, visible]); useEffect(() => { if (!visible) { return; } setResizeEpoch((value) => value + 1); }, [visible]); useEffect(() => { return () => { syncHeight(drawerHeightRef.current); }; }, [syncHeight]); if (normalizedTerminalIds.length === 0) { return ( ); } const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); return ( ); }