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, useColorScheme } from "react-native";import { KeyboardController, KeyboardEvents, KeyboardStickyView, useKeyboardState,} from "react-native-keyboard-controller"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";import { ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller,} from "../../components/ComposerToolbarTrigger";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 { 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 { getPierreTerminalTheme } 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 { 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"; type PendingModifier = "ctrl" | "meta";type HostPlatform = "mac" | "linux" | "windows" | "unknown"; 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: "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 applyCtrlModifier(input: string): string { const firstCharacter = input[0]; if (!firstCharacter) { return input; } const lowerCharacter = firstCharacter.toLowerCase(); if (lowerCharacter >= "a" && lowerCharacter <= "z") { return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96); } if (firstCharacter === "@") return "\u0000"; if (firstCharacter === "[") return "\u001b"; if (firstCharacter === "\\") return "\u001c"; if (firstCharacter === "]") return "\u001d"; if (firstCharacter === "^") return "\u001e"; if (firstCharacter === "_") return "\u001f"; if (firstCharacter === "?") return "\u007f"; return input;} function pickRunningTerminalSessionForBootstrap( sessions: ReadonlyArray<KnownTerminalSession>,): 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 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 appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; 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, 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<ReturnType<typeof setTimeout> | null>(null); const firstNonEmptyBufferLoggedRef = useRef(false); const lastBufferReplayKeyRef = useRef<string | null>(null); const sentInitialInputKeyRef = useRef<string | null>(null); const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(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<string | null>(null); const reopenedStaleTerminalKeyRef = useRef<string | null>(null); const pendingExitNavigationRef = useRef<string | null>(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 hostPlatform = useMemo( () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), [selectedEnvironmentConnection?.environmentLabel], ); const terminalTheme = getPierreTerminalTheme(appearanceScheme); const usesNativeHeaderGlass = Platform.OS === "ios"; const pendingModifier = pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; const headerSubtitle = selectedThreadProject?.title ?? ""; const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => { const modifierActions: ReadonlyArray<TerminalToolbarAction> = 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: "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]);Lines 1–500 of 1324. Load more
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, useColorScheme } from "react-native"; import { KeyboardController, KeyboardEvents, KeyboardStickyView, useKeyboardState, } from "react-native-keyboard-controller"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, } from "../../components/ComposerToolbarTrigger"; 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 { 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 { getPierreTerminalTheme } 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 { 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"; type PendingModifier = "ctrl" | "meta"; type HostPlatform = "mac" | "linux" | "windows" | "unknown"; 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: "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 applyCtrlModifier(input: string): string { const firstCharacter = input[0]; if (!firstCharacter) { return input; } const lowerCharacter = firstCharacter.toLowerCase(); if (lowerCharacter >= "a" && lowerCharacter <= "z") { return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96); } if (firstCharacter === "@") return "\u0000"; if (firstCharacter === "[") return "\u001b"; if (firstCharacter === "\\") return "\u001c"; if (firstCharacter === "]") return "\u001d"; if (firstCharacter === "^") return "\u001e"; if (firstCharacter === "_") return "\u001f"; if (firstCharacter === "?") return "\u007f"; return input; } function pickRunningTerminalSessionForBootstrap( sessions: ReadonlyArray<KnownTerminalSession>, ): 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 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 appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; 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, 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<ReturnType<typeof setTimeout> | null>(null); const firstNonEmptyBufferLoggedRef = useRef(false); const lastBufferReplayKeyRef = useRef<string | null>(null); const sentInitialInputKeyRef = useRef<string | null>(null); const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(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<string | null>(null); const reopenedStaleTerminalKeyRef = useRef<string | null>(null); const pendingExitNavigationRef = useRef<string | null>(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 hostPlatform = useMemo( () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), [selectedEnvironmentConnection?.environmentLabel], ); const terminalTheme = getPierreTerminalTheme(appearanceScheme); const usesNativeHeaderGlass = Platform.OS === "ios"; const pendingModifier = pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; const headerSubtitle = selectedThreadProject?.title ?? ""; const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => { const modifierActions: ReadonlyArray<TerminalToolbarAction> = 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: "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]);