1714 lines · 63.7 KB · TypeScript
RawDownload
import { useAtomValue } from "@effect/atom-react";import {  isAtomCommandInterrupted,  squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import {  INITIAL_TERMINAL_OUTPUT_CURSOR,  readTerminalOutputUpdate,  type TerminalOutputCursor,  type TerminalOutputUpdate,  type TerminalSessionState,} from "@t3tools/client-runtime/state/terminal";import {  Plus,  Square,  SquareSplitHorizontal,  SquareSplitVertical,  TerminalSquare,  Trash2,} from "lucide-react";import {  type ContextMenuItem,  type ProviderInstanceId,  type ResolvedKeybindingsConfig,  type ScopedThreadRef,  type ThreadId,} from "@t3tools/contracts";import { getTerminalLabel } from "@t3tools/shared/terminalLabels";import * as Schema from "effect/Schema";import {  type PointerEvent as ReactPointerEvent,  type ReactNode,  type SetStateAction,  useCallback,  useEffect,  useEffectEvent,  useLayoutEffect,  useMemo,  useRef,  useState,} from "react";import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";import { Button } from "~/components/ui/button";import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";import { stackedThreadToast, toastManager } from "~/components/ui/toast";import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";import { cn } from "~/lib/utils";import { type TerminalContextSelection } from "~/lib/terminalContext";import {  observeSelectionActions,  resolveSelectionActionPosition,  type SelectionActionPoint,} from "~/lib/selectionActions";import {  GhosttyTerminalSurface,  type GhosttyTerminalSurfaceOptions,} from "~/terminal/ghostty/surface";import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core";import { useOpenInPreferredEditor } from "../editorPreferences";import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links";import {  isDiffToggleShortcut,  isTerminalClearShortcut,  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 { confirmTerminalClose } from "~/lib/terminalCloseConfirm";import { useClientSettings } from "../hooks/useSettings";import { useLocalStorage } from "../hooks/useLocalStorage";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";import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";import {  resolveTerminalFontPreference,  resolveTerminalFontSizePreference,  TYPOGRAPHY_ADVANCED_STORAGE_KEY,} from "../appearanceFonts"; const MIN_DRAWER_HEIGHT = 180;const MAX_DRAWER_HEIGHT_RATIO = 0.75; 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: GhosttyTerminalSurface, message: string): void {  terminal.write(`\r\n[terminal] ${message}\r\n`);} export function writeTerminalOutputUpdate(  terminal: Pick<GhosttyTerminalSurface, "resetAndWrite" | "write">,  update: TerminalOutputUpdate,): void {  if (update.type === "reset") {    terminal.resetAndWrite(update.data);  } else if (update.type === "append") {    terminal.write(update.data);  }} function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor {  if (typeof document === "undefined") return fallback;   const canvas = document.createElement("canvas");  canvas.width = 1;  canvas.height = 1;  const context = canvas.getContext("2d", { willReadFrequently: true });  if (!context) return fallback;   context.clearRect(0, 0, 1, 1);  context.fillStyle = value;  context.fillRect(0, 0, 1, 1);  const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;  if (alpha === 0) return fallback;   return {    r: red ?? fallback.r,    g: green ?? fallback.g,    b: blue ?? fallback.b,  };} 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 readThemeColor(styles: CSSStyleDeclaration, variable: string, fallback: string): string {  return normalizeComputedColor(styles.getPropertyValue(variable), fallback);} /** The surface treats an omitted family or size as "use the built-in default". */function terminalFontOptions(family: string, size: number): { family?: string; size: number } {  const trimmed = family.trim();  return trimmed.length > 0 ? { family: trimmed, size } : { size };} export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme {  const drawerSurface =    mountElement?.closest(".thread-terminal-drawer") ??    document.querySelector(".thread-terminal-drawer") ??    document.body;  const drawerStyles = getComputedStyle(drawerSurface);  const themeStyles = mountElement ? getComputedStyle(mountElement) : drawerStyles;  const colorScheme = themeStyles.colorScheme;  const isDark =    colorScheme === "dark"      ? true      : colorScheme === "light"        ? false        : 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 bodyStyles = getComputedStyle(document.body);  const rootThemeStyles = getComputedStyle(document.documentElement);  const background = normalizeComputedColor(    drawerStyles.backgroundColor,    normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground),  );  const foreground = normalizeComputedColor(    drawerStyles.color,    normalizeComputedColor(bodyStyles.color, fallbackForeground),  );  const terminalBackground = readThemeColor(    themeStyles,    "--terminal-background",    readThemeColor(rootThemeStyles, "--terminal-background", background),  );  const terminalForeground = readThemeColor(    themeStyles,    "--terminal-foreground",    readThemeColor(rootThemeStyles, "--terminal-foreground", foreground),  );  const terminalCursor = readThemeColor(    themeStyles,    "--terminal-cursor",    isDark ? "rgb(180, 203, 255)" : "rgb(38, 56, 78)",  );  const terminalSelection = readThemeColor(    themeStyles,    "--terminal-selection-background",    isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)",  );  return {    background: parseTerminalColor(      terminalBackground,      isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 },    ),    foreground: parseTerminalColor(      terminalForeground,      isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 },    ),    cursor: parseTerminalColor(      terminalCursor,      isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 },    ),    selectionBackground: terminalSelection,  };} export function terminalSelectionLineRange(position: {  start: { y: number };  end: { y: number };}): { lineStart: number; lineEnd: number } {  const lineStart = position.start.y + 1;  return {    lineStart,    lineEnd: Math.max(lineStart, position.end.y + 1),  };} export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; /** Post-selection popup: available selection actions, always enabled. */export function terminalSelectionMenuItems(options?: {  canAddToChat?: boolean;}): ContextMenuItem<"add-to-chat" | "copy">[] {  return [    ...(options?.canAddToChat === false      ? []      : ([{ id: "add-to-chat", label: "Add to chat" }] satisfies ContextMenuItem<"add-to-chat">[])),    { id: "copy", label: "Copy" },  ];} /** * Right-click menu for the terminal canvas: the selection actions (disabled * until a selection exists) plus Paste. Paste is always offered: the browser * (and Electron's default editing menu) can only paste into an editable * element, so a canvas terminal never gets a usable entry from them. */export function terminalContextMenuItems(options: {  hasSelection: boolean;  canAddToChat?: boolean;}): ContextMenuItem<TerminalContextMenuAction>[] {  const { hasSelection, canAddToChat = true } = options;  return [    ...terminalSelectionMenuItems({ canAddToChat }).map((item) => ({      ...item,      disabled: !hasSelection,    })),    { id: "paste", label: "Paste" },  ];} /** * An empty selection change may only cancel a selection-action flow that is * still current: a pending popup timer, or an open popup whose request id has * not been superseded. A popup already superseded by a right-click keeps its * menu promise unsettled for a moment; treating it as active would cancel the * newer context-menu flow instead. */export function shouldClearTerminalSelectionAction(options: {  actionPending: boolean;  openMenuRequestId: number | null;  currentRequestId: number;}): boolean {  return options.actionPending || options.openMenuRequestId === options.currentRequestId;} export function shouldHandleTerminalExit(  current: TerminalSessionState["status"],  synchronized: TerminalSessionState["status"],  alreadyHandled: boolean,): boolean {  return (    (current === "closed" || current === "exited") && current !== synchronized && !alreadyHandled  );} interface TerminalViewportProps {  advancedTypography: boolean;  threadRef: ScopedThreadRef;  threadId: ThreadId;  terminalId: string;  terminalLabel: string;  cwd: string;  worktreePath?: string | null;  runtimeEnv?: Record<string, string>;  providerInstanceId?: ProviderInstanceId;  onSessionExited: () => void;  onAddTerminalContext?: (selection: TerminalContextSelection) => void;  focusRequestId: number;  autoFocus: boolean;  visible: boolean;  resizeEpoch: number;  drawerHeight: number;  keybindings: ResolvedKeybindingsConfig;} interface TerminalLaunchLocation {  readonly cwd: string;  readonly worktreePath?: string | null;  readonly runtimeEnv?: Record<string, string>;} export function TerminalViewport({  advancedTypography,  threadRef,  threadId,  terminalId,  terminalLabel,  cwd,  worktreePath,  runtimeEnv,  providerInstanceId,  onSessionExited,  onAddTerminalContext,  focusRequestId,  autoFocus,  visible,  resizeEpoch,  drawerHeight,  keybindings,}: TerminalViewportProps) {  const containerRef = useRef<HTMLDivElement>(null);  const terminalRef = useRef<GhosttyTerminalSurface | null>(null);  const visibleRef = useRef(visible);  const environmentId = threadRef.environmentId;  const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));  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 selectionActionRequestIdRef = useRef(0);  // Holds the request id of the selection popup currently on screen, so a  // popup that was superseded (but whose menu promise has not settled yet)  // cannot be mistaken for the active flow.  const openSelectionMenuRequestIdRef = 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 canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined);  const readTerminalLabel = useEffectEvent(() => terminalLabel);  const terminalFontFamily = useClientSettings((settings) =>    resolveTerminalFontPreference({      advanced: advancedTypography,      code: settings.fontFamilyCode,      terminal: settings.fontFamilyTerminal,    }),  );  const terminalFontSize = useClientSettings((settings) =>    resolveTerminalFontSizePreference({      advanced: advancedTypography,      code: settings.fontSizeCode,      terminal: settings.fontSizeTerminal,    }),  );  const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize });  const terminalSession = useAttachedTerminalSession({    environmentId,    terminal: {      threadId,      terminalId,      cwd,      ...(worktreePath !== undefined ? { worktreePath } : {}),      ...(runtimeEnv ? { env: runtimeEnv } : {}),      ...(providerInstanceId ? { providerInstanceId } : {}),    },  });  const writeTerminal = useEffectEvent((data: string) =>    runTerminalWrite({      environmentId,      input: { threadId, terminalId, data },    }),  );  const resizeTerminal = useEffectEvent((cols: number, rows: number) =>    runTerminalResize({      environmentId,      input: { threadId, terminalId, cols, rows },    }),  );  const terminalOutput = terminalSession.output;  const terminalError = terminalSession.error;  const terminalStatus = terminalSession.status;  const outputCursorRef = useRef<TerminalOutputCursor>(INITIAL_TERMINAL_OUTPUT_CURSOR);  const synchronizedStatusRef = useRef<TerminalSessionState["status"]>("closed");  const synchronizeTerminalStatus = useEffectEvent(    (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"]) => {      const synchronized = synchronizedStatusRef.current;      if (status === "running") {        hasHandledExitRef.current = false;      } else if (shouldHandleTerminalExit(status, synchronized, hasHandledExitRef.current)) {        hasHandledExitRef.current = true;        writeSystemMessage(terminal, status === "closed" ? "Terminal closed" : "Process exited");        window.setTimeout(() => {          if (hasHandledExitRef.current) {            handleSessionExited();          }        }, 0);      }      synchronizedStatusRef.current = status;    },  );  const terminalVersion = terminalSession.version;  const previousSessionRef = useRef({    output: terminalOutput,    error: terminalError,    status: terminalStatus,    version: terminalVersion,  });  const latestSessionRef = useRef(previousSessionRef.current);  latestSessionRef.current = {    output: terminalOutput,    error: terminalError,    status: terminalStatus,    version: terminalVersion,  };   useEffect(() => {    keybindingsRef.current = keybindings;  }, [keybindings]);   useLayoutEffect(() => {    visibleRef.current = visible;    terminalRef.current?.setVisible(visible);  }, [visible]);   useEffect(() => {    const current = terminalFontRef.current;    if (current.family === terminalFontFamily && current.size === terminalFontSize) return;    terminalFontRef.current = { family: terminalFontFamily, size: terminalFontSize };    void terminalRef.current?.setFont(terminalFontOptions(terminalFontFamily, terminalFontSize));  }, [terminalFontFamily, terminalFontSize]);   useEffect(() => {    const mount = containerRef.current;    if (!mount) return;     const localApi = readLocalApi();    let cancelled = false;    let teardown: (() => void) | null = null;    let setupTerminal: GhosttyTerminalSurface | null = null;    let setupCleanups: Array<() => void> = [];    let selectionActions: ReturnType<typeof observeSelectionActions> | null = null;     const setup = async (): Promise<(() => void) | null> => {      const setupFont = terminalFontRef.current;      const terminalOptions: GhosttyTerminalSurfaceOptions = {        theme: terminalThemeFromApp(mount),        font: terminalFontOptions(setupFont.family, setupFont.size),        get visible() {          return visibleRef.current;        },        onData: (data) => handleData(data),        onResize: (cols, rows) => void resizeTerminal(cols, rows),

Lines 1–500 of 1714.