1798 lines · 66.1 KB · TypeScript
RawDownload
import { useAtomValue } from "@effect/atom-react";import { FitAddon, Terminal, init as initGhostty, type ITheme } from "ghostty-web";import {  isAtomCommandInterrupted,  squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import {  Plus,  SquareSplitHorizontal,  SquareSplitVertical,  TerminalSquare,  Trash2,  XIcon,} from "lucide-react";import {  type ResolvedKeybindingsConfig,  type ScopedThreadRef,  type ServerTerminalStyle,  type ThreadId,} from "@t3tools/contracts";import { getTerminalLabel } from "@t3tools/shared/terminalLabels";import {  type PointerEvent as ReactPointerEvent,  type ReactNode,  type SetStateAction,  useCallback,  useEffect,  useEffectEvent,  useMemo,  useRef,  useState,} from "react";import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";import { cn } from "~/lib/utils";import { type TerminalContextSelection } from "~/lib/terminalContext";import { useOpenInPreferredEditor } from "../editorPreferences";import { shouldFocusTerminalAfterSetup } from "../terminal-focus";import {  collectWrappedTerminalLinkLine,  extractTerminalLinks,  isTerminalLinkActivation,  resolvePathLinkTarget,  resolveWrappedTerminalLinkRange,  wrappedTerminalLinkRangeIntersectsBufferLine,} from "../terminal-links";import {  isDiffToggleShortcut,  isTerminalClearShortcut,  isTerminalCloseShortcut,  isTerminalNewShortcut,  isTerminalSplitShortcut,  isTerminalSplitVerticalShortcut,  isTerminalToggleShortcut,  terminalDeleteShortcutData,  terminalNavigationShortcutData,} from "../keybindings";import {  DEFAULT_THREAD_TERMINAL_HEIGHT,  MAX_TERMINALS_PER_GROUP,  type ThreadTerminalGroup,} from "../types";import { readLocalApi } from "~/localApi";import { useAttachedTerminalSession } from "../state/terminalSessions";import { serverEnvironment } from "../state/server";import { previewEnvironment } from "../state/preview";import { terminalEnvironment } from "../state/terminal";import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";import { useAtomCommand } from "../state/use-atom-command"; const MIN_DRAWER_HEIGHT = 180;const MAX_DRAWER_HEIGHT_RATIO = 0.75;const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; // ghostty-web needs its WASM module loaded once before any Terminal is constructed.let ghosttyReadyPromise: Promise<void> | null = null;function ensureGhosttyReady(): Promise<void> {  // Drop a failed init from the cache so a later mount can retry instead of  // reusing the rejected promise until the page reloads.  ghosttyReadyPromise ??= initGhostty().catch((error: unknown) => {    ghosttyReadyPromise = null;    throw error;  });  return ghosttyReadyPromise;} // ghostty-web registers built-in OSC8/URL link providers on open() whose activate()// calls window.open directly, bypassing the app's preview/editor flow. There is no// public API to remove them, so clear the provider list before installing ours,// which covers the same URL schemes and routes them through the app.function clearBuiltInLinkProviders(terminal: Terminal): void {  const detector = (terminal as unknown as { linkDetector?: { providers?: unknown[] } })    .linkDetector;  detector?.providers?.splice(0);} function isScrolledToBottom(terminal: Terminal): boolean {  // ghostty-web viewportY counts lines scrolled back from the bottom (0 = bottom),  // unlike xterm.js where viewportY >= baseY meant "at bottom".  return terminal.getViewportY() <= 0;} 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 writeTerminalBuffer(terminal: Terminal, buffer: string): void {  terminal.write("\u001bc");  if (buffer.length > 0) {    terminal.write(buffer);  }} const TERMINAL_SCROLLBACK_LIMIT = 5_000; // ghostty-web snaps the viewport to the bottom on every write, unlike// xterm.js which keeps a scrolled-back viewport anchored. Restore the anchor// by advancing viewportY by however many lines the write pushed into// scrollback (viewportY counts lines back from the bottom).function writePreservingScrollback(terminal: Terminal, data: string): void {  const viewportY = terminal.getViewportY();  if (viewportY <= 0) {    terminal.write(data);    return;  }  const scrollbackBefore = terminal.getScrollbackLength();  terminal.write(data);  const scrollbackAfter = terminal.getScrollbackLength();  const measuredGrowth = scrollbackAfter - scrollbackBefore;  // At the scrollback limit the length stops growing even though each new  // line still evicts the oldest, so measured growth alone would let the  // anchor drift toward the live tail. Fall back to counting written  // newlines — approximate (ignores soft wraps) but drift-free.  const scrollbackAdded =    scrollbackAfter >= TERMINAL_SCROLLBACK_LIMIT      ? Math.max(measuredGrowth, data.split("\n").length - 1)      : measuredGrowth;  terminal.scrollToLine(Math.round(viewportY) + scrollbackAdded);} function fitTerminalSafely(fitAddon: FitAddon): boolean {  try {    fitAddon.fit();    return true;  } catch {    return false;  }} function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string {  if (!runtimeEnv) return "";  return JSON.stringify(    Object.entries(runtimeEnv)      .filter(([key, value]) => key.length > 0 && typeof value === "string")      .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)),  );} 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)",      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)",    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)",  };} const TERMINAL_PALETTE_KEYS = [  "black",  "red",  "green",  "yellow",  "blue",  "magenta",  "cyan",  "white",  "brightBlack",  "brightRed",  "brightGreen",  "brightYellow",  "brightBlue",  "brightMagenta",  "brightCyan",  "brightWhite",] as const; // Nerd Font fallbacks resolve private-use-area glyphs (powerline segments,// devicons) from whichever patched font is installed locally. Ghostty embeds// its own symbols fallback, but the browser canvas only walks this list.const NERD_FONT_FALLBACK_STACK =  '"Symbols Nerd Font Mono", "Symbols Nerd Font", "MesloLGS NF", "FiraCode Nerd Font Mono", "JetBrainsMono Nerd Font Mono", "Hack Nerd Font Mono"';const DEFAULT_TERMINAL_FONT_STACK = `"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, ${NERD_FONT_FALLBACK_STACK}, monospace`;const DEFAULT_TERMINAL_FONT_SIZE = 12; const CSS_GENERIC_FONT_FAMILIES = new Set([  "serif",  "sans-serif",  "monospace",  "cursive",  "fantasy",  "system-ui",  "ui-serif",  "ui-sans-serif",  "ui-monospace",  "ui-rounded",]); function quoteFontFamily(family: string): string {  // Generic families must stay unquoted; quoting turns them into literal font  // name lookups (e.g. a font named "monospace") instead of CSS keywords.  if (CSS_GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return family;  return family.includes('"') ? family : `"${family}"`;} function resolveTerminalFontFamily(style: ServerTerminalStyle | undefined): string {  const configured = (style?.fontFamily ?? []).map(quoteFontFamily);  return configured.length > 0    ? `${configured.join(", ")}, ${DEFAULT_TERMINAL_FONT_STACK}`    : DEFAULT_TERMINAL_FONT_STACK;} /** * App theme colors, overridden by the user's Ghostty theme when the server * found one. Ghostty light/dark variants follow the app's active mode. */function resolveTerminalTheme(  mountElement: HTMLElement | null | undefined,  style: ServerTerminalStyle | undefined,): ITheme {  const base = terminalThemeFromApp(mountElement);  const isDark = document.documentElement.classList.contains("dark");  const colors = isDark ? (style?.dark ?? style?.light) : (style?.light ?? style?.dark);  if (!colors) return base;   const theme: ITheme = { ...base };  if (colors.background) theme.background = colors.background;  if (colors.foreground) theme.foreground = colors.foreground;  if (colors.cursor) theme.cursor = colors.cursor;  if (colors.selectionBackground) theme.selectionBackground = colors.selectionBackground;  if (colors.selectionForeground) theme.selectionForeground = colors.selectionForeground;  colors.palette?.forEach((hex, index) => {    const key = TERMINAL_PALETTE_KEYS[index];    if (hex && key) {      theme[key] = hex;    }  });  return theme;} 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;} interface TerminalLaunchLocation {  readonly cwd: string;  readonly worktreePath?: string | null;  readonly runtimeEnv?: Record<string, string>;} 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 serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));  const terminalStyle = serverConfig?.terminalStyle;  const terminalStyleKey = JSON.stringify(terminalStyle ?? null);  const readTerminalStyle = useEffectEvent(() => terminalStyle);  const openInPreferredEditor = useOpenInPreferredEditor(    environmentId,    serverConfig?.availableEditors ?? [],  );  const openTerminalPath = useEffectEvent((target: string) => openInPreferredEditor(target));  const openPreview = useAtomCommand(previewEnvironment.open, {    reportFailure: false,  });  const runTerminalWrite = useAtomCommand(terminalEnvironment.write, {    reportFailure: false,  });  const runTerminalResize = useAtomCommand(terminalEnvironment.resize, {    reportFailure: false,  });  const hasHandledExitRef = useRef(false);  const hasMountedTerminalRef = useRef(false);  const restoreTerminalFocusOnSetupRef = useRef(false);  const focusOnInitialSessionSyncRef = useRef(false);  // Bumped when the async WASM gate finishes creating a terminal, so the  // session sync effect re-runs against the live terminal even when no  // session field changed while the WASM module was loading.  const [terminalEpoch, setTerminalEpoch] = useState(0);  const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);  const selectionGestureActiveRef = useRef(false);  const selectionActionRequestIdRef = useRef(0);  const selectionActionMenuOpenRef = useRef(false);  const selectionActionTimerRef = useRef<number | null>(null);  const keybindingsRef = useRef(keybindings);  const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]);  const handleSessionExited = useEffectEvent(() => {    onSessionExited();  });  const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => {    onAddTerminalContext(selection);  });  const readTerminalLabel = useEffectEvent(() => terminalLabel);  const readAutoFocus = useEffectEvent(() => autoFocus);  const terminalSession = useAttachedTerminalSession({    environmentId,    terminal: {      threadId,      terminalId,      cwd,      ...(worktreePath !== undefined ? { worktreePath } : {}),      ...(runtimeEnv ? { env: runtimeEnv } : {}),    },  });  const writeTerminal = useEffectEvent((data: string) =>    runTerminalWrite({      environmentId,

Lines 1–500 of 1798.