1132 lines · 36.0 KB · TypeScript
RawDownload
import {  DEFAULT_TERMINAL_ID,  EnvironmentId,  type TerminalAttachStreamEvent,  ThreadId,} from "@t3tools/contracts";import type { KnownTerminalSession } from "@t3tools/client-runtime";import { SymbolView } from "expo-symbols";import { Stack, useLocalSearchParams, useRouter } from "expo-router";import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { Pressable, Text as RNText, View, useColorScheme } from "react-native";import {  KeyboardController,  KeyboardEvents,  KeyboardStickyView,  useKeyboardState,} from "react-native-keyboard-controller"; import {  ComposerToolbarButton,  ComposerToolbarRow,  ComposerToolbarScroller,} from "../../components/ComposerToolbarTrigger";import { EmptyState } from "../../components/EmptyState";import { GlassSurface } from "../../components/GlassSurface";import { LoadingScreen } from "../../components/LoadingScreen";import { buildThreadTerminalNavigation } from "../../lib/routes";import { getEnvironmentClient } from "../../state/environment-session-registry";import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";import {  attachTerminalSession,  useKnownTerminalSessions,  useTerminalSession,  useTerminalSessionTarget,} from "../../state/use-terminal-session";import { useThreadSelection } from "../../state/use-thread-selection";import { useSelectedThreadDetail } from "../../state/use-thread-detail";import { TerminalSurface } from "./NativeTerminalSurface";import { getPierreTerminalTheme } from "./terminalTheme";import { loadPreferences, savePreferencesPatch } from "../../lib/storage";import { terminalDebugLog } from "./terminalDebugLog";import {  getTerminalBufferReplayKey,  getTerminalSurfaceReplayBuffer,  TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS,} from "./terminalBufferReplay";import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap";import {  resolveTerminalOpenLocation,  stagePendingTerminalLaunch,  takePendingTerminalLaunch,} from "./terminalLaunchContext";import {  basename,  buildTerminalMenuSessions,  getTerminalStatusLabel,  nextOpenTerminalId,  resolveTerminalSessionLabel,  type TerminalMenuSession,} from "./terminalMenu";import {  DEFAULT_TERMINAL_FONT_SIZE,  MAX_TERMINAL_FONT_SIZE,  MIN_TERMINAL_FONT_SIZE,  TERMINAL_FONT_SIZE_STEP,  normalizeTerminalFontSize,} from "./terminalPreferences";import {  cacheTerminalFontSize,  cacheTerminalGridSize,  getCachedTerminalFontSize,  getCachedTerminalGridSize,} from "./terminalUiState"; const DEFAULT_TERMINAL_COLS = 80;const DEFAULT_TERMINAL_ROWS = 24;const TERMINAL_ACCESSORY_HEIGHT = 52; 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  );} export function ThreadTerminalRouteScreen() {  const router = useRouter();  const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";  const { isLoadingSavedConnection } = useRemoteEnvironmentState();  const params = useLocalSearchParams<{    environmentId?: string | string[];    threadId?: string | string[];    terminalId?: string | string[];  }>();  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 requestedTerminalId = firstRouteParam(params.terminalId);  const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID;  const cachedFontSize = getCachedTerminalFontSize();  const cachedRouteGridSize =    routeEnvironmentId && routeThreadId      ? getCachedTerminalGridSize({          environmentId: routeEnvironmentId,          threadId: routeThreadId,          terminalId,        })      : null;  const knownSessions = useKnownTerminalSessions({    environmentId: selectedThread?.environmentId ?? null,    threadId: selectedThread?.id ?? null,  });  const [lastGridSize, setLastGridSize] = useState(    cachedRouteGridSize ?? {      cols: DEFAULT_TERMINAL_COLS,      rows: DEFAULT_TERMINAL_ROWS,    },  );  const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE);  const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0);  const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false);  const hasOpenedRef = useRef(false);  const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);  const attachStreamLogCountRef = useRef(0);  const firstNonEmptyBufferLoggedRef = useRef(false);  const lastBufferReplayKeyRef = useRef<string | null>(null);  const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null);  const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState(    cachedFontSize !== 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 target = useTerminalSessionTarget({    environmentId: selectedThread?.environmentId ?? null,    threadId: selectedThread?.id ?? null,    terminalId,  });  const terminal = useTerminalSession(target);  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";   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 runningSession = useMemo(    () => pickRunningTerminalSessionForBootstrap(knownSessions),    [knownSessions],  );  const activeKnownSession = useMemo(    () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null,    [knownSessions, terminalId],  );   const terminalAttachLaunchHintsRef = useRef({    terminalSummary: terminal.summary,    activeKnownSummary: activeKnownSession?.state.summary ?? null,  });  terminalAttachLaunchHintsRef.current = {    terminalSummary: terminal.summary,    activeKnownSummary: activeKnownSession?.state.summary ?? null,  };   const terminalTheme = getPierreTerminalTheme(appearanceScheme);  const pendingModifier =    pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;  const headerTitle = useMemo(() => {    const topLineParts = [      selectedEnvironmentConnection?.environmentLabel ?? null,      selectedThreadProject?.title ?? null,    ].filter((value): value is string => Boolean(value));     return {      topLine: topLineParts.join(" \u00b7 "),      bottomLine: cwd ?? selectedThreadProject?.workspaceRoot ?? "",    };  }, [    cwd,    selectedEnvironmentConnection?.environmentLabel,    selectedThreadProject?.title,    selectedThreadProject?.workspaceRoot,  ]);  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]);  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<ReadonlyArray<TerminalMenuSession>>(    () =>      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,    ],  );   const logAttachStreamEvent = useCallback((event: TerminalAttachStreamEvent) => {    const n = ++attachStreamLogCountRef.current;    if (event.type === "output" && n > 32 && n % 64 !== 0) {      return;    }    if (event.type === "snapshot") {      terminalDebugLog("attach:stream", {        n,        type: event.type,        status: event.snapshot.status,        historyLen: event.snapshot.history.length,        cwd: event.snapshot.cwd,      });      return;    }    if (event.type === "output") {      terminalDebugLog("attach:stream", { n, type: event.type, dataLen: event.data.length });      return;    }    terminalDebugLog("attach:stream", { n, type: event.type });  }, []);   const attachTerminal = useCallback(() => {    if (!selectedThread || !selectedThreadProject?.workspaceRoot) {      terminalDebugLog("attach:abort", { reason: "no-thread-or-workspace" });      return null;    }     const client = getEnvironmentClient(selectedThread.environmentId);    if (!client) {      terminalDebugLog("attach:abort", {        reason: "no-environment-client",        environmentId: selectedThread.environmentId,      });      return null;    }     const pendingLaunchTarget = {      environmentId: selectedThread.environmentId,      threadId: selectedThread.id,      terminalId,    };    const pendingLaunch = takePendingTerminalLaunch(pendingLaunchTarget);    let initialInputSent = false;     try {      const launchLocation = pendingLaunch        ? {            cwd: pendingLaunch.cwd,            worktreePath: pendingLaunch.worktreePath,          }        : resolveTerminalOpenLocation({            terminalLocation: terminalAttachLaunchHintsRef.current.terminalSummary,            activeSessionLocation: terminalAttachLaunchHintsRef.current.activeKnownSummary,            workspaceRoot: selectedThreadProject.workspaceRoot,            threadShellWorktreePath: selectedThread.worktreePath ?? null,            threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null,          });       terminalDebugLog("attach:start", {        terminalId,        threadId: selectedThread.id,        cols: lastGridSize.cols,        rows: lastGridSize.rows,        cwd: launchLocation.cwd,        worktreePath: launchLocation.worktreePath,      });       return attachTerminalSession({        environmentId: selectedThread.environmentId,        client,        terminal: {          threadId: selectedThread.id,          terminalId,          cwd: launchLocation.cwd,          worktreePath: launchLocation.worktreePath,          cols: lastGridSize.cols,          rows: lastGridSize.rows,          env: pendingLaunch?.env,          ...(pendingLaunch ? { restartIfNotRunning: true } : {}),        },        onEvent: logAttachStreamEvent,        onSnapshot: () => {          if (!pendingLaunch?.initialInput || initialInputSent) {            return;          }           initialInputSent = true;          void client.terminal.write({            threadId: selectedThread.id,            terminalId,            data: pendingLaunch.initialInput,

Lines 1–500 of 1132.