/** * Popup autocomplete menu for terminal. * Renders a floating list of completion suggestions near the terminal cursor. * Shows a detail tooltip for the selected/hovered item with full description. * Colors are derived from the active terminal theme for visual consistency. */ import React, { useEffect, useLayoutEffect, useRef, useState, memo } from "react"; import { Folder, File, Link } from "lucide-react"; import type { CompletionSuggestion, SuggestionSource } from "./completionEngine"; import { clampAutocompletePopupGeometry, computeAutocompletePopupPlacement, resolveAutocompleteClampViewport, } from "./terminalAutocompleteLayout"; export interface AutocompleteThemeColors { background: string; foreground: string; selection: string; cursor: string; } export interface SubDirEntry { name: string; type: "file" | "directory" | "symlink"; } export interface SubDirPanel { entries: SubDirEntry[]; selectedIndex: number; dirPath: string; } interface AutocompletePopupProps { suggestions: CompletionSuggestion[]; selectedIndex: number; /** Cursor anchor in viewport coordinates */ anchorViewport: { left: number; top: number; bottom: number }; visible: boolean; expandUpward?: boolean; themeColors?: AutocompleteThemeColors; onSelect: (suggestion: CompletionSuggestion) => void; maxHeight?: number; subDirPanels?: SubDirPanel[]; subDirFocusLevel?: number; /** Reference to the terminal container for calculating fixed position */ containerRef?: React.RefObject; /** Ask the autocomplete controller to recompute cursor-relative popup position */ onRequestReposition?: () => void; /** Offset from top of container to terminal content area (toolbar + search bar) */ searchBarOffset?: number; /** Called when user clicks outside the popup to dismiss it */ onDismiss?: () => void; } const SOURCE_LABELS: Record = { history: { label: "h", fullLabel: "History", fallbackColor: "#FBBF24" }, command: { label: "c", fullLabel: "Command", fallbackColor: "#34D399" }, subcommand: { label: "s", fullLabel: "Subcommand", fallbackColor: "#60A5FA" }, option: { label: "o", fullLabel: "Option", fallbackColor: "#A78BFA" }, arg: { label: "a", fullLabel: "Argument", fallbackColor: "#F87171" }, path: { label: "p", fullLabel: "Path", fallbackColor: "#38BDF8" }, snippet: { label: "{}", fullLabel: "Snippet", fallbackColor: "#C084FC" }, plugin: { label: "P", fullLabel: "Plugin", fallbackColor: "#F472B6" }, }; /** Lucide icon components for file types in path suggestions */ const FILE_TYPE_CONFIG: Record; color: string }> = { directory: { Icon: Folder, color: "#38BDF8" }, file: { Icon: File, color: "#94A3B8" }, symlink: { Icon: Link, color: "#A78BFA" }, }; const FileTypeIcon: React.FC<{ fileType: string }> = ({ fileType }) => { const cfg = FILE_TYPE_CONFIG[fileType] ?? FILE_TYPE_CONFIG.file; return ( ); }; /** Chevron indicator for expandable directory items */ const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ visible, color }) => ( ); /** Small key-cap badge shown on the selected row to hint the actionable key. */ const KeyCap: React.FC<{ label: string; color: string; bg: string }> = ({ label, color, bg }) => ( {label} ); const AutocompletePopup: React.FC = ({ suggestions, selectedIndex, anchorViewport, visible, expandUpward = false, themeColors, onSelect, maxHeight = 240, subDirPanels = [], subDirFocusLevel = -1, containerRef, onRequestReposition, searchBarOffset: _searchBarOffset = 30, onDismiss, }) => { const wrapperRef = useRef(null); const listRef = useRef(null); const selectedRef = useRef(null); const [hoveredIndex, setHoveredIndex] = useState(-1); const [measuredSize, setMeasuredSize] = useState<{ width: number; height: number } | null>(null); useEffect(() => { if (selectedRef.current && listRef.current) { selectedRef.current.scrollIntoView({ block: "nearest", behavior: "instant" as ScrollBehavior, }); } }, [selectedIndex]); // Reset hover when suggestions change useEffect(() => { setHoveredIndex(-1); }, [suggestions]); useEffect(() => { if (!visible || !onRequestReposition) return; let frameId = 0; const requestReposition = () => { if (frameId) cancelAnimationFrame(frameId); frameId = requestAnimationFrame(() => { frameId = 0; onRequestReposition(); }); }; const container = containerRef?.current; const observer = container ? new ResizeObserver(requestReposition) : null; observer?.observe(container); window.addEventListener("resize", requestReposition); return () => { if (frameId) cancelAnimationFrame(frameId); observer?.disconnect(); window.removeEventListener("resize", requestReposition); }; }, [containerRef, onRequestReposition, visible]); useEffect(() => { if (!visible || !onRequestReposition || suggestions.length === 0) return; let firstFrame = 0; let secondFrame = 0; firstFrame = requestAnimationFrame(() => { onRequestReposition(); secondFrame = requestAnimationFrame(onRequestReposition); }); return () => { if (firstFrame) cancelAnimationFrame(firstFrame); if (secondFrame) cancelAnimationFrame(secondFrame); }; }, [onRequestReposition, subDirPanels.length, suggestions, visible]); useLayoutEffect(() => { if (!visible || suggestions.length === 0) { setMeasuredSize((current) => (current === null ? current : null)); return; } let frameId = 0; const measure = () => { const rect = wrapperRef.current?.getBoundingClientRect(); if (!rect || rect.width <= 0 || rect.height <= 0) return; setMeasuredSize((current) => { if ( current && Math.abs(current.width - rect.width) < 0.5 && Math.abs(current.height - rect.height) < 0.5 ) { return current; } return { width: rect.width, height: rect.height }; }); }; measure(); const wrapper = wrapperRef.current; const observer = wrapper ? new ResizeObserver(measure) : null; observer?.observe(wrapper); frameId = requestAnimationFrame(measure); return () => { if (frameId) cancelAnimationFrame(frameId); observer?.disconnect(); }; }, [hoveredIndex, selectedIndex, subDirPanels, suggestions, visible]); // Dismiss popup when clicking outside useEffect(() => { if (!visible || !onDismiss) return; const handlePointerDown = (e: PointerEvent) => { if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { onDismiss(); } }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, [visible, onDismiss]); if (!visible || suggestions.length === 0) return null; const bg = themeColors?.background ?? "#1e1e2e"; const fg = themeColors?.foreground ?? "#cdd6f4"; // Accent comes from the active terminal theme's cursor/selection colors, // which already track the user's accent setting (custom accent rewrites them // in applyCustomAccentToTerminalTheme). Falling back to selection, then a // neutral fg-mix, keeps older/partial theme payloads working. This is what // makes the popup's highlight follow the accent instead of a hardcoded blue. const accent = themeColors?.cursor || themeColors?.selection || fg; const popupBg = `color-mix(in srgb, ${bg} 92%, ${fg} 8%)`; const popupBorder = `color-mix(in srgb, ${bg} 75%, ${fg} 25%)`; const selectedBg = `color-mix(in srgb, ${accent} 26%, ${bg} 74%)`; const selectedBorderAccent = `color-mix(in srgb, ${accent} 60%, ${bg} 40%)`; const hoverBg = `color-mix(in srgb, ${accent} 12%, ${bg} 88%)`; const textColor = fg; const dimTextColor = `color-mix(in srgb, ${fg} 50%, ${bg} 50%)`; // Determine which item to show the detail tooltip for const detailIndex = hoveredIndex >= 0 ? hoveredIndex : selectedIndex; const detailItem = detailIndex >= 0 ? suggestions[detailIndex] : null; const showDetail = detailItem?.description && detailItem.description.length > 0; // Whether ANY item in the current set can open the detail tooltip (non-path // row with a description). Placement reserves space from this set-level flag // rather than the hovered item, so moving the mouse between rows can't change // totalWidth/height and shift the popup out from under the pointer. const setMayShowDetailPanel = suggestions.some( (s) => s.source !== "path" && Boolean(s.description && s.description.length > 0), ); const fixedLeft = anchorViewport.left; const fixedLineTop = anchorViewport.top; const fixedLineBottom = anchorViewport.bottom; const viewportPadding = 8; const anchorGap = 8; const clampViewport = resolveAutocompleteClampViewport(containerRef?.current ?? null); const estimatedPopupHeight = Math.min(maxHeight, suggestions.length * 28 + 8); // Reserve the detail height for the whole set (not the hovered row) so the // chosen direction/height stays stable while hovering. const estimatedDetailHeight = setMayShowDetailPanel ? 96 : 0; const desiredContentHeight = Math.max(estimatedPopupHeight, estimatedDetailHeight); // Total horizontal extent so the WHOLE assembly is clamped inside the // viewport — not just the main list. Mirrors the rendered maxWidths: // main list (400) + each cascading sub-dir panel (240) + the detail // tooltip (280), separated by the flex gap (4). Without this, expanding a // directory near the right edge pushed the sub-panels off-screen (#1202). const FLEX_GAP = 4; const MAIN_LIST_MAX_WIDTH = 400; const SUBDIR_PANEL_MAX_WIDTH = 240; const DETAIL_PANEL_MAX_WIDTH = 280; const totalWidth = MAIN_LIST_MAX_WIDTH + subDirPanels.length * (FLEX_GAP + SUBDIR_PANEL_MAX_WIDTH) + (setMayShowDetailPanel ? FLEX_GAP + DETAIL_PANEL_MAX_WIDTH : 0); const clampWidth = MAIN_LIST_MAX_WIDTH + subDirPanels.length * (FLEX_GAP + SUBDIR_PANEL_MAX_WIDTH); const placement = computeAutocompletePopupPlacement({ anchorTop: fixedLineTop, anchorBottom: fixedLineBottom, anchorLeft: fixedLeft, viewportWidth: clampViewport.width, viewportHeight: clampViewport.height, clampViewport, desiredHeight: desiredContentHeight, totalWidth, clampWidth, maxHeight, anchorGap, viewportPadding, expandUpwardHint: expandUpward, forceExpandUpward: expandUpward, }); const renderUpward = placement.renderUpward; const effectiveMaxHeight = placement.maxHeight; const anchoredTop = placement.top; const clampedLeft = placement.left; const finalGeometry = measuredSize ? clampAutocompletePopupGeometry({ left: clampedLeft, top: anchoredTop, width: measuredSize.width, height: measuredSize.height, clampViewport, viewportPadding, }) : { left: clampedLeft, top: anchoredTop }; const sharedBoxStyle = { // border-box so each panel's maxWidth is its true outer width (padding + // border included). The horizontal clamp's totalWidth sums these maxWidths, // so this keeps the off-screen math exact even for the padded detail panel. boxSizing: "border-box" as const, backgroundColor: popupBg, border: `1px solid ${popupBorder}`, borderRadius: "6px", boxShadow: renderUpward ? "0 -2px 6px rgba(0, 0, 0, 0.15)" : "0 2px 6px rgba(0, 0, 0, 0.15)", fontFamily: "inherit", fontSize: "13px", color: textColor, }; return (
{ e.preventDefault(); e.stopPropagation(); }} > {/* Main suggestion list */}
{suggestions.map((suggestion, index) => { const isSelected = index === selectedIndex; const isHovered = index === hoveredIndex; const sourceInfo = SOURCE_LABELS[suggestion.source]; return (
setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(-1)} onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); onSelect(suggestion); }} > {/* Source / file type indicator */} {suggestion.source === "path" && suggestion.fileType ? ( ) : ( {sourceInfo.label} )} {/* Command text */} {suggestion.displayText} {/* Inline description (truncated). Snippets show only their label in the row — the full command lives in the detail preview. */} {suggestion.source !== "snippet" && suggestion.description && ( {suggestion.description} )} {/* Frequency badge for history */} {suggestion.frequency && suggestion.frequency > 1 && ( ×{suggestion.frequency} )} {/* Expand indicator for directories */} {suggestion.source === "path" && suggestion.fileType === "directory" && ( )} {/* Key hint on the selected row: → expands directories, ↵ runs. */} {isSelected && ( {suggestion.source === "path" && suggestion.fileType === "directory" && ( )} )}
); })}
{/* Cascading sub-directory panels */} {subDirPanels.map((panel, level) => (
{panel.entries.map((entry, idx) => { const isFocused = level === subDirFocusLevel; const isSubSelected = isFocused && idx === panel.selectedIndex; return (
{ el?.scrollIntoView({ block: "nearest" }); } : undefined} style={{ display: "flex", alignItems: "center", padding: "4px 10px", cursor: "pointer", backgroundColor: isSubSelected ? selectedBg : (idx === panel.selectedIndex && level < subDirFocusLevel) ? hoverBg : "transparent", boxShadow: isSubSelected ? `inset 2px 0 0 0 ${selectedBorderAccent}` : undefined, gap: "8px", lineHeight: "1.4", }} onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }} > {entry.name}{entry.type === "directory" ? "/" : ""} {entry.type === "directory" && ( )}
); })}
))} {/* Detail tooltip panel — shows full description for non-path items */} {showDetail && detailItem && detailItem.source !== "path" && (
{detailItem.displayText} {SOURCE_LABELS[detailItem.source].fullLabel}
{detailItem.source === "snippet" ? (
                {detailItem.description}
              
) : ( detailItem.description )}
)}
); }; export default memo(AutocompletePopup);