1353 lines · 47.4 KB · TypeScript
RawDownload
import { FitAddon } from "@xterm/addon-fit";import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";import {  type ResolvedKeybindingsConfig,  type ScopedThreadRef,  type TerminalEvent,  type TerminalSessionSnapshot,  type ThreadId,} from "@t3tools/contracts";import { Terminal, type ITheme } from "@xterm/xterm";import {  type PointerEvent as ReactPointerEvent,  type ReactNode,  useCallback,  useEffect,  useEffectEvent,  useMemo,  useRef,  useState,} from "react";import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";import { type TerminalContextSelection } from "~/lib/terminalContext";import { openInPreferredEditor } from "../editorPreferences";import {  collectWrappedTerminalLinkLine,  extractTerminalLinks,  isTerminalLinkActivation,  resolvePathLinkTarget,  resolveWrappedTerminalLinkRange,  wrappedTerminalLinkRangeIntersectsBufferLine,} from "../terminal-links";import {  isDiffToggleShortcut,  isTerminalClearShortcut,  isTerminalCloseShortcut,  isTerminalNewShortcut,  isTerminalSplitShortcut,  isTerminalToggleShortcut,  terminalDeleteShortcutData,  terminalNavigationShortcutData,} from "../keybindings";import {  DEFAULT_THREAD_TERMINAL_HEIGHT,  DEFAULT_THREAD_TERMINAL_ID,  MAX_TERMINALS_PER_GROUP,  type ThreadTerminalGroup,} from "../types";import { readEnvironmentApi } from "~/environmentApi";import { readLocalApi } from "~/localApi";import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; const MIN_DRAWER_HEIGHT = 180;const MAX_DRAWER_HEIGHT_RATIO = 0.75;const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; function maxDrawerHeight(): number {  if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;  return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO));} function clampDrawerHeight(height: number): number {  const safeHeight = Number.isFinite(height) ? height : DEFAULT_THREAD_TERMINAL_HEIGHT;  const maxHeight = maxDrawerHeight();  return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight);} function writeSystemMessage(terminal: Terminal, message: string): void {  terminal.write(`\r\n[terminal] ${message}\r\n`);} function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnapshot): void {  terminal.write("\u001bc");  if (snapshot.history.length > 0) {    terminal.write(snapshot.history);  }} export function selectTerminalEventEntriesAfterSnapshot(  entries: ReadonlyArray<{ id: number; event: TerminalEvent }>,  snapshotUpdatedAt: string,): ReadonlyArray<{ id: number; event: TerminalEvent }> {  return entries.filter((entry) => entry.event.createdAt > snapshotUpdatedAt);} export function selectPendingTerminalEventEntries(  entries: ReadonlyArray<{ id: number; event: TerminalEvent }>,  lastAppliedTerminalEventId: number,): ReadonlyArray<{ id: number; event: TerminalEvent }> {  return entries.filter((entry) => entry.id > lastAppliedTerminalEventId);} function normalizeComputedColor(value: string | null | undefined, fallback: string): string {  const normalizedValue = value?.trim().toLowerCase();  if (    !normalizedValue ||    normalizedValue === "transparent" ||    normalizedValue === "rgba(0, 0, 0, 0)" ||    normalizedValue === "rgba(0 0 0 / 0)"  ) {    return fallback;  }  return value ?? fallback;} function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme {  const isDark = document.documentElement.classList.contains("dark");  const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)";  const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)";  const drawerSurface =    mountElement?.closest(".thread-terminal-drawer") ??    document.querySelector(".thread-terminal-drawer") ??    document.body;  const drawerStyles = getComputedStyle(drawerSurface);  const bodyStyles = getComputedStyle(document.body);  const background = normalizeComputedColor(    drawerStyles.backgroundColor,    normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground),  );  const foreground = normalizeComputedColor(    drawerStyles.color,    normalizeComputedColor(bodyStyles.color, fallbackForeground),  );   if (isDark) {    return {      background,      foreground,      cursor: "rgb(180, 203, 255)",      selectionBackground: "rgba(180, 203, 255, 0.25)",      scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)",      scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)",      scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)",      black: "rgb(24, 30, 38)",      red: "rgb(255, 122, 142)",      green: "rgb(134, 231, 149)",      yellow: "rgb(244, 205, 114)",      blue: "rgb(137, 190, 255)",      magenta: "rgb(208, 176, 255)",      cyan: "rgb(124, 232, 237)",      white: "rgb(210, 218, 230)",      brightBlack: "rgb(110, 120, 136)",      brightRed: "rgb(255, 168, 180)",      brightGreen: "rgb(176, 245, 186)",      brightYellow: "rgb(255, 224, 149)",      brightBlue: "rgb(174, 210, 255)",      brightMagenta: "rgb(229, 203, 255)",      brightCyan: "rgb(167, 244, 247)",      brightWhite: "rgb(244, 247, 252)",    };  }   return {    background,    foreground,    cursor: "rgb(38, 56, 78)",    selectionBackground: "rgba(37, 63, 99, 0.2)",    scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)",    scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)",    scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)",    black: "rgb(44, 53, 66)",    red: "rgb(191, 70, 87)",    green: "rgb(60, 126, 86)",    yellow: "rgb(146, 112, 35)",    blue: "rgb(72, 102, 163)",    magenta: "rgb(132, 86, 149)",    cyan: "rgb(53, 127, 141)",    white: "rgb(210, 215, 223)",    brightBlack: "rgb(112, 123, 140)",    brightRed: "rgb(212, 95, 112)",    brightGreen: "rgb(85, 148, 111)",    brightYellow: "rgb(173, 133, 45)",    brightBlue: "rgb(91, 124, 194)",    brightMagenta: "rgb(153, 107, 172)",    brightCyan: "rgb(70, 149, 164)",    brightWhite: "rgb(236, 240, 246)",  };} function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null {  const selection = window.getSelection();  if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {    return null;  }   const range = selection.getRangeAt(0);  const commonAncestor = range.commonAncestorContainer;  const selectionRoot =    commonAncestor instanceof Element ? commonAncestor : commonAncestor.parentElement;  if (!(selectionRoot instanceof Element) || !mountElement.contains(selectionRoot)) {    return null;  }   const rects = Array.from(range.getClientRects()).filter(    (rect) => rect.width > 0 || rect.height > 0,  );  if (rects.length > 0) {    return rects[rects.length - 1] ?? null;  }   const boundingRect = range.getBoundingClientRect();  return boundingRect.width > 0 || boundingRect.height > 0 ? boundingRect : null;} export function resolveTerminalSelectionActionPosition(options: {  bounds: { left: number; top: number; width: number; height: number };  selectionRect: { right: number; bottom: number } | null;  pointer: { x: number; y: number } | null;  viewport?: { width: number; height: number } | null;}): { x: number; y: number } {  const { bounds, selectionRect, pointer, viewport } = options;  const viewportWidth =    viewport?.width ??    (typeof window === "undefined" ? bounds.left + bounds.width + 8 : window.innerWidth);  const viewportHeight =    viewport?.height ??    (typeof window === "undefined" ? bounds.top + bounds.height + 8 : window.innerHeight);  const drawerLeft = Math.round(bounds.left);  const drawerTop = Math.round(bounds.top);  const drawerRight = Math.round(bounds.left + bounds.width);  const drawerBottom = Math.round(bounds.top + bounds.height);  const preferredX =    selectionRect !== null      ? Math.round(selectionRect.right)      : pointer === null        ? Math.round(bounds.left + bounds.width - 140)        : Math.max(drawerLeft, Math.min(Math.round(pointer.x), drawerRight));  const preferredY =    selectionRect !== null      ? Math.round(selectionRect.bottom + 4)      : pointer === null        ? Math.round(bounds.top + 12)        : Math.max(drawerTop, Math.min(Math.round(pointer.y), drawerBottom));  return {    x: Math.max(8, Math.min(preferredX, Math.max(viewportWidth - 8, 8))),    y: Math.max(8, Math.min(preferredY, Math.max(viewportHeight - 8, 8))),  };} export function terminalSelectionActionDelayForClickCount(clickCount: number): number {  return clickCount >= 2 ? MULTI_CLICK_SELECTION_ACTION_DELAY_MS : 0;} export function shouldHandleTerminalSelectionMouseUp(  selectionGestureActive: boolean,  button: number,): boolean {  return selectionGestureActive && button === 0;} interface TerminalViewportProps {  threadRef: ScopedThreadRef;  threadId: ThreadId;  terminalId: string;  terminalLabel: string;  cwd: string;  worktreePath?: string | null;  runtimeEnv?: Record<string, string>;  onSessionExited: () => void;  onAddTerminalContext: (selection: TerminalContextSelection) => void;  focusRequestId: number;  autoFocus: boolean;  resizeEpoch: number;  drawerHeight: number;  keybindings: ResolvedKeybindingsConfig;} export function TerminalViewport({  threadRef,  threadId,  terminalId,  terminalLabel,  cwd,  worktreePath,  runtimeEnv,  onSessionExited,  onAddTerminalContext,  focusRequestId,  autoFocus,  resizeEpoch,  drawerHeight,  keybindings,}: TerminalViewportProps) {  const containerRef = useRef<HTMLDivElement>(null);  const terminalRef = useRef<Terminal | null>(null);  const fitAddonRef = useRef<FitAddon | null>(null);  const environmentId = threadRef.environmentId;  const hasHandledExitRef = useRef(false);  const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);  const selectionGestureActiveRef = useRef(false);  const selectionActionRequestIdRef = useRef(0);  const selectionActionOpenRef = useRef(false);  const selectionActionTimerRef = useRef<number | null>(null);  const keybindingsRef = useRef(keybindings);  const lastAppliedTerminalEventIdRef = useRef(0);  const terminalHydratedRef = useRef(false);  const handleSessionExited = useEffectEvent(() => {    onSessionExited();  });  const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => {    onAddTerminalContext(selection);  });  const readTerminalLabel = useEffectEvent(() => terminalLabel);   useEffect(() => {    keybindingsRef.current = keybindings;  }, [keybindings]);   useEffect(() => {    const mount = containerRef.current;    if (!mount) return;     let disposed = false;    const api = readEnvironmentApi(environmentId);    const localApi = readLocalApi();    if (!api || !localApi) return;     const fitAddon = new FitAddon();    const terminal = new Terminal({      cursorBlink: true,      lineHeight: 1.2,      fontSize: 12,      scrollback: 5_000,      fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',      theme: terminalThemeFromApp(mount),    });    terminal.loadAddon(fitAddon);    terminal.open(mount);    fitAddon.fit();     terminalRef.current = terminal;    fitAddonRef.current = fitAddon;     const clearSelectionAction = () => {      selectionActionRequestIdRef.current += 1;      if (selectionActionTimerRef.current !== null) {        window.clearTimeout(selectionActionTimerRef.current);        selectionActionTimerRef.current = null;      }    };     const readSelectionAction = (): {      position: { x: number; y: number };      selection: TerminalContextSelection;    } | null => {      const activeTerminal = terminalRef.current;      const mountElement = containerRef.current;      if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) {        return null;      }      const selectionText = activeTerminal.getSelection();      const selectionPosition = activeTerminal.getSelectionPosition();      const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");      if (!selectionPosition || normalizedText.length === 0) {        return null;      }      const lineStart = selectionPosition.start.y + 1;      const lineCount = normalizedText.split("\n").length;      const lineEnd = Math.max(lineStart, lineStart + lineCount - 1);      const bounds = mountElement.getBoundingClientRect();      const selectionRect = getTerminalSelectionRect(mountElement);      const position = resolveTerminalSelectionActionPosition({        bounds,        selectionRect:          selectionRect === null            ? null            : { right: selectionRect.right, bottom: selectionRect.bottom },        pointer: selectionPointerRef.current,      });      return {        position,        selection: {          terminalId,          terminalLabel: readTerminalLabel(),          lineStart,          lineEnd,          text: normalizedText,        },      };    };     const showSelectionAction = async () => {      if (selectionActionOpenRef.current) {        return;      }      const nextAction = readSelectionAction();      if (!nextAction) {        clearSelectionAction();        return;      }      const requestId = ++selectionActionRequestIdRef.current;      selectionActionOpenRef.current = true;      try {        const clicked = await localApi.contextMenu.show(          [{ id: "add-to-chat", label: "Add to chat" }],          nextAction.position,        );        if (requestId !== selectionActionRequestIdRef.current || clicked !== "add-to-chat") {          return;        }        handleAddTerminalContext(nextAction.selection);        terminalRef.current?.clearSelection();        terminalRef.current?.focus();      } finally {        selectionActionOpenRef.current = false;      }    };     const sendTerminalInput = async (data: string, fallbackError: string) => {      const activeTerminal = terminalRef.current;      if (!activeTerminal) return;      try {        await api.terminal.write({ threadId, terminalId, data });      } catch (error) {        writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError);      }    };     terminal.attachCustomKeyEventHandler((event) => {      const currentKeybindings = keybindingsRef.current;      const options = { context: { terminalFocus: true, terminalOpen: true } };      if (        isTerminalToggleShortcut(event, currentKeybindings, options) ||        isTerminalSplitShortcut(event, currentKeybindings, options) ||        isTerminalNewShortcut(event, currentKeybindings, options) ||        isTerminalCloseShortcut(event, currentKeybindings, options) ||        isDiffToggleShortcut(event, currentKeybindings, options)      ) {        return false;      }       const navigationData = terminalNavigationShortcutData(event);      if (navigationData !== null) {        event.preventDefault();        event.stopPropagation();        void sendTerminalInput(navigationData, "Failed to move cursor");        return false;      }       const deleteData = terminalDeleteShortcutData(event);      if (deleteData !== null) {        event.preventDefault();        event.stopPropagation();        void sendTerminalInput(deleteData, "Failed to delete terminal input");        return false;      }       if (!isTerminalClearShortcut(event)) return true;      event.preventDefault();      event.stopPropagation();      void sendTerminalInput("\u000c", "Failed to clear terminal");      return false;    });     const terminalLinksDisposable = terminal.registerLinkProvider({      provideLinks: (bufferLineNumber, callback) => {        const activeTerminal = terminalRef.current;        if (!activeTerminal) {          callback(undefined);          return;        }         const wrappedLine = collectWrappedTerminalLinkLine(bufferLineNumber, (bufferLineIndex) =>          activeTerminal.buffer.active.getLine(bufferLineIndex),        );        if (!wrappedLine) {          callback(undefined);          return;        }         const links = extractTerminalLinks(wrappedLine.text)          .map((match) => ({            match,            range: resolveWrappedTerminalLinkRange(wrappedLine, match),          }))          .filter(({ range }) =>            wrappedTerminalLinkRangeIntersectsBufferLine(range, bufferLineNumber),          );        if (links.length === 0) {          callback(undefined);          return;        }         callback(          links.map(({ match, range }) => ({            text: match.text,            range,            activate: (event: MouseEvent) => {              if (!isTerminalLinkActivation(event)) return;               const latestTerminal = terminalRef.current;              if (!latestTerminal) return;               if (match.kind === "url") {                void localApi.shell.openExternal(match.text).catch((error: unknown) => {                  writeSystemMessage(                    latestTerminal,                    error instanceof Error ? error.message : "Unable to open link",                  );                });                return;

Lines 1–500 of 1353.