/** * Owns selection-driven "Add to AI" chrome so selection change does not re-render * the whole Terminal / TerminalView tree (common when focus moves to the AI input). */ import React, { memo, useEffect, useState, type RefObject } from 'react'; import type { Terminal as XTerm } from '@xterm/xterm'; import { Sparkles } from 'lucide-react'; import { useI18n } from '../../application/i18n/I18nProvider'; import { createCopyOnSelectUserGestureTracker, shouldWriteCopyOnSelect, subscribeCopyOnSelectUserCommand, subscribeCopyOnSelectUserGesture, } from './copyOnSelect'; import { getTerminalSelectionForClipboard } from './normalizeTerminalSelection'; import { resolveSelectionOverlayPosition } from './useTerminalEffects'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { shouldShowSelectionAIOverlay } from './TerminalView'; type SelectionOverlayPosition = { left: number; top: number } | null; const areSelectionOverlayPositionsEqual = ( a: SelectionOverlayPosition, b: SelectionOverlayPosition, ): boolean => { if (a === b) return true; if (!a || !b) return false; return a.left === b.left && a.top === b.top; }; type Props = { termRef: RefObject; containerRef: RefObject; showSelectionAIAction?: boolean; onAddSelectionToAI?: () => void; copyOnSelect?: boolean; normalizeTextOnCopy?: boolean; /** * True while createXTermRuntime programmatically restores selection * (preserveSelectionOnInput). Copy-on-select must skip those events. */ isRestoringSelectionRef?: RefObject; isVisible?: boolean; }; function TerminalSelectionAIOverlayInner({ termRef, containerRef, showSelectionAIAction, onAddSelectionToAI, copyOnSelect, normalizeTextOnCopy = true, isRestoringSelectionRef, isVisible = true, }: Props) { const { t } = useI18n(); const [hasSelection, setHasSelection] = useState(false); const [selectionOverlayPosition, setSelectionOverlayPosition] = useState(null); useEffect(() => { if (!isVisible) return; let disposed = false; let overlayRafId: number | null = null; let copyTimer: ReturnType | null = null; let waitRafId: number | null = null; let lastHasSelection: boolean | null = null; let lastOverlayPosition: SelectionOverlayPosition = null; let selectionDisposable: { dispose: () => void } | null = null; let scrollDisposable: { dispose: () => void } | null | undefined = null; let resizeDisposable: { dispose: () => void } | null | undefined = null; let resizeObserver: ResizeObserver | null = null; let userGestureUnsubscribe: (() => void) | null = null; let userGestureTracker: ReturnType | null = null; const requestFrame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0) as unknown as number; const cancelFrame = typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : (id: number) => clearTimeout(id); const cleanupListeners = () => { if (overlayRafId !== null) { cancelFrame(overlayRafId); overlayRafId = null; } if (copyTimer) { clearTimeout(copyTimer); copyTimer = null; } selectionDisposable?.dispose(); selectionDisposable = null; scrollDisposable?.dispose(); scrollDisposable = null; resizeDisposable?.dispose(); resizeDisposable = null; resizeObserver?.disconnect(); resizeObserver = null; userGestureUnsubscribe?.(); userGestureUnsubscribe = null; userGestureTracker?.dispose(); userGestureTracker = null; }; const attach = (term: XTerm) => { cleanupListeners(); userGestureTracker = createCopyOnSelectUserGestureTracker(); const unsubscribePointer = subscribeCopyOnSelectUserGesture(term, userGestureTracker); const unsubscribeCommand = subscribeCopyOnSelectUserCommand(term, () => { userGestureTracker?.pulse(); }); userGestureUnsubscribe = () => { unsubscribePointer(); unsubscribeCommand(); }; const publishSelectionOverlayPosition = () => { overlayRafId = null; if (disposed) return; const nextPosition = resolveSelectionOverlayPosition(term, containerRef.current); if (areSelectionOverlayPositionsEqual(lastOverlayPosition, nextPosition)) return; lastOverlayPosition = nextPosition; setSelectionOverlayPosition(nextPosition); }; const scheduleSelectionOverlayPosition = () => { if (lastHasSelection === false) return; if (overlayRafId !== null) return; overlayRafId = requestFrame(publishSelectionOverlayPosition); }; const onSelectionChange = (options?: { allowCopy?: boolean }) => { if (disposed) return; const allowCopy = options?.allowCopy !== false; const rawSelection = term.getSelection(); const hasText = !!rawSelection && rawSelection.length > 0; if (lastHasSelection !== hasText) { lastHasSelection = hasText; setHasSelection(hasText); } if (copyTimer) { clearTimeout(copyTimer); copyTimer = null; } if (!hasText) { if (lastOverlayPosition !== null) { lastOverlayPosition = null; setSelectionOverlayPosition(null); } return; } scheduleSelectionOverlayPosition(); // Skip programmatic selections: preserveSelectionOnInput restore, // SearchAddon match highlight (issue #3007), and the initial attach // snapshot so those writes cannot clobber a user copy. if (shouldWriteCopyOnSelect({ allowCopy, hasText, copyOnSelect: !!copyOnSelect, isRestoringSelection: !!isRestoringSelectionRef?.current, isUserSelection: !!userGestureTracker?.isActive(), })) { const selection = getTerminalSelectionForClipboard(term, normalizeTextOnCopy); if (!selection) return; copyTimer = setTimeout(() => { void navigator.clipboard.writeText(selection).catch(() => { /* ignore clipboard failures */ }); }, 80); } }; selectionDisposable = term.onSelectionChange(() => onSelectionChange()); scrollDisposable = term.onScroll?.(scheduleSelectionOverlayPosition); resizeDisposable = term.onResize?.(scheduleSelectionOverlayPosition); resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(scheduleSelectionOverlayPosition); if (containerRef.current) { resizeObserver?.observe(containerRef.current); } // Sync UI only; do not write clipboard on reattach. onSelectionChange({ allowCopy: false }); }; // Child effects run before parent useTerminalEffects assigns termRef. // Poll until the xterm runtime exists so copy-on-select / overlay attach // for sessions that mount already visible. Bound the wait so a failed // runtime never leaves a permanent rAF loop. const waitStartedAt = Date.now(); const MAX_RUNTIME_WAIT_MS = 15_000; const tryAttach = () => { if (disposed) return; const term = termRef.current; if (!term) { if (Date.now() - waitStartedAt >= MAX_RUNTIME_WAIT_MS) { waitRafId = null; return; } waitRafId = requestFrame(tryAttach); return; } waitRafId = null; attach(term); }; tryAttach(); return () => { disposed = true; if (waitRafId !== null) cancelFrame(waitRafId); cleanupListeners(); }; }, [ termRef, containerRef, copyOnSelect, normalizeTextOnCopy, isRestoringSelectionRef, isVisible, ]); if (!shouldShowSelectionAIOverlay({ hasSelection, selectionOverlayPosition, onAddSelectionToAI, showSelectionAIAction, }) || !onAddSelectionToAI || !selectionOverlayPosition) { return null; } return (
{t('terminal.selection.addToAIDesc')}
); } export const TerminalSelectionAIOverlay = memo(TerminalSelectionAIOverlayInner); TerminalSelectionAIOverlay.displayName = 'TerminalSelectionAIOverlay';