import { BlurTargetView } from "expo-blur"; import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Platform, Pressable, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import * as Schema from "effect/Schema"; import { KeyboardController, KeyboardEvents, KeyboardStickyView, useKeyboardState, } from "react-native-keyboard-controller"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, } from "../../components/ComposerToolbar"; import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { GlassSurface } from "../../components/GlassSurface"; import { LoadingScreen } from "../../components/LoadingScreen"; import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { terminalEnvironment } from "../../state/terminal"; import { useAtomCommand } from "../../state/use-atom-command"; import { useServerConfigs } from "../../state/entities"; import { useWorkspaceState } from "../../state/workspace"; import { MAX_TERMINAL_FONT_SIZE, MIN_TERMINAL_FONT_SIZE, TERMINAL_FONT_SIZE_STEP, stepTerminalFontSize, } from "../../lib/appearancePreferences"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAttachedTerminalSession, useKnownTerminalSessions, } from "../../state/use-terminal-session"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadDetail } from "../../state/use-thread-detail"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { TerminalSurface } from "./NativeTerminalSurface"; import { getMobileTerminalTheme } from "./terminalTheme"; import { terminalDebugLog } from "./terminalDebugLog"; import { getTerminalBufferReplayKey, getTerminalSurfaceReplayBuffer, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, } from "./terminalBufferReplay"; import { resolveTerminalOpenLocation, takePendingTerminalLaunch, type PendingTerminalLaunch, } from "./terminalLaunchContext"; import { basename, buildTerminalMenuSessions, getTerminalStatusLabel, nextOpenTerminalId, previousLiveTerminalId, resolveTerminalSessionLabel, type TerminalMenuSession, } from "./terminalMenu"; import { hostPlatformFromOs, resolveModifiedTerminalInput, type HostPlatform, type PendingModifier, } from "./terminalInput"; import { createTerminalPasteSession } from "./terminalPaste"; import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState"; const DEFAULT_TERMINAL_COLS = 80; const DEFAULT_TERMINAL_ROWS = 24; const TERMINAL_ACCESSORY_HEIGHT = 52; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; class TerminalClipboardReadError extends Schema.TaggedError()( "TerminalClipboardReadError", { terminalId: Schema.String, cause: Schema.Defect() }, ) { override get message(): string { return `Failed to read the clipboard for a paste into terminal ${this.terminalId}.`; } } type TerminalToolbarAction = | { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string } | { readonly kind: "clear"; readonly key: string; readonly label: string } | { readonly kind: "paste"; readonly key: string; readonly label: string } | { readonly kind: "modifier"; readonly key: string; readonly label: string; readonly modifier: PendingModifier; }; function firstRouteParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { return value[0] ?? null; } return value ?? null; } function inferHostPlatform(environmentLabel: string | null): HostPlatform { const value = environmentLabel?.toLowerCase() ?? ""; if ( value.includes("mac") || value.includes("macbook") || value.includes("mac mini") || value.includes("imac") || value.includes("darwin") ) { return "mac"; } if (value.includes("windows") || value.includes("win")) { return "windows"; } if (value.includes("linux") || value.includes("ubuntu") || value.includes("debian")) { return "linux"; } return "unknown"; } function pickRunningTerminalSessionForBootstrap( sessions: ReadonlyArray, ): KnownTerminalSession | null { const running = sessions.filter( (session) => session.state.status === "running" || session.state.status === "starting", ); if (running.length === 0) { return null; } return ( running.find((session) => session.target.terminalId === DEFAULT_TERMINAL_ID) ?? running[0] ?? null ); } type ThreadTerminalRouteScreenProps = StaticScreenProps<{ readonly environmentId: string; readonly threadId: string; readonly terminalId?: string; }>; export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { const terminalBlurTarget = useRef(null); const navigation = useNavigation(); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear"); const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const { state: workspaceState } = useWorkspaceState(); const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const params = props.route.params; const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); const routeEnvironmentIdRaw = firstRouteParam(params.environmentId); const routeThreadIdRaw = firstRouteParam(params.threadId); const routeEnvironmentId = routeEnvironmentIdRaw ? EnvironmentId.make(routeEnvironmentIdRaw) : null; const routeThreadId = routeThreadIdRaw ? ThreadId.make(routeThreadIdRaw) : null; const environment = useEnvironmentPresentation(routeEnvironmentId); const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; const requestedTerminalId = firstRouteParam(params.terminalId); const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID; const { isReady: hasResolvedFontPreference, appearance, themeAppearance: appearanceScheme, themeId, setTerminalFontSize, } = useAppearancePreferences(); const fontSize = appearance.terminalFontSize; const cachedRouteGridSize = routeEnvironmentId && routeThreadId ? getCachedTerminalGridSize({ environmentId: routeEnvironmentId, threadId: routeThreadId, terminalId, }) : null; const knownSessions = useKnownTerminalSessions({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, }); const runningSession = useMemo( () => pickRunningTerminalSessionForBootstrap(knownSessions), [knownSessions], ); const activeKnownSession = useMemo( () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, [knownSessions, terminalId], ); const launchTarget = useMemo( () => selectedThread ? { environmentId: selectedThread.environmentId, threadId: selectedThread.id, terminalId, } : null, [selectedThread, terminalId], ); const launchTargetKey = launchTarget ? `${launchTarget.environmentId}:${launchTarget.threadId}:${launchTarget.terminalId}` : null; const [pendingLaunchEntry, setPendingLaunchEntry] = useState<{ readonly key: string | null; readonly launch: PendingTerminalLaunch | null; }>(() => ({ key: launchTargetKey, launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget), })); const pendingLaunch = pendingLaunchEntry.key === launchTargetKey ? pendingLaunchEntry.launch : null; const hasResolvedPendingLaunch = pendingLaunchEntry.key === launchTargetKey; const [initialAttachGridEntry, setInitialAttachGridEntry] = useState(() => ({ key: launchTargetKey, size: cachedRouteGridSize ?? { cols: DEFAULT_TERMINAL_COLS, rows: DEFAULT_TERMINAL_ROWS, }, })); const initialAttachGridSize = initialAttachGridEntry.key === launchTargetKey ? initialAttachGridEntry.size : null; const [lastGridSize, setLastGridSize] = useState( cachedRouteGridSize ?? { cols: DEFAULT_TERMINAL_COLS, rows: DEFAULT_TERMINAL_ROWS, }, ); const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0); const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false); const bufferReplayTimerRef = useRef | null>(null); const firstNonEmptyBufferLoggedRef = useRef(false); const lastBufferReplayKeyRef = useRef(null); const sentInitialInputKeyRef = useRef(null); const [readyBufferReplayKey, setReadyBufferReplayKey] = useState(null); /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */ const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true); const [pendingModifierState, setPendingModifierState] = useState<{ readonly terminalId: string; readonly value: PendingModifier | null; }>({ terminalId, value: null, }); const shouldRedirectToRunningTerminal = requestedTerminalId === null && runningSession !== null && runningSession.target.terminalId !== terminalId; const launchLocationCandidate = useMemo(() => { if (!selectedThread || !selectedThreadProject?.workspaceRoot) { return null; } if (pendingLaunch) { return { cwd: pendingLaunch.cwd, worktreePath: pendingLaunch.worktreePath, }; } return resolveTerminalOpenLocation({ terminalLocation: activeKnownSession?.state.summary ?? null, activeSessionLocation: activeKnownSession?.state.summary ?? null, workspaceRoot: selectedThreadProject.workspaceRoot, threadShellWorktreePath: selectedThread.worktreePath ?? null, threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, }); }, [ activeKnownSession?.state.summary, pendingLaunch, selectedThread, selectedThreadDetail?.worktreePath, selectedThreadProject?.workspaceRoot, ]); const [initialLaunchLocationEntry, setInitialLaunchLocationEntry] = useState(() => ({ key: launchTargetKey, location: launchLocationCandidate, })); const launchLocation = initialLaunchLocationEntry.key === launchTargetKey ? initialLaunchLocationEntry.location : null; const terminalAttachInput = useMemo( () => selectedThread !== null && launchLocation !== null && hasResolvedPendingLaunch && initialAttachGridSize !== null && hasResolvedFontPreference && hasMeasuredSurface && isEnvironmentReady && !shouldRedirectToRunningTerminal ? { threadId: selectedThread.id, terminalId, cwd: launchLocation.cwd, worktreePath: launchLocation.worktreePath, cols: initialAttachGridSize.cols, rows: initialAttachGridSize.rows, ...(pendingLaunch?.env ? { env: pendingLaunch.env } : {}), ...(pendingLaunch ? { restartIfNotRunning: true } : {}), } : null, [ hasMeasuredSurface, hasResolvedFontPreference, hasResolvedPendingLaunch, initialAttachGridSize, isEnvironmentReady, launchLocation, pendingLaunch, selectedThread, shouldRedirectToRunningTerminal, terminalId, ], ); const terminal = useAttachedTerminalSession({ environmentId: selectedThread?.environmentId ?? null, terminal: terminalAttachInput, }); const terminalKey = selectedThread ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}` : terminalId; const bufferReplayKey = useMemo( () => getTerminalBufferReplayKey({ terminalKey, fontSize }), [fontSize, terminalKey], ); if (lastBufferReplayKeyRef.current === null) { lastBufferReplayKeyRef.current = bufferReplayKey; } const terminalSurfaceBuffer = getTerminalSurfaceReplayBuffer({ buffer: terminal.buffer, replayKey: bufferReplayKey, readyReplayKey: readyBufferReplayKey, }); const isRunning = terminal.status === "running" || terminal.status === "starting"; // When the process ends while this screen is attached (e.g. typing `exit`), // close the session and leave the screen, mirroring the web drawer's // onSessionExited flow. Only react to a running -> exited transition // observed on this screen so already-exited sessions can still be opened // (they restart on attach). const runningTerminalKeyRef = useRef(null); const reopenedStaleTerminalKeyRef = useRef(null); const pendingExitNavigationRef = useRef(null); // Attach subscriptions are cached with an idle TTL, so revisiting a // terminal whose session ended while unobserved reuses the stale stream // without a new attach RPC — the server never respawns anything. Detect // that (dead status with processed events, never seen running here) and // issue an explicit open; its snapshot flows into the live subscription. useEffect(() => { if (isRunning) { reopenedStaleTerminalKeyRef.current = null; return; } if ( terminalAttachInput === null || !selectedThread || (terminal.status !== "closed" && terminal.status !== "exited") || terminal.version === 0 || runningTerminalKeyRef.current === terminalKey || reopenedStaleTerminalKeyRef.current === terminalKey ) { return; } reopenedStaleTerminalKeyRef.current = terminalKey; void openTerminal({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, terminalId, cwd: terminalAttachInput.cwd, worktreePath: terminalAttachInput.worktreePath, cols: terminalAttachInput.cols, rows: terminalAttachInput.rows, ...(terminalAttachInput.env ? { env: terminalAttachInput.env } : {}), }, }).then((result) => { // Release the guard on failure so a later render can retry the respawn. if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) { reopenedStaleTerminalKeyRef.current = null; } }); }, [ isRunning, openTerminal, selectedThread, terminal.status, terminal.version, terminalAttachInput, terminalId, terminalKey, ]); useEffect(() => { terminalDebugLog("surface:props", { terminalKey, atomBufferLen: terminal.buffer.length, surfaceBufferLen: terminalSurfaceBuffer.length, replayKey: bufferReplayKey, readyReplayKey: readyBufferReplayKey, status: terminal.status, version: terminal.version, }); }, [ bufferReplayKey, readyBufferReplayKey, terminal.buffer.length, terminal.status, terminal.version, terminalKey, terminalSurfaceBuffer.length, ]); useEffect(() => { terminalDebugLog("session:status", { terminalKey, status: terminal.status, error: terminal.error, summary: terminal.summary?.cwd ?? null, bufferLen: terminal.buffer.length, version: terminal.version, }); }, [ terminal.buffer.length, terminal.error, terminal.status, terminal.summary?.cwd, terminal.version, terminalKey, ]); useEffect(() => { if (terminal.buffer.length === 0 || firstNonEmptyBufferLoggedRef.current) { return; } firstNonEmptyBufferLoggedRef.current = true; terminalDebugLog("session:first-nonempty-buffer", { terminalKey, length: terminal.buffer.length, preview: terminal.buffer.slice(0, 160), }); }, [terminal.buffer, terminal.buffer.length, terminalKey]); const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null; const serverConfigs = useServerConfigs(); const hostOs = routeEnvironmentId === null ? null : (serverConfigs.get(routeEnvironmentId)?.environment.platform.os ?? null); // The descriptor is authoritative; the label is only a hint until it arrives. const hostPlatform = useMemo( () => hostPlatformFromOs(hostOs) ?? inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), [hostOs, selectedEnvironmentConnection?.environmentLabel], ); const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme); const usesNativeHeaderGlass = Platform.OS === "ios"; const pendingModifier = pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; const headerSubtitle = selectedThreadProject?.title ?? ""; const terminalToolbarActions = useMemo>(() => { const modifierActions: ReadonlyArray = hostPlatform === "mac" ? [ { kind: "modifier", key: "cmd", label: "cmd", modifier: "meta" }, { kind: "modifier", key: "ctrl", label: "ctrl", modifier: "ctrl" }, ] : [ { kind: "modifier", key: "ctrl", label: "ctrl", modifier: "ctrl" }, { kind: "modifier", key: "alt", label: "alt", modifier: "meta" }, ]; return [ { kind: "send", key: "esc", label: "esc", data: "\u001b" }, ...modifierActions, { kind: "send", key: "tab", label: "tab", data: "\t" }, { kind: "paste", key: "paste", label: "paste" }, { kind: "clear", key: "clear", label: "clear" }, { kind: "send", key: "up", label: "↑", data: "\u001b[A" }, { kind: "send", key: "down", label: "↓", data: "\u001b[B" }, { kind: "send", key: "left", label: "←", data: "\u001b[D" }, { kind: "send", key: "right", label: "→", data: "\u001b[C" }, { kind: "send", key: "tilde", label: "~", data: "~" }, { kind: "send", key: "pipe", label: "|", data: "|" }, { kind: "send", key: "slash", label: "/", data: "/" }, { kind: "send", key: "dash", label: "-", data: "-" }, ]; }, [hostPlatform]); const keyboardState = useKeyboardState((state) => ({ height: state.height, isVisible: state.isVisible, })); const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed; const terminalBottomInset = (keyboardState.isVisible ? keyboardState.height : 0) + (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); useEffect(() => { const keyboardWillShow = KeyboardEvents.addListener("keyboardWillShow", () => { setIsAccessoryDismissed(false); }); const keyboardWillHide = KeyboardEvents.addListener("keyboardWillHide", () => { setIsAccessoryDismissed(true); }); return () => { keyboardWillShow.remove(); keyboardWillHide.remove(); }; }, []); const terminalMenuSessions = useMemo>( () => buildTerminalMenuSessions({ knownSessions, workspaceRoot: selectedThreadProject?.workspaceRoot ?? null, currentSession: { terminalId, cwd: cwd ?? null, status: terminal.status, hasRunningSubprocess: terminal.hasRunningSubprocess, displayLabel: resolveTerminalSessionLabel(terminalId, terminal.summary), updatedAt: terminal.updatedAt, }, }), [ cwd, knownSessions, selectedThreadProject?.workspaceRoot, terminal.hasRunningSubprocess, terminal.summary, terminal.status, terminal.updatedAt, terminalId, ], ); useEffect(() => { if (pendingLaunchEntry.key === launchTargetKey) { return; } setPendingLaunchEntry({ key: launchTargetKey, launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget), }); }, [launchTarget, launchTargetKey, pendingLaunchEntry.key]); useEffect(() => { if (initialAttachGridEntry.key === launchTargetKey) { return; } setInitialAttachGridEntry({ key: launchTargetKey, size: cachedRouteGridSize ?? { cols: DEFAULT_TERMINAL_COLS, rows: DEFAULT_TERMINAL_ROWS, }, }); }, [cachedRouteGridSize, initialAttachGridEntry.key, launchTargetKey]); useEffect(() => { if ( initialLaunchLocationEntry.key === launchTargetKey && initialLaunchLocationEntry.location !== null ) { return; } if (initialLaunchLocationEntry.key === launchTargetKey && launchLocationCandidate === null) { return; } setInitialLaunchLocationEntry({ key: launchTargetKey, location: launchLocationCandidate, }); }, [ initialLaunchLocationEntry.key, initialLaunchLocationEntry.location, launchLocationCandidate, launchTargetKey, ]); useEffect(() => { if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) { return; } navigation.dispatch( StackActions.replace("ThreadTerminal", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), terminalId: runningSession.target.terminalId, }), ); }, [navigation, runningSession, selectedThread, shouldRedirectToRunningTerminal]); useEffect(() => { const initialInput = pendingLaunch?.initialInput; if ( !initialInput || !selectedThread || terminal.version === 0 || sentInitialInputKeyRef.current === launchTargetKey ) { return; } sentInitialInputKeyRef.current = launchTargetKey; void writeTerminal({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, terminalId, data: initialInput, }, }); }, [ launchTargetKey, pendingLaunch?.initialInput, selectedThread, terminal.version, terminalId, writeTerminal, ]); useEffect(() => { firstNonEmptyBufferLoggedRef.current = false; sentInitialInputKeyRef.current = null; }, [terminalKey]); const clearBufferReplayTimer = useCallback(() => { if (bufferReplayTimerRef.current !== null) { clearTimeout(bufferReplayTimerRef.current); bufferReplayTimerRef.current = null; } }, []); const scheduleBufferReplayReady = useCallback(() => { clearBufferReplayTimer(); const replayKey = bufferReplayKey; terminalDebugLog("replay:schedule-ready", { replayKey, delayMs: TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, }); bufferReplayTimerRef.current = setTimeout(() => { bufferReplayTimerRef.current = null; setReadyBufferReplayKey(replayKey); terminalDebugLog("replay:ready", { replayKey }); }, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS); }, [bufferReplayKey, clearBufferReplayTimer]); useEffect(() => { if (lastBufferReplayKeyRef.current === bufferReplayKey) { return; } lastBufferReplayKeyRef.current = bufferReplayKey; clearBufferReplayTimer(); setReadyBufferReplayKey(null); }, [bufferReplayKey, clearBufferReplayTimer]); useEffect(() => clearBufferReplayTimer, [clearBufferReplayTimer]); useEffect(() => { if (!routeEnvironmentId || !routeThreadId) { setLastGridSize({ cols: DEFAULT_TERMINAL_COLS, rows: DEFAULT_TERMINAL_ROWS, }); return; } setLastGridSize( getCachedTerminalGridSize({ environmentId: routeEnvironmentId, threadId: routeThreadId, terminalId, }) ?? { cols: DEFAULT_TERMINAL_COLS, rows: DEFAULT_TERMINAL_ROWS, }, ); setHasMeasuredSurface(true); }, [routeEnvironmentId, routeThreadId, terminalId]); /** Resolves true once the pty accepted the write, false if it was skipped or rejected. */ const writeInput = useCallback( async (data: string): Promise => { if (!selectedThread || !isRunning) { return false; } const result = await writeTerminal({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, terminalId, data, }, }); return result._tag === "Success"; }, [isRunning, selectedThread, terminalId, writeTerminal], ); const pasteSessionRef = useRef | null>(null); if (pasteSessionRef.current === null) { pasteSessionRef.current = createTerminalPasteSession(); } const pasteSession = pasteSessionRef.current; // Drop delayed clipboard reads whenever the route or attached pty changes. useEffect(() => { pasteSession.reset(isRunning); return () => { pasteSession.reset(false); }; }, [isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]); const pasteFromClipboard = useCallback(async () => { await pasteSession.paste({ readText: Clipboard.getStringAsync, write: writeInput, onReadError: (cause) => { console.error(new TerminalClipboardReadError({ terminalId, cause })); }, }); }, [pasteSession, terminalId, writeInput]); /** Sends a key through the armed toolbar modifier, if any, and disarms it. */ const writeModifiedInput = useCallback( (data: string) => { if (pendingModifier === null) { void writeInput(data); return; } setPendingModifierState({ terminalId, value: null }); const resolved = resolveModifiedTerminalInput({ data, modifier: pendingModifier, hostPlatform, }); if (resolved.kind === "paste") { void pasteFromClipboard(); return; } void writeInput(resolved.data); }, [hostPlatform, pasteFromClipboard, pendingModifier, terminalId, writeInput], ); const handleInput = useCallback( (data: string) => { if (data.length === 0) { return; } writeModifiedInput(data); }, [writeModifiedInput], ); const handleResize = useCallback( (size: { readonly cols: number; readonly rows: number }) => { terminalDebugLog("native:onResize", { cols: size.cols, rows: size.rows, terminalKey, }); setHasMeasuredSurface(true); if (readyBufferReplayKey !== bufferReplayKey) { scheduleBufferReplayReady(); } if (routeEnvironmentId && routeThreadId) { cacheTerminalGridSize( { environmentId: routeEnvironmentId, threadId: routeThreadId, terminalId, }, size, ); } if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) { return; } setLastGridSize(size); if (!selectedThread || !isRunning) { return; } void resizeTerminal({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, terminalId, cols: size.cols, rows: size.rows, }, }); }, [ isRunning, lastGridSize.cols, lastGridSize.rows, bufferReplayKey, readyBufferReplayKey, routeEnvironmentId, routeThreadId, resizeTerminal, scheduleBufferReplayReady, selectedThread, terminalId, terminalKey, ], ); const handleSelectTerminal = useCallback( (nextTerminalId: string) => { if (!selectedThread || nextTerminalId === terminalId) { return; } navigation.dispatch( StackActions.replace("ThreadTerminal", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), terminalId: nextTerminalId, }), ); }, [navigation, selectedThread, terminalId], ); const handleCloseTerminal = useCallback(() => { if (navigation.canGoBack()) { navigation.goBack(); return; } navigation.dispatch( StackActions.replace("Thread", { environmentId: params.environmentId, threadId: params.threadId, }), ); }, [navigation, params.environmentId, params.threadId]); const navigateAwayAfterExit = useCallback(() => { // With other shells still live, fall through to the previous one instead // of dropping the user back on the thread. const fallbackTerminalId = previousLiveTerminalId({ sessions: terminalMenuSessions, exitedTerminalId: terminalId, }); if (fallbackTerminalId !== null && selectedThread) { navigation.dispatch( StackActions.replace("ThreadTerminal", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), terminalId: fallbackTerminalId, }), ); return; } if (navigation.canGoBack()) { navigation.goBack(); return; } // Deep-linked/root mounts have nothing to pop; land on the thread // instead of stranding the user on a dead terminal. if (selectedThread) { navigation.dispatch( StackActions.replace("Thread", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), }), ); } }, [navigation, selectedThread, terminalId, terminalMenuSessions]); useEffect(() => { // Detached (hidden surface or environment drop): forget the running // marker so a reattach takes the stale-reopen path instead of misreading // the dead snapshot as an exit observed on this screen. A pending exit // navigation stays armed — it only clears once the session runs again — // so refocusing a dead screen still leaves it. if (terminalAttachInput === null) { runningTerminalKeyRef.current = null; return; } if (isRunning) { runningTerminalKeyRef.current = terminalKey; // The session came back (e.g. respawned elsewhere) before the user // returned; a stale pending exit must not eject a live terminal. pendingExitNavigationRef.current = null; return; } // The web drawer treats both exited and closed as session end. const sessionEnded = terminal.status === "exited" || terminal.status === "closed"; if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) { return; } runningTerminalKeyRef.current = null; // Mark this key handled so the stale-attach effect doesn't respawn the // session the user just ended. reopenedStaleTerminalKeyRef.current = terminalKey; if (selectedThread) { void closeTerminal({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, terminalId, }, }); } if (navigation.isFocused()) { navigateAwayAfterExit(); return; } // An unfocused screen can't navigate; leave when the user returns so // they never land on the dead session. pendingExitNavigationRef.current = terminalKey; }, [ closeTerminal, isRunning, navigateAwayAfterExit, navigation, selectedThread, terminal.status, terminalAttachInput, terminalId, terminalKey, ]); useEffect( () => navigation.addListener("focus", () => { if (pendingExitNavigationRef.current !== terminalKey) { return; } pendingExitNavigationRef.current = null; navigateAwayAfterExit(); }), [navigateAwayAfterExit, navigation, terminalKey], ); const handleOpenNewTerminal = useCallback(() => { if (!selectedThread) { return; } navigation.dispatch( StackActions.replace("ThreadTerminal", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), terminalId: nextOpenTerminalId({ listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), activeRouteTerminalId: terminalId, }), }), ); }, [navigation, selectedThread, terminalId, terminalMenuSessions]); const handleDecreaseFontSize = useCallback(() => { setTerminalFontSize(stepTerminalFontSize(fontSize, -1)); }, [fontSize, setTerminalFontSize]); const handleIncreaseFontSize = useCallback(() => { setTerminalFontSize(stepTerminalFontSize(fontSize, 1)); }, [fontSize, setTerminalFontSize]); // Android mirror of the iOS NativeHeaderToolbar terminal menu below: text // size, session switching, and "Open new terminal", rendered through the // token-styled anchored menu (the native header items are iOS-only). const androidTerminalMenuActions = useMemo( () => [ { id: "text-size", title: "Text size", subactions: [ { id: "font-decrease", title: `A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, attributes: fontSize <= MIN_TERMINAL_FONT_SIZE ? { disabled: true } : undefined, }, { id: "font-increase", title: `A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, attributes: fontSize >= MAX_TERMINAL_FONT_SIZE ? { disabled: true } : undefined, }, ], }, ...terminalMenuSessions.map((session): MenuAction => ({ id: `terminal-session:${session.terminalId}`, title: session.displayLabel, subtitle: [getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] .filter(Boolean) .join(" · "), state: session.terminalId === terminalId ? ("on" as const) : undefined, })), { id: "terminal-new", title: "Open new terminal", image: "plus", subtitle: `Start another shell in ${basename(selectedThreadProject?.workspaceRoot ?? null) ?? "this workspace"}`, }, ], [fontSize, selectedThreadProject?.workspaceRoot, terminalId, terminalMenuSessions], ); const handleAndroidTerminalMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { const id = event.nativeEvent.event; if (id === "font-decrease") { handleDecreaseFontSize(); return; } if (id === "font-increase") { handleIncreaseFontSize(); return; } if (id === "terminal-new") { handleOpenNewTerminal(); return; } if (id.startsWith("terminal-session:")) { handleSelectTerminal(id.slice("terminal-session:".length)); } }, [handleDecreaseFontSize, handleIncreaseFontSize, handleOpenNewTerminal, handleSelectTerminal], ); const handleClearTerminal = useCallback(() => { if (!selectedThread) { return; } setPendingModifierState({ terminalId, value: null }); void clearTerminal({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, terminalId, }, }); }, [clearTerminal, selectedThread, terminalId]); const handleToolbarActionPress = useCallback( (action: TerminalToolbarAction) => { if (action.kind === "modifier") { setPendingModifierState((current) => ({ terminalId, value: (current.terminalId === terminalId ? current.value : null) === action.modifier ? null : action.modifier, })); return; } if (action.kind === "clear") { handleClearTerminal(); return; } if (action.kind === "paste") { setPendingModifierState({ terminalId, value: null }); void pasteFromClipboard(); return; } writeModifiedInput(action.data); }, [handleClearTerminal, pasteFromClipboard, terminalId, writeModifiedInput], ); const handleDismissKeyboard = useCallback(() => { setIsAccessoryDismissed(true); void KeyboardController.dismiss(); }, []); const handleShowKeyboard = useCallback(() => { setKeyboardFocusRequest((current) => current + 1); }, []); const handleRetryEnvironment = useCallback(() => { if (routeEnvironmentId !== null) { void retryEnvironment(routeEnvironmentId); } }, [retryEnvironment, routeEnvironmentId]); if (!selectedThread) { if (workspaceState.isLoadingConnections) { return ; } return ( ); } if (!selectedThreadProject?.workspaceRoot) { return ( ); } if (!environment.isReady && environment.presentation === null) { return ; } return ( <> 0 ? headerSubtitle : undefined, }} /> {Platform.OS === "android" ? ( {layout.usesSplitView ? ( ) : null} {isEnvironmentReady ? ( ) : null} } /> ) : null} {layout.usesSplitView ? ( ) : null} {isEnvironmentReady ? ( {getTerminalStatusLabel({ status: terminal.status, hasRunningSubprocess: terminal.hasRunningSubprocess, })} Text size {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} = MAX_TERMINAL_FONT_SIZE} discoverabilityLabel="Increase terminal text size" onPress={handleIncreaseFontSize} > {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} {terminalMenuSessions.map((session) => ( handleSelectTerminal(session.terminalId)} subtitle={[ getTerminalStatusLabel({ status: session.status }), basename(session.cwd), ] .filter(Boolean) .join(" · ")} > {session.displayLabel} ))} Open new terminal ) : null} {!isEnvironmentReady ? ( ) : ( <> {isAccessoryVisible ? ( {terminalToolbarActions.map((action) => { const active = action.kind === "modifier" && pendingModifier === action.modifier; return ( 1 ? 56 : 44} onPress={() => handleToolbarActionPress(action)} showChevron={false} textTransform={ action.kind === "modifier" || action.kind === "clear" ? "uppercase" : "none" } /> ); })} ) : !keyboardState.isVisible ? ( ({ bottom: 16, borderRadius: 28, opacity: pressed ? 0.72 : 1, position: "absolute", right: 16, })} > ) : null} )} ); }