import { useAtomValue } from "@effect/atom-react";import { FitAddon } from "@xterm/addon-fit";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 ThreadId,} from "@t3tools/contracts";import { getTerminalLabel } from "@t3tools/shared/terminalLabels";import { Terminal, type ITheme } from "@xterm/xterm";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 { 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; 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); }} 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)", scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)", scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)", scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)", black: "rgb(24, 30, 38)", red: "rgb(255, 122, 142)", green: "rgb(134, 231, 149)", yellow: "rgb(244, 205, 114)", blue: "rgb(137, 190, 255)", magenta: "rgb(208, 176, 255)", cyan: "rgb(124, 232, 237)", white: "rgb(210, 218, 230)", brightBlack: "rgb(110, 120, 136)", brightRed: "rgb(255, 168, 180)", brightGreen: "rgb(176, 245, 186)", brightYellow: "rgb(255, 224, 149)", brightBlue: "rgb(174, 210, 255)", brightMagenta: "rgb(229, 203, 255)", brightCyan: "rgb(167, 244, 247)", brightWhite: "rgb(244, 247, 252)", }; } return { background, foreground, cursor: "rgb(38, 56, 78)", selectionBackground: "rgba(37, 63, 99, 0.2)", scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)", scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)", scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)", black: "rgb(44, 53, 66)", red: "rgb(191, 70, 87)", green: "rgb(60, 126, 86)", yellow: "rgb(146, 112, 35)", blue: "rgb(72, 102, 163)", magenta: "rgb(132, 86, 149)", cyan: "rgb(53, 127, 141)", white: "rgb(210, 215, 223)", brightBlack: "rgb(112, 123, 140)", brightRed: "rgb(212, 95, 112)", brightGreen: "rgb(85, 148, 111)", brightYellow: "rgb(173, 133, 45)", brightBlue: "rgb(91, 124, 194)", brightMagenta: "rgb(153, 107, 172)", brightCyan: "rgb(70, 149, 164)", brightWhite: "rgb(236, 240, 246)", };} function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { return null; } const range = selection.getRangeAt(0); const commonAncestor = range.commonAncestorContainer; const selectionRoot = commonAncestor instanceof Element ? commonAncestor : commonAncestor.parentElement; if (!(selectionRoot instanceof Element) || !mountElement.contains(selectionRoot)) { return null; } const rects = Array.from(range.getClientRects()).filter( (rect) => rect.width > 0 || rect.height > 0, ); if (rects.length > 0) { return rects[rects.length - 1] ?? null; } const boundingRect = range.getBoundingClientRect(); return boundingRect.width > 0 || boundingRect.height > 0 ? boundingRect : null;} export function resolveTerminalSelectionActionPosition(options: { bounds: { left: number; top: number; width: number; height: number }; selectionRect: { right: number; bottom: number } | null; pointer: { x: number; y: number } | null; viewport?: { width: number; height: number } | null;}): { x: number; y: number } { const { bounds, selectionRect, pointer, viewport } = options; const viewportWidth = viewport?.width ?? (typeof window === "undefined" ? bounds.left + bounds.width + 8 : window.innerWidth); const viewportHeight = viewport?.height ?? (typeof window === "undefined" ? bounds.top + bounds.height + 8 : window.innerHeight); const drawerLeft = Math.round(bounds.left); const drawerTop = Math.round(bounds.top); const drawerRight = Math.round(bounds.left + bounds.width); const drawerBottom = Math.round(bounds.top + bounds.height); const preferredX = selectionRect !== null ? Math.round(selectionRect.right) : pointer === null ? Math.round(bounds.left + bounds.width - 140) : Math.max(drawerLeft, Math.min(Math.round(pointer.x), drawerRight)); const preferredY = selectionRect !== null ? Math.round(selectionRect.bottom + 4) : pointer === null ? Math.round(bounds.top + 12) : Math.max(drawerTop, Math.min(Math.round(pointer.y), drawerBottom)); return { x: Math.max(8, Math.min(preferredX, Math.max(viewportWidth - 8, 8))), y: Math.max(8, Math.min(preferredY, Math.max(viewportHeight - 8, 8))), };} export function terminalSelectionActionDelayForClickCount(clickCount: number): number { return clickCount >= 2 ? MULTI_CLICK_SELECTION_ACTION_DELAY_MS : 0;} export function shouldHandleTerminalSelectionMouseUp( selectionGestureActive: boolean, button: number,): boolean { return selectionGestureActive && button === 0;} interface TerminalViewportProps { threadRef: ScopedThreadRef; threadId: ThreadId; terminalId: string; terminalLabel: string; cwd: string; worktreePath?: string | null; runtimeEnv?: Record<string, string>; onSessionExited: () => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; resizeEpoch: number; drawerHeight: number; keybindings: ResolvedKeybindingsConfig;} 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 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); 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 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 terminalVersion = terminalSession.version; const previousSessionRef = useRef({ buffer: terminalBuffer, error: terminalError, status: terminalStatus, version: terminalVersion, }); useEffect(() => { keybindingsRef.current = keybindings; }, [keybindings]); useEffect(() => { const mount = containerRef.current; if (!mount) return; const localApi = readLocalApi(); const fitAddon = new FitAddon(); const terminal = new Terminal({ cursorBlink: true, lineHeight: 1, fontSize: 12, scrollback: 5_000, fontFamily: '"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace', theme: terminalThemeFromApp(mount), }); terminal.loadAddon(fitAddon); terminal.open(mount); fitTerminalSafely(fitAddon); terminalRef.current = terminal; fitAddonRef.current = fitAddon; previousSessionRef.current = { buffer: "", status: "closed", error: null, version: 0, }; const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; if (selectionActionTimerRef.current !== null) { window.clearTimeout(selectionActionTimerRef.current); selectionActionTimerRef.current = null; } }; const readSelectionAction = (): { position: { x: number; y: number }; clipboardText: string; selection: TerminalContextSelection; } | null => { const activeTerminal = terminalRef.current; const mountElement = containerRef.current; if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { return null; } const selectionText = activeTerminal.getSelection(); const selectionPosition = activeTerminal.getSelectionPosition(); const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); if (!selectionPosition || normalizedText.length === 0) { return null; } const lineStart = selectionPosition.start.y + 1; const lineCount = normalizedText.split("\n").length; const lineEnd = Math.max(lineStart, lineStart + lineCount - 1); const bounds = mountElement.getBoundingClientRect(); const selectionRect = getTerminalSelectionRect(mountElement); const position = resolveTerminalSelectionActionPosition({ bounds, selectionRect: selectionRect === null ? null : { right: selectionRect.right, bottom: selectionRect.bottom }, pointer: selectionPointerRef.current, }); return { position, clipboardText: selectionText, selection: { terminalId, terminalLabel: readTerminalLabel(), lineStart, lineEnd, text: normalizedText, }, }; }; const showSelectionAction = async () => { if (!localApi) { clearSelectionAction(); return; } if (selectionActionMenuOpenRef.current) { return; } const nextAction = readSelectionAction(); if (!nextAction) { clearSelectionAction(); return; } const requestId = ++selectionActionRequestIdRef.current; selectionActionMenuOpenRef.current = true; const clicked = await localApi.contextMenu .show( [ { id: "add-to-chat", label: "Add to chat" }, { id: "copy", label: "Copy" }, ], nextAction.position, ) .finally(() => { selectionActionMenuOpenRef.current = false; }); if (requestId !== selectionActionRequestIdRef.current || clicked === null) { return; } switch (clicked) { case "add-to-chat": handleAddTerminalContext(nextAction.selection); terminalRef.current?.clearSelection(); terminalRef.current?.focus(); return; case "copy": try { await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); } catch (error) { if (requestId !== selectionActionRequestIdRef.current) {Lines 1–500 of 1549. Load more
import { useAtomValue } from "@effect/atom-react"; import { FitAddon } from "@xterm/addon-fit"; 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 ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { Terminal, type ITheme } from "@xterm/xterm"; 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 { 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; 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); } } 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)", scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)", scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)", scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)", black: "rgb(24, 30, 38)", red: "rgb(255, 122, 142)", green: "rgb(134, 231, 149)", yellow: "rgb(244, 205, 114)", blue: "rgb(137, 190, 255)", magenta: "rgb(208, 176, 255)", cyan: "rgb(124, 232, 237)", white: "rgb(210, 218, 230)", brightBlack: "rgb(110, 120, 136)", brightRed: "rgb(255, 168, 180)", brightGreen: "rgb(176, 245, 186)", brightYellow: "rgb(255, 224, 149)", brightBlue: "rgb(174, 210, 255)", brightMagenta: "rgb(229, 203, 255)", brightCyan: "rgb(167, 244, 247)", brightWhite: "rgb(244, 247, 252)", }; } return { background, foreground, cursor: "rgb(38, 56, 78)", selectionBackground: "rgba(37, 63, 99, 0.2)", scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)", scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)", scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)", black: "rgb(44, 53, 66)", red: "rgb(191, 70, 87)", green: "rgb(60, 126, 86)", yellow: "rgb(146, 112, 35)", blue: "rgb(72, 102, 163)", magenta: "rgb(132, 86, 149)", cyan: "rgb(53, 127, 141)", white: "rgb(210, 215, 223)", brightBlack: "rgb(112, 123, 140)", brightRed: "rgb(212, 95, 112)", brightGreen: "rgb(85, 148, 111)", brightYellow: "rgb(173, 133, 45)", brightBlue: "rgb(91, 124, 194)", brightMagenta: "rgb(153, 107, 172)", brightCyan: "rgb(70, 149, 164)", brightWhite: "rgb(236, 240, 246)", }; } function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { return null; } const range = selection.getRangeAt(0); const commonAncestor = range.commonAncestorContainer; const selectionRoot = commonAncestor instanceof Element ? commonAncestor : commonAncestor.parentElement; if (!(selectionRoot instanceof Element) || !mountElement.contains(selectionRoot)) { return null; } const rects = Array.from(range.getClientRects()).filter( (rect) => rect.width > 0 || rect.height > 0, ); if (rects.length > 0) { return rects[rects.length - 1] ?? null; } const boundingRect = range.getBoundingClientRect(); return boundingRect.width > 0 || boundingRect.height > 0 ? boundingRect : null; } export function resolveTerminalSelectionActionPosition(options: { bounds: { left: number; top: number; width: number; height: number }; selectionRect: { right: number; bottom: number } | null; pointer: { x: number; y: number } | null; viewport?: { width: number; height: number } | null; }): { x: number; y: number } { const { bounds, selectionRect, pointer, viewport } = options; const viewportWidth = viewport?.width ?? (typeof window === "undefined" ? bounds.left + bounds.width + 8 : window.innerWidth); const viewportHeight = viewport?.height ?? (typeof window === "undefined" ? bounds.top + bounds.height + 8 : window.innerHeight); const drawerLeft = Math.round(bounds.left); const drawerTop = Math.round(bounds.top); const drawerRight = Math.round(bounds.left + bounds.width); const drawerBottom = Math.round(bounds.top + bounds.height); const preferredX = selectionRect !== null ? Math.round(selectionRect.right) : pointer === null ? Math.round(bounds.left + bounds.width - 140) : Math.max(drawerLeft, Math.min(Math.round(pointer.x), drawerRight)); const preferredY = selectionRect !== null ? Math.round(selectionRect.bottom + 4) : pointer === null ? Math.round(bounds.top + 12) : Math.max(drawerTop, Math.min(Math.round(pointer.y), drawerBottom)); return { x: Math.max(8, Math.min(preferredX, Math.max(viewportWidth - 8, 8))), y: Math.max(8, Math.min(preferredY, Math.max(viewportHeight - 8, 8))), }; } export function terminalSelectionActionDelayForClickCount(clickCount: number): number { return clickCount >= 2 ? MULTI_CLICK_SELECTION_ACTION_DELAY_MS : 0; } export function shouldHandleTerminalSelectionMouseUp( selectionGestureActive: boolean, button: number, ): boolean { return selectionGestureActive && button === 0; } interface TerminalViewportProps { threadRef: ScopedThreadRef; threadId: ThreadId; terminalId: string; terminalLabel: string; cwd: string; worktreePath?: string | null; runtimeEnv?: Record<string, string>; onSessionExited: () => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; resizeEpoch: number; drawerHeight: number; keybindings: ResolvedKeybindingsConfig; } 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 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); 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 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 terminalVersion = terminalSession.version; const previousSessionRef = useRef({ buffer: terminalBuffer, error: terminalError, status: terminalStatus, version: terminalVersion, }); useEffect(() => { keybindingsRef.current = keybindings; }, [keybindings]); useEffect(() => { const mount = containerRef.current; if (!mount) return; const localApi = readLocalApi(); const fitAddon = new FitAddon(); const terminal = new Terminal({ cursorBlink: true, lineHeight: 1, fontSize: 12, scrollback: 5_000, fontFamily: '"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace', theme: terminalThemeFromApp(mount), }); terminal.loadAddon(fitAddon); terminal.open(mount); fitTerminalSafely(fitAddon); terminalRef.current = terminal; fitAddonRef.current = fitAddon; previousSessionRef.current = { buffer: "", status: "closed", error: null, version: 0, }; const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; if (selectionActionTimerRef.current !== null) { window.clearTimeout(selectionActionTimerRef.current); selectionActionTimerRef.current = null; } }; const readSelectionAction = (): { position: { x: number; y: number }; clipboardText: string; selection: TerminalContextSelection; } | null => { const activeTerminal = terminalRef.current; const mountElement = containerRef.current; if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { return null; } const selectionText = activeTerminal.getSelection(); const selectionPosition = activeTerminal.getSelectionPosition(); const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); if (!selectionPosition || normalizedText.length === 0) { return null; } const lineStart = selectionPosition.start.y + 1; const lineCount = normalizedText.split("\n").length; const lineEnd = Math.max(lineStart, lineStart + lineCount - 1); const bounds = mountElement.getBoundingClientRect(); const selectionRect = getTerminalSelectionRect(mountElement); const position = resolveTerminalSelectionActionPosition({ bounds, selectionRect: selectionRect === null ? null : { right: selectionRect.right, bottom: selectionRect.bottom }, pointer: selectionPointerRef.current, }); return { position, clipboardText: selectionText, selection: { terminalId, terminalLabel: readTerminalLabel(), lineStart, lineEnd, text: normalizedText, }, }; }; const showSelectionAction = async () => { if (!localApi) { clearSelectionAction(); return; } if (selectionActionMenuOpenRef.current) { return; } const nextAction = readSelectionAction(); if (!nextAction) { clearSelectionAction(); return; } const requestId = ++selectionActionRequestIdRef.current; selectionActionMenuOpenRef.current = true; const clicked = await localApi.contextMenu .show( [ { id: "add-to-chat", label: "Add to chat" }, { id: "copy", label: "Copy" }, ], nextAction.position, ) .finally(() => { selectionActionMenuOpenRef.current = false; }); if (requestId !== selectionActionRequestIdRef.current || clicked === null) { return; } switch (clicked) { case "add-to-chat": handleAddTerminalContext(nextAction.selection); terminalRef.current?.clearSelection(); terminalRef.current?.focus(); return; case "copy": try { await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); } catch (error) { if (requestId !== selectionActionRequestIdRef.current) {