1370 lines · 46.9 KB · TypeScript
RawDownload
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 { useSelectedThreadProjection } 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.TaggedErrorClass<TerminalClipboardReadError>()(  "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>,): 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 { state: workspaceState } = useWorkspaceState();  const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();  const params = props.route.params;  const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =    useThreadSelection();  const selectedThreadDetail = useSelectedThreadProjection();  const selectedThreadDetailWorktreePath =    selectedThreadDetail?.projection.thread.worktreePath ?? null;  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<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: selectedThreadDetailWorktreePath,    });  }, [    activeKnownSession?.state.summary,    pendingLaunch,    selectedThread,    selectedThreadDetailWorktreePath,    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 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<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: "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" },

Lines 1–500 of 1370.