import { useAtomValue } from "@effect/atom-react";import { isAtomCommandInterrupted, squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal";import { Plus, Square, SquareSplitHorizontal, SquareSplitVertical, TerminalSquare, Trash2,} from "lucide-react";import { type ContextMenuItem, 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, 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 { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";import { cn } from "~/lib/utils";import { type TerminalContextSelection } from "~/lib/terminalContext";import { GhosttyTerminalSurface, type GhosttyTerminalSurfaceOptions,} from "~/terminal/ghostty/surface";import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core";import { useOpenInPreferredEditor } from "../editorPreferences";import { isTerminalLinkActivation, 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;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: GhosttyTerminalSurface, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`);} function writeTerminalBuffer(terminal: GhosttyTerminalSurface, buffer: string): void { terminal.resetAndWrite(buffer);} 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 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 themeStyles = 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", background); const terminalForeground = readThemeColor(themeStyles, "--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 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;} 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: just the two selection actions, always enabled. */export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { return [ { id: "add-to-chat", label: "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;}): ContextMenuItem<TerminalContextMenuAction>[] { return [ ...terminalSelectionMenuItems().map((item) => ({ ...item, disabled: !options.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: { timerPending: boolean; openMenuRequestId: number | null; currentRequestId: number;}): boolean { return options.timerPending || 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>; 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({ advancedTypography, threadRef, threadId, terminalId, terminalLabel, cwd, worktreePath, runtimeEnv, onSessionExited, onAddTerminalContext, focusRequestId, autoFocus, resizeEpoch, drawerHeight, keybindings,}: TerminalViewportProps) { const containerRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<GhosttyTerminalSurface | null>(null); 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 selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = 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 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 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 } : {}), }, }); 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 terminalBuffer = terminalSession.buffer; const terminalError = terminalSession.error; const terminalStatus = terminalSession.status; 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({ buffer: terminalBuffer, error: terminalError, status: terminalStatus, version: terminalVersion, }); const latestSessionRef = useRef(previousSessionRef.current); latestSessionRef.current = { buffer: terminalBuffer, error: terminalError, status: terminalStatus, version: terminalVersion, }; useEffect(() => { keybindingsRef.current = keybindings; }, [keybindings]); 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> = []; const setup = async (): Promise<(() => void) | null> => { const setupFont = terminalFontRef.current; const terminalOptions: GhosttyTerminalSurfaceOptions = { theme: terminalThemeFromApp(mount), font: terminalFontOptions(setupFont.family, setupFont.size), onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), // The surface listens from construction, so a right-click can land // while `create` is still awaiting WASM — before the handler below it // exists. The ref is only assigned once that setup has run. onContextMenu: (event) => { if (terminalRef.current) void showTerminalContextMenu(event); },Lines 1–500 of 1710. Load more
import { useAtomValue } from "@effect/atom-react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; import { Plus, Square, SquareSplitHorizontal, SquareSplitVertical, TerminalSquare, Trash2, } from "lucide-react"; import { type ContextMenuItem, 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, 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 { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { GhosttyTerminalSurface, type GhosttyTerminalSurfaceOptions, } from "~/terminal/ghostty/surface"; import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { isTerminalLinkActivation, 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; 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: GhosttyTerminalSurface, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } function writeTerminalBuffer(terminal: GhosttyTerminalSurface, buffer: string): void { terminal.resetAndWrite(buffer); } 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 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 themeStyles = 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", background); const terminalForeground = readThemeColor(themeStyles, "--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 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; } 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: just the two selection actions, always enabled. */ export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { return [ { id: "add-to-chat", label: "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; }): ContextMenuItem<TerminalContextMenuAction>[] { return [ ...terminalSelectionMenuItems().map((item) => ({ ...item, disabled: !options.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: { timerPending: boolean; openMenuRequestId: number | null; currentRequestId: number; }): boolean { return options.timerPending || 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>; 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({ advancedTypography, threadRef, threadId, terminalId, terminalLabel, cwd, worktreePath, runtimeEnv, onSessionExited, onAddTerminalContext, focusRequestId, autoFocus, resizeEpoch, drawerHeight, keybindings, }: TerminalViewportProps) { const containerRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<GhosttyTerminalSurface | null>(null); 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 selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = 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 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 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 } : {}), }, }); 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 terminalBuffer = terminalSession.buffer; const terminalError = terminalSession.error; const terminalStatus = terminalSession.status; 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({ buffer: terminalBuffer, error: terminalError, status: terminalStatus, version: terminalVersion, }); const latestSessionRef = useRef(previousSessionRef.current); latestSessionRef.current = { buffer: terminalBuffer, error: terminalError, status: terminalStatus, version: terminalVersion, }; useEffect(() => { keybindingsRef.current = keybindings; }, [keybindings]); 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> = []; const setup = async (): Promise<(() => void) | null> => { const setupFont = terminalFontRef.current; const terminalOptions: GhosttyTerminalSurfaceOptions = { theme: terminalThemeFromApp(mount), font: terminalFontOptions(setupFont.family, setupFont.size), onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), // The surface listens from construction, so a right-click can land // while `create` is still awaiting WASM — before the handler below it // exists. The ref is only assigned once that setup has run. onContextMenu: (event) => { if (terminalRef.current) void showTerminalContextMenu(event); },