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. Load more
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,