import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useFocusEffect } from "@react-navigation/native"; import { ActivityIndicator, Pressable, ScrollView, Text, View, } from "react-native"; import { Plus, X } from "lucide-react-native"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop, } from "react-native-svg"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import type { ListTerminalsResponse } from "@server/shared/messages"; import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input"; import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { hasPendingTerminalModifiers, normalizeTerminalTransportKey, resolvePendingModifierDataInput, } from "@/utils/terminal-keys"; import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { confirmDialog } from "@/utils/confirm-dialog"; import { TerminalOutputPump, type TerminalOutputChunk, } from "@/terminal/runtime/terminal-output-pump"; import { TerminalOutputDeliveryQueue } from "@/terminal/runtime/terminal-output-delivery-queue"; import { TerminalStreamController, type TerminalStreamControllerStatus, } from "@/terminal/runtime/terminal-stream-controller"; import { summarizeTerminalText, terminalDebugLog, } from "@/terminal/runtime/terminal-debug"; import { usePanelStore } from "@/stores/panel-store"; import TerminalEmulator from "./terminal-emulator"; interface TerminalPaneProps { serverId: string; cwd: string; selectedTerminalId?: string | null; onSelectedTerminalIdChange?: (terminalId: string | null) => void; hideHeader?: boolean; manageTerminalDirectorySubscription?: boolean; } const MAX_OUTPUT_CHARS = 200_000; const TERMINAL_TAB_MAX_WIDTH = 220; const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320]; const MODIFIER_LABELS = { ctrl: "Ctrl", shift: "Shift", alt: "Alt", } as const; const KEY_BUTTONS: Array<{ id: string; label: string; key: string }> = [ { id: "esc", label: "Esc", key: "Escape" }, { id: "tab", label: "Tab", key: "Tab" }, { id: "up", label: "↑", key: "ArrowUp" }, { id: "down", label: "↓", key: "ArrowDown" }, { id: "left", label: "←", key: "ArrowLeft" }, { id: "right", label: "→", key: "ArrowRight" }, { id: "enter", label: "Enter", key: "Enter" }, { id: "backspace", label: "⌫", key: "Backspace" }, { id: "c", label: "C", key: "c" }, ]; type ModifierState = { ctrl: boolean; shift: boolean; alt: boolean; }; type TerminalOutputChunkState = { sequence: number; text: string; }; type PendingTerminalInput = | { type: "data"; data: string; } | { type: "key"; input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta?: boolean; }; }; type ListTerminalsPayload = ListTerminalsResponse["payload"]; const EMPTY_MODIFIERS: ModifierState = { ctrl: false, shift: false, alt: false, }; function terminalScopeKey(input: { serverId: string; cwd: string }): string { return `${input.serverId}:${input.cwd}`; } function TerminalCloseGradient({ color, gradientId }: { color: string; gradientId: string }) { return ( ); } export function TerminalPane({ serverId, cwd, selectedTerminalId: selectedTerminalIdProp, onSelectedTerminalIdChange, hideHeader = false, manageTerminalDirectorySubscription = true, }: TerminalPaneProps) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); const openAgentList = usePanelStore((state) => state.openAgentList); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const swipeGesturesEnabled = isMobile && mobileView === "agent"; const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({ mode: "padding", enabled: isMobile, }); const queryClient = useQueryClient(); const { client, isConnected } = useHostRuntimeSession(serverId); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]); const selectedTerminalByScopeRef = useRef>(new Map()); const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null); const streamControllerRef = useRef(null); const outputPumpRef = useRef(null); const outputDeliveryQueueRef = useRef(null); const isSelectedTerminalControlled = typeof selectedTerminalIdProp !== "undefined"; const [uncontrolledSelectedTerminalId, setUncontrolledSelectedTerminalId] = useState(null); const selectedTerminalId = isSelectedTerminalControlled ? selectedTerminalIdProp ?? null : uncontrolledSelectedTerminalId; const [selectedOutputChunk, setSelectedOutputChunk] = useState({ sequence: 0, text: "", }); const [selectedOutputSnapshot, setSelectedOutputSnapshot] = useState(""); const [activeStream, setActiveStream] = useState<{ terminalId: string; streamId: number; } | null>(null); const [isAttaching, setIsAttaching] = useState(false); const [streamError, setStreamError] = useState(null); const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS); const [focusRequestToken, setFocusRequestToken] = useState(0); const [resizeRequestToken, setResizeRequestToken] = useState(0); const [hoveredTerminalId, setHoveredTerminalId] = useState(null); const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState( null ); const hoverOutTimeoutRef = useRef | null>(null); const selectedTerminalIdRef = useRef(selectedTerminalId); const pendingTerminalInputRef = useRef([]); const keyboardRefitTimeoutsRef = useRef>>([]); const updateSelectedTerminalId = useCallback( ( next: | string | null | ((current: string | null) => string | null) ) => { const current = selectedTerminalIdRef.current; const resolved = typeof next === "function" ? (next as (value: string | null) => string | null)(current) : next; selectedTerminalIdRef.current = resolved; if (isSelectedTerminalControlled) { onSelectedTerminalIdChange?.(resolved); return; } setUncontrolledSelectedTerminalId(resolved); }, [isSelectedTerminalControlled, onSelectedTerminalIdChange] ); useEffect(() => { selectedTerminalIdRef.current = selectedTerminalId; }, [selectedTerminalId]); useEffect(() => { const outputDeliveryQueue = new TerminalOutputDeliveryQueue({ onDeliver: (chunk) => { setSelectedOutputChunk(chunk); }, }); outputDeliveryQueueRef.current = outputDeliveryQueue; return () => { if (outputDeliveryQueueRef.current === outputDeliveryQueue) { outputDeliveryQueueRef.current = null; } outputDeliveryQueue.reset(); }; }, []); useEffect(() => { const outputPump = new TerminalOutputPump({ maxOutputChars: MAX_OUTPUT_CHARS, onSelectedOutputChunk: (chunk: TerminalOutputChunk) => { outputDeliveryQueueRef.current?.enqueue(chunk); }, }); outputPumpRef.current = outputPump; return () => { if (outputPumpRef.current === outputPump) { outputPumpRef.current = null; } outputPump.dispose(); }; }, []); const clearHoverOutTimeout = useCallback(() => { if (!hoverOutTimeoutRef.current) { return; } clearTimeout(hoverOutTimeoutRef.current); hoverOutTimeoutRef.current = null; }, []); const handleTerminalTabHoverIn = useCallback( (terminalId: string) => { clearHoverOutTimeout(); setHoveredTerminalId(terminalId); }, [clearHoverOutTimeout] ); const handleTerminalTabHoverOut = useCallback( (terminalId: string) => { clearHoverOutTimeout(); hoverOutTimeoutRef.current = setTimeout(() => { setHoveredTerminalId((current) => (current === terminalId ? null : current)); setHoveredCloseTerminalId((current) => current === terminalId ? null : current ); }, 50); }, [clearHoverOutTimeout] ); const handleTerminalCloseHoverIn = useCallback( (terminalId: string) => { clearHoverOutTimeout(); setHoveredTerminalId(terminalId); setHoveredCloseTerminalId(terminalId); }, [clearHoverOutTimeout] ); const handleTerminalCloseHoverOut = useCallback((terminalId: string) => { setHoveredCloseTerminalId((current) => (current === terminalId ? null : current)); }, []); useEffect(() => { return () => clearHoverOutTimeout(); }, [clearHoverOutTimeout]); const requestTerminalFocus = useCallback(() => { setFocusRequestToken((current) => current + 1); }, []); const requestTerminalReflow = useCallback(() => { setResizeRequestToken((current) => current + 1); }, []); const clearKeyboardRefitTimeouts = useCallback(() => { if (keyboardRefitTimeoutsRef.current.length === 0) { return; } for (const handle of keyboardRefitTimeoutsRef.current) { clearTimeout(handle); } keyboardRefitTimeoutsRef.current = []; }, []); const pulseKeyboardRefits = useCallback(() => { clearKeyboardRefitTimeouts(); requestTerminalReflow(); keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) => setTimeout(() => { requestTerminalReflow(); }, delayMs) ); }, [clearKeyboardRefitTimeouts, requestTerminalReflow]); useEffect(() => { return () => clearKeyboardRefitTimeouts(); }, [clearKeyboardRefitTimeouts]); useAnimatedReaction( () => keyboardShift.value > 0, (next, prev) => { if (next === prev) { return; } runOnJS(pulseKeyboardRefits)(); }, [pulseKeyboardRefits] ); useFocusEffect( useCallback(() => { if (!selectedTerminalId) { return; } // Navigation transitions can temporarily report stale dimensions. // Pulse forced refits so xterm fills the pane when returning to an agent. const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) => setTimeout(() => { requestTerminalReflow(); }, delayMs) ); return () => { for (const handle of timeoutHandles) { clearTimeout(handle); } }; }, [requestTerminalReflow, selectedTerminalId]) ); const terminalsQuery = useQuery({ queryKey: terminalsQueryKey, enabled: Boolean(client && isConnected && cwd.startsWith("/")), queryFn: async () => { if (!client) { throw new Error("Host is not connected"); } return await client.listTerminals(cwd); }, staleTime: 5_000, }); const terminals = terminalsQuery.data?.terminals ?? []; useEffect(() => { if (!client || !isConnected) { return; } return client.on("terminal_stream_exit", (message) => { if (message.type !== "terminal_stream_exit") { return; } const exitedTerminalId = message.payload.terminalId; if (!exitedTerminalId) { return; } streamControllerRef.current?.handleStreamExit({ terminalId: exitedTerminalId, streamId: message.payload.streamId, }); setModifiers({ ...EMPTY_MODIFIERS }); }); }, [client, isConnected]); useEffect(() => { if ( !manageTerminalDirectorySubscription || !client || !isConnected || !cwd.startsWith("/") ) { return; } const unsubscribe = client.on("terminals_changed", (message) => { if (message.type !== "terminals_changed") { return; } if (message.payload.cwd !== cwd) { return; } queryClient.setQueryData(terminalsQueryKey, (current) => ({ cwd: message.payload.cwd, terminals: message.payload.terminals, requestId: current?.requestId ?? `terminals-changed-${Date.now()}`, })); }); client.subscribeTerminals({ cwd }); return () => { unsubscribe(); client.unsubscribeTerminals({ cwd }); }; }, [ client, cwd, isConnected, manageTerminalDirectorySubscription, queryClient, terminalsQueryKey, ]); const createTerminalMutation = useMutation({ mutationFn: async () => { if (!client) { throw new Error("Host is not connected"); } return await client.createTerminal(cwd); }, onSuccess: (payload) => { const createdTerminal = payload.terminal; if (createdTerminal) { queryClient.setQueryData(terminalsQueryKey, (current) => { const nextTerminals = upsertTerminalListEntry({ terminals: current?.terminals ?? [], terminal: createdTerminal, }); return { cwd: current?.cwd ?? cwd, terminals: nextTerminals, requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`, }; }); if (!isSelectedTerminalControlled) { selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id); } updateSelectedTerminalId(createdTerminal.id); requestTerminalFocus(); } void queryClient.invalidateQueries({ queryKey: terminalsQueryKey, }); }, }); const killTerminalMutation = useMutation({ mutationFn: async (terminalId: string) => { if (!client) { throw new Error("Host is not connected"); } const payload = await client.killTerminal(terminalId); if (!payload.success) { throw new Error("Unable to close terminal"); } return payload; }, onSuccess: (_, terminalId) => { setHoveredTerminalId((current) => (current === terminalId ? null : current)); outputPumpRef.current?.clearTerminal({ terminalId }); if (selectedTerminalIdRef.current === terminalId) { updateSelectedTerminalId((current) => current === terminalId ? null : current ); setModifiers({ ...EMPTY_MODIFIERS }); } void queryClient.invalidateQueries({ queryKey: terminalsQueryKey, }); void queryClient.refetchQueries({ queryKey: terminalsQueryKey, type: "active", }); }, }); useEffect(() => { if (!isSelectedTerminalControlled) { updateSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null); } lastReportedSizeRef.current = null; }, [isSelectedTerminalControlled, scopeKey, updateSelectedTerminalId]); useEffect(() => { if (isSelectedTerminalControlled) { return; } if (selectedTerminalId) { selectedTerminalByScopeRef.current.set(scopeKey, selectedTerminalId); } }, [isSelectedTerminalControlled, scopeKey, selectedTerminalId]); useEffect(() => { if (terminals.length === 0) { updateSelectedTerminalId(null); return; } const has = (id: string | null | undefined) => Boolean(id && terminals.some((terminal) => terminal.id === id)); if (has(selectedTerminalId)) { return; } const stored = isSelectedTerminalControlled ? null : selectedTerminalByScopeRef.current.get(scopeKey); if (has(stored)) { updateSelectedTerminalId(stored!); return; } const fallback = terminals[0]?.id ?? null; if (fallback) { if (!isSelectedTerminalControlled) { selectedTerminalByScopeRef.current.set(scopeKey, fallback); } updateSelectedTerminalId(fallback); } }, [ isSelectedTerminalControlled, scopeKey, terminals, selectedTerminalId, updateSelectedTerminalId, ]); useEffect(() => { const terminalIds = terminals.map((terminal) => terminal.id); outputPumpRef.current?.prune({ terminalIds }); streamControllerRef.current?.pruneResumeOffsets({ terminalIds }); }, [terminals]); const handleStreamControllerStatus = useCallback( (status: TerminalStreamControllerStatus) => { setIsAttaching(status.isAttaching); setStreamError(status.error); if (status.terminalId && typeof status.streamId === "number") { setActiveStream({ terminalId: status.terminalId, streamId: status.streamId, }); return; } setActiveStream(null); }, [] ); useEffect(() => { streamControllerRef.current?.dispose(); streamControllerRef.current = null; setActiveStream(null); setIsAttaching(false); setStreamError(null); if (!client || !isConnected) { return; } const outputPump = outputPumpRef.current; if (!outputPump) { return; } const controller = new TerminalStreamController({ client, getPreferredSize: () => lastReportedSizeRef.current, onChunk: ({ terminalId, text }) => { outputPump.append({ terminalId, text }); }, onReset: ({ terminalId }) => { outputPump.clearTerminal({ terminalId }); if (selectedTerminalIdRef.current === terminalId) { setSelectedOutputSnapshot(""); } }, onStatusChange: handleStreamControllerStatus, }); streamControllerRef.current = controller; controller.setTerminal({ terminalId: selectedTerminalIdRef.current }); return () => { controller.dispose(); if (streamControllerRef.current === controller) { streamControllerRef.current = null; } }; }, [client, handleStreamControllerStatus, isConnected]); useEffect(() => { outputDeliveryQueueRef.current?.reset(); pendingTerminalInputRef.current = []; setSelectedOutputChunk({ sequence: 0, text: "" }); outputPumpRef.current?.setSelectedTerminal({ terminalId: selectedTerminalId, }); streamControllerRef.current?.setTerminal({ terminalId: selectedTerminalId, }); setSelectedOutputSnapshot( outputPumpRef.current?.readSnapshot({ terminalId: selectedTerminalId, }) ?? "" ); }, [selectedTerminalId]); const activeStreamId = activeStream && activeStream.terminalId === selectedTerminalId ? activeStream.streamId : null; const getCurrentActiveStreamId = useCallback(() => { return streamControllerRef.current?.getActiveStreamId() ?? null; }, []); const selectedTerminal = useMemo( () => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null, [terminals, selectedTerminalId] ); const handleCreateTerminal = useCallback(() => { createTerminalMutation.mutate(); }, [createTerminalMutation]); const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => { const queue = pendingTerminalInputRef.current; queue.push(entry); if (queue.length > 512) { queue.splice(0, queue.length - 512); } }, []); const dispatchTerminalInputEntry = useCallback( (entry: PendingTerminalInput): boolean => { if (!client) { return false; } const terminalId = selectedTerminalIdRef.current; if (!terminalId) { return false; } if (entry.type === "data") { client.sendTerminalInput(terminalId, { type: "input", data: entry.data, }); return true; } const encoded = encodeTerminalKeyInput(entry.input); if (encoded.length === 0) { return true; } client.sendTerminalInput(terminalId, { type: "input", data: encoded, }); return true; }, [client] ); const flushPendingTerminalInput = useCallback(() => { const queue = pendingTerminalInputRef.current; if (queue.length === 0) { return; } let sentCount = 0; while (sentCount < queue.length) { const entry = queue[sentCount]; if (!entry) { break; } if (!dispatchTerminalInputEntry(entry)) { break; } sentCount += 1; } if (sentCount > 0) { queue.splice(0, sentCount); } }, [dispatchTerminalInputEntry]); useEffect(() => { flushPendingTerminalInput(); }, [activeStreamId, flushPendingTerminalInput]); const handleCloseTerminal = useCallback( async (terminalId: string) => { if ( killTerminalMutation.isPending && killTerminalMutation.variables === terminalId ) { return; } const confirmed = await confirmDialog({ title: "Close terminal?", message: "Any running process in this terminal will be stopped immediately.", confirmLabel: "Close", cancelLabel: "Cancel", destructive: true, }); if (!confirmed) { return; } killTerminalMutation.mutate(terminalId); }, [killTerminalMutation] ); const clearPendingModifiers = useCallback(() => { setModifiers({ ...EMPTY_MODIFIERS }); }, []); const sendTerminalKey = useCallback( ( input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta?: boolean; } ): boolean => { if (!client || !selectedTerminalIdRef.current) { enqueuePendingTerminalInput({ type: "key", input: { key: normalizeTerminalTransportKey(input.key), ctrl: input.ctrl, shift: input.shift, alt: input.alt, meta: input.meta, }, }); return true; } const normalizedKey = normalizeTerminalTransportKey(input.key); const pendingEntry: PendingTerminalInput = { type: "key", input: { key: normalizedKey, ctrl: input.ctrl, shift: input.shift, alt: input.alt, meta: input.meta, }, }; terminalDebugLog({ scope: "terminal-pane", event: "input:key:send", details: { key: normalizedKey, ctrl: input.ctrl, shift: input.shift, alt: input.alt, activeStreamId: getCurrentActiveStreamId(), }, }); if (!dispatchTerminalInputEntry(pendingEntry)) { enqueuePendingTerminalInput(pendingEntry); } return true; }, [ client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, getCurrentActiveStreamId, ] ); const handleTerminalData = useCallback( async (data: string) => { if (data.length === 0) { return; } const currentStreamId = getCurrentActiveStreamId(); terminalDebugLog({ scope: "terminal-pane", event: "input:data:received", details: { length: data.length, preview: summarizeTerminalText({ text: data, maxChars: 80 }), activeStreamId: currentStreamId, }, }); if (hasPendingTerminalModifiers(modifiers)) { const pendingResolution = resolvePendingModifierDataInput({ data, pendingModifiers: modifiers, }); if (pendingResolution.mode === "key") { if ( sendTerminalKey({ key: pendingResolution.key, ctrl: modifiers.ctrl, shift: modifiers.shift, alt: modifiers.alt, meta: false, }) ) { clearPendingModifiers(); return; } } if (pendingResolution.clearPendingModifiers) { clearPendingModifiers(); } } if (!client || !selectedTerminalIdRef.current) { enqueuePendingTerminalInput({ type: "data", data, }); return; } terminalDebugLog({ scope: "terminal-pane", event: "input:data:send", details: { length: data.length, preview: summarizeTerminalText({ text: data, maxChars: 80 }), activeStreamId: currentStreamId, }, }); const pendingEntry: PendingTerminalInput = { type: "data", data, }; if (!dispatchTerminalInputEntry(pendingEntry)) { enqueuePendingTerminalInput(pendingEntry); } }, [ clearPendingModifiers, client, dispatchTerminalInputEntry, getCurrentActiveStreamId, modifiers.alt, modifiers.ctrl, modifiers.shift, sendTerminalKey, enqueuePendingTerminalInput, ] ); const handleTerminalResize = useCallback( async (input: { rows: number; cols: number }) => { const { rows, cols } = input; if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) { return; } const normalizedRows = Math.floor(rows); const normalizedCols = Math.floor(cols); const previous = lastReportedSizeRef.current; if ( previous && previous.rows === normalizedRows && previous.cols === normalizedCols ) { return; } lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols }; terminalDebugLog({ scope: "terminal-pane", event: "display:resize:send", details: { terminalId: selectedTerminalId, rows: normalizedRows, cols: normalizedCols, }, }); client.sendTerminalInput(selectedTerminalId, { type: "resize", rows: normalizedRows, cols: normalizedCols, }); }, [client, selectedTerminalId] ); const handleTerminalKey = useCallback( async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean; }) => { sendTerminalKey(input); }, [sendTerminalKey] ); const handlePendingModifiersConsumed = useCallback(() => { clearPendingModifiers(); }, [clearPendingModifiers]); const handleOutputChunkConsumed = useCallback((sequence: number) => { outputDeliveryQueueRef.current?.consume({ sequence }); }, []); const toggleModifier = useCallback( (modifier: keyof ModifierState) => { setModifiers((current) => ({ ...current, [modifier]: !current[modifier] })); requestTerminalFocus(); }, [requestTerminalFocus] ); const sendVirtualKey = useCallback( (key: string) => { sendTerminalKey({ key, ctrl: modifiers.ctrl, shift: modifiers.shift, alt: modifiers.alt, meta: false, }); clearPendingModifiers(); requestTerminalFocus(); }, [ clearPendingModifiers, modifiers.alt, modifiers.ctrl, modifiers.shift, requestTerminalFocus, sendTerminalKey, ] ); if (!client || !isConnected) { return ( Host is not connected ); } const queryError = terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null; const isCreating = createTerminalMutation.isPending; const createError = createTerminalMutation.error instanceof Error ? createTerminalMutation.error.message : null; const closeError = killTerminalMutation.error instanceof Error ? killTerminalMutation.error.message : null; const combinedError = streamError ?? closeError ?? createError ?? queryError; return ( {!hideHeader ? ( {terminals.map((terminal) => { const isActive = terminal.id === selectedTerminalId; const isTabHovered = hoveredTerminalId === terminal.id; const isCloseHovered = hoveredCloseTerminalId === terminal.id; const isClosingTerminal = killTerminalMutation.isPending && killTerminalMutation.variables === terminal.id; const shouldShowCloseButton = isTabHovered || isCloseHovered || isClosingTerminal; const gradientId = `terminal-close-gradient-${terminal.id.replace( /[^a-zA-Z0-9_-]/g, "-" )}`; return ( updateSelectedTerminalId(terminal.id)} onHoverIn={() => handleTerminalTabHoverIn(terminal.id)} onHoverOut={() => handleTerminalTabHoverOut(terminal.id)} style={({ pressed, hovered }) => [ styles.terminalTab, isActive && styles.terminalTabActive, shouldShowCloseButton && styles.terminalTabHovered, (pressed || hovered) && styles.terminalTabHovered, ]} > {terminal.name} handleTerminalCloseHoverIn(terminal.id)} onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)} onPress={(event) => { event.stopPropagation(); void handleCloseTerminal(terminal.id); }} style={({ hovered, pressed }) => [ styles.terminalTabCloseButton, shouldShowCloseButton ? styles.terminalTabCloseButtonShown : styles.terminalTabCloseButtonHidden, ]} > {({ hovered = false, pressed = false }) => { const iconColor = hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted; return ( <> {isClosingTerminal ? ( ) : ( )} ); }} ); })} [ styles.headerIconButton, (hovered || pressed) && styles.headerIconButtonHovered, ]} > {isCreating ? ( ) : ( )} ) : null} {selectedTerminal ? ( { if (!swipeGesturesEnabled) { return; } openAgentList(); }} onSwipeLeft={() => { if (!swipeGesturesEnabled) { return; } openFileExplorer(); }} onInput={handleTerminalData} onResize={handleTerminalResize} onTerminalKey={handleTerminalKey} onPendingModifiersConsumed={handlePendingModifiersConsumed} onOutputChunkConsumed={handleOutputChunkConsumed} pendingModifiers={modifiers} focusRequestToken={focusRequestToken} resizeRequestToken={resizeRequestToken} /> ) : ( No terminal selected )} {isAttaching ? ( ) : null} {combinedError ? ( {combinedError} ) : null} {isMobile ? ( {(Object.keys(MODIFIER_LABELS) as Array).map((modifier) => ( toggleModifier(modifier)} style={({ hovered, pressed }) => [ styles.keyButton, modifiers[modifier] && styles.keyButtonActive, (hovered || pressed) && styles.keyButtonHovered, ]} > {MODIFIER_LABELS[modifier]} ))} {KEY_BUTTONS.map((button) => ( sendVirtualKey(button.key)} style={({ hovered, pressed }) => [ styles.keyButton, (hovered || pressed) && styles.keyButtonHovered, ]} > {button.label} ))} ) : null} ); } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0, backgroundColor: theme.colors.surface0, }, header: { minHeight: 48, borderBottomWidth: 1, borderBottomColor: theme.colors.border, flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[1], paddingHorizontal: theme.spacing[1], paddingVertical: theme.spacing[1], }, tabsScroll: { flex: 1, minWidth: 0, }, tabsContent: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], paddingRight: theme.spacing[2], }, terminalTab: { borderRadius: theme.borderRadius.md, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], justifyContent: "center", maxWidth: TERMINAL_TAB_MAX_WIDTH, minWidth: 96, overflow: "hidden", position: "relative", }, terminalTabHovered: { backgroundColor: theme.colors.surface2, }, terminalTabActive: { backgroundColor: theme.colors.surface2, }, terminalTabText: { minWidth: 0, flexShrink: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, terminalTabTextActive: { color: theme.colors.foreground, }, terminalTabCloseButton: { position: "absolute", right: 0, top: 0, bottom: 0, width: 32, borderRadius: theme.borderRadius.sm, alignItems: "flex-end", justifyContent: "center", paddingRight: theme.spacing[2], }, terminalTabCloseButtonShown: { opacity: 1, }, terminalTabCloseButtonHidden: { opacity: 0, }, terminalTabCloseGradient: { ...StyleSheet.absoluteFillObject, borderRadius: theme.borderRadius.sm, overflow: "hidden", zIndex: 0, }, terminalTabCloseIcon: { position: "relative", zIndex: 1, alignItems: "center", justifyContent: "center", }, headerActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, headerIconButton: { width: 30, height: 30, borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface1, alignItems: "center", justifyContent: "center", }, headerIconButtonHovered: { backgroundColor: theme.colors.surface2, }, outputContainer: { flex: 1, minHeight: 0, position: "relative", backgroundColor: theme.colors.background, }, terminalGestureContainer: { flex: 1, minHeight: 0, }, attachOverlay: { ...StyleSheet.absoluteFillObject, alignItems: "center", justifyContent: "center", backgroundColor: "rgba(0, 0, 0, 0.16)", }, errorRow: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[1], borderTopWidth: 1, borderTopColor: theme.colors.border, backgroundColor: theme.colors.surface1, }, statusError: { color: theme.colors.destructive, fontSize: theme.fontSize.xs, }, keyboardContainer: { borderTopWidth: 1, borderTopColor: theme.colors.border, backgroundColor: theme.colors.surface0, paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[2], }, keyboardRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], paddingRight: theme.spacing[3], }, keyButton: { minWidth: 44, height: 34, borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, alignItems: "center", justifyContent: "center", paddingHorizontal: theme.spacing[2], backgroundColor: theme.colors.surface1, }, keyButtonHovered: { backgroundColor: theme.colors.surface2, }, keyButtonActive: { borderColor: theme.colors.primary, backgroundColor: theme.colors.surface2, }, keyButtonText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, keyButtonTextActive: { color: theme.colors.foreground, }, centerState: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: theme.spacing[4], }, stateText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, textAlign: "center", }, }));