import type { Terminal as XTerm } from "@xterm/xterm"; import type { CompletionSuggestion } from "./completionEngine"; import type { PromptDetectionResult } from "./promptDetector"; import type { SubDirPanel } from "./useTerminalAutocomplete"; import { stringCellWidth } from "./terminalStringCellWidth"; import { getXTermCellDimensions } from "./xtermUtils"; export function resolveAutocompleteCwd( promptText: string, currentWord: string, fallbackCwd: string | undefined, os: "linux" | "windows" | "macos", ): string | undefined { return resolveAutocompleteCwdWithSource(promptText, currentWord, fallbackCwd, os).cwd; } export type AutocompleteCwdSource = "prompt" | "fallback" | "none"; export function resolveAutocompleteCwdWithSource( promptText: string, currentWord: string, fallbackCwd: string | undefined, os: "linux" | "windows" | "macos", ): { cwd: string | undefined; source: AutocompleteCwdSource } { if (os === "windows") return { cwd: fallbackCwd, source: fallbackCwd ? "fallback" : "none" }; const normalizedWord = currentWord.trim().replace(/^['"]/, ""); // Absolute or home-relative paths don't depend on cwd if (normalizedWord.startsWith("/") || normalizedWord.startsWith("~/")) { return { cwd: fallbackCwd, source: fallbackCwd ? "fallback" : "none" }; } // For empty word (e.g. "cd ") and relative paths, try prompt-based cwd // extraction which reflects the current visible prompt — more up-to-date // than fallbackCwd when OSC 7 is not supported. const promptCwd = extractPosixCwdFromPrompt(promptText); return chooseAutocompleteCwdWithSource(promptCwd, fallbackCwd); } function chooseAutocompleteCwdWithSource( promptCwd: string | undefined, fallbackCwd: string | undefined, ): { cwd: string | undefined; source: AutocompleteCwdSource } { if (!promptCwd) return { cwd: fallbackCwd, source: fallbackCwd ? "fallback" : "none" }; if (!fallbackCwd) return { cwd: promptCwd, source: "prompt" }; // Prompt cwd is extracted from the currently visible prompt, so it tracks // directory changes even when OSC 7 is not supported. Prefer it over // fallbackCwd (which may be stale from initial connection) whenever it // looks like a usable path. if (promptCwd.startsWith("/") || promptCwd === "~" || promptCwd.startsWith("~/")) { return { cwd: promptCwd, source: "prompt" }; } // Bare directory name (e.g. "xunlong") can't be used as a path — fallback return { cwd: fallbackCwd, source: fallbackCwd ? "fallback" : "none" }; } function extractPosixCwdFromPrompt(promptText: string): string | undefined { const trimmed = promptText.trimEnd().replace(/[#$%>]\s*$/, ""); if (!trimmed) return undefined; const patterns = [ /:(\/[^\s\]]*|~(?:\/[^\s\]]*)?)$/, /\s(\/[^\s\]]*|~(?:\/[^\s\]]*)?)\]$/, /(^|[\s:])(\/[^\s\]]*|~(?:\/[^\s\]]*)?)$/, ]; for (const pattern of patterns) { const match = trimmed.match(pattern); if (!match) continue; const candidate = match[match.length - 1]; if (candidate === "/" || candidate.startsWith("/") || candidate === "~" || candidate.startsWith("~/")) { return candidate; } } const fallbackTokens = trimmed .split(/\s+/) .map((token) => token.replace(/^[([{:]+/, "").replace(/[\])}:]+$/, "")); for (let index = fallbackTokens.length - 1; index >= 0; index--) { const candidate = fallbackTokens[index]; if (candidate === "/" || candidate.startsWith("/") || candidate === "~" || candidate.startsWith("~/")) { return candidate; } } return undefined; } export function areSuggestionsEqual( left: CompletionSuggestion[], right: CompletionSuggestion[], ): boolean { if (left.length !== right.length) return false; for (let i = 0; i < left.length; i++) { const a = left[i]; const b = right[i]; if ( a.text !== b.text || a.displayText !== b.displayText || a.description !== b.description || a.source !== b.source || a.score !== b.score || a.frequency !== b.frequency || a.fileType !== b.fileType ) { return false; } } return true; } /** * Keep a popup highlight across a same-query list refresh (e.g. late path * suggestions). Match the previously selected row by stable identity; if a * late path replaces a same-text history/plugin entry, fall back to text. */ export function resolvePreservedSuggestionIndex( previousSuggestions: CompletionSuggestion[], previousSelectedIndex: number, nextSuggestions: CompletionSuggestion[], ): number { if (previousSelectedIndex < 0 || previousSelectedIndex >= previousSuggestions.length) { return -1; } const selected = previousSuggestions[previousSelectedIndex]; if (!selected) return -1; const exactIndex = nextSuggestions.findIndex( (candidate) => candidate.text === selected.text && candidate.source === selected.source && candidate.displayText === selected.displayText && candidate.fileType === selected.fileType, ); if (exactIndex >= 0) return exactIndex; return nextSuggestions.findIndex((candidate) => candidate.text === selected.text); } export function areSubDirPanelsEqual(left: SubDirPanel[], right: SubDirPanel[]): boolean { if (left.length !== right.length) return false; for (let i = 0; i < left.length; i++) { const a = left[i]; const b = right[i]; if (a.dirPath !== b.dirPath || a.selectedIndex !== b.selectedIndex) return false; if (a.entries.length !== b.entries.length) return false; for (let j = 0; j < a.entries.length; j++) { if (a.entries[j].name !== b.entries[j].name || a.entries[j].type !== b.entries[j].type) { return false; } } } return true; } export interface PopupClampViewport { left: number; top: number; width: number; height: number; } export interface PopupPlacementInput { /** Anchor (current input line) top edge, in viewport coordinates. */ anchorTop: number; /** Anchor (current input line) bottom edge, in viewport coordinates. */ anchorBottom: number; /** Desired left edge (cursor column), in viewport coordinates. */ anchorLeft: number; viewportWidth: number; viewportHeight: number; /** * Optional clamp region in viewport coordinates. Defaults to the rectangle * `(0, 0, viewportWidth, viewportHeight)`. */ clampViewport?: PopupClampViewport; /** Natural height the popup wants if unconstrained (main list or detail). */ desiredHeight: number; /** * Total horizontal extent of the popup including any cascading sub-directory * panels and the detail tooltip — used so the whole assembly is clamped * inside the viewport, not just the main list. */ totalWidth: number; /** * Width budget for horizontal clamping. Defaults to `totalWidth`. The detail * tooltip is rendered beside the list and can extend left on its own, so * callers may pass a smaller width to keep the primary list near the cursor. */ clampWidth?: number; /** Hard cap on rendered height (matches the list's maxHeight prop). */ maxHeight: number; /** Gap between the anchor line and the popup. */ anchorGap: number; /** Minimum distance to keep from the viewport edges. */ viewportPadding: number; /** * Direction hint from the cursor-cell based calculation. Only used to break * ties when neither side can fully fit the desired height. */ expandUpwardHint: boolean; /** * When true, keep rendering above the supplied anchor even if there is a * full fit below. Used after a wrap pins the anchor to the command start * so placement cannot flip down over the continuation rows (#3061). */ forceExpandUpward?: boolean; } export interface PopupPlacement { /** Whether the popup renders above the anchor line (flipped up). */ renderUpward: boolean; /** Final top edge, in viewport coordinates (already clamped). */ top: number; /** Final left edge, in viewport coordinates (already clamped). */ left: number; /** Height budget for the rendered content (drives scrolling). */ maxHeight: number; } export interface PopupGeometryClampInput { left: number; top: number; width: number; height: number; clampViewport: PopupClampViewport; viewportPadding: number; } export interface PopupGeometry { top: number; left: number; } function clampCoordinate(value: number, min: number, max: number): number { if (max <= min) return min; return Math.max(min, Math.min(value, max)); } /** * Final guardrail using the rendered popup's actual DOM size. The placement * pass uses estimated list/detail/panel sizes so it can decide before render; * this pass prevents any estimate mismatch or delayed xterm cursor refresh * from letting the fixed-position portal escape the terminal/app bounds. */ export function clampAutocompletePopupGeometry( input: PopupGeometryClampInput, ): PopupGeometry { const { left, top, width, height, clampViewport, viewportPadding } = input; const safeWidth = Number.isFinite(width) ? Math.max(0, width) : 0; const safeHeight = Number.isFinite(height) ? Math.max(0, height) : 0; const minLeft = clampViewport.left + viewportPadding; const minTop = clampViewport.top + viewportPadding; const maxLeft = clampViewport.left + clampViewport.width - viewportPadding - safeWidth; const maxTop = clampViewport.top + clampViewport.height - viewportPadding - safeHeight; return { left: clampCoordinate(left, minLeft, Math.max(minLeft, maxLeft)), top: clampCoordinate(top, minTop, Math.max(minTop, maxTop)), }; } /** * Decide where to place the autocomplete popup so it never spills past the * viewport edges. Pure and deterministic so the boundary math is unit-tested * independently of React/DOM. * * Vertical: prefer downward, but flip upward when the space below the input * line can't fit the desired height and the space above is a better fit. The * height is then clamped to whatever the chosen side actually offers so the * list scrolls instead of overflowing. * * Horizontal: clamp the left edge using the popup's *total* width (main list + * cascading sub-dir panels + detail tooltip), not just the main list, so wide * assemblies near the right edge slide left instead of overflowing. When the * assembly is wider than the viewport it pins to the left padding so the * primary list stays visible. */ export function computeAutocompletePopupPlacement( input: PopupPlacementInput, ): PopupPlacement { const { anchorTop, anchorBottom, anchorLeft, viewportWidth, viewportHeight, desiredHeight, totalWidth, maxHeight, anchorGap, viewportPadding, expandUpwardHint, forceExpandUpward = false, clampViewport, clampWidth, } = input; const bounds: PopupClampViewport = clampViewport ?? { left: 0, top: 0, width: viewportWidth, height: viewportHeight, }; const boundsRight = bounds.left + bounds.width; const boundsBottom = bounds.top + bounds.height; const horizontalClampWidth = clampWidth ?? totalWidth; const cappedDesiredHeight = Math.min(maxHeight, Math.max(0, desiredHeight)); const spaceAbove = Math.max(0, anchorTop - bounds.top - viewportPadding - anchorGap); const spaceBelow = Math.max(0, boundsBottom - anchorBottom - viewportPadding - anchorGap); const canFullyRenderAbove = spaceAbove >= cappedDesiredHeight; const canFullyRenderBelow = spaceBelow >= cappedDesiredHeight; const renderUpward = forceExpandUpward && spaceAbove > 0 ? true : canFullyRenderBelow ? false : canFullyRenderAbove ? true : expandUpwardHint ? spaceAbove >= Math.min(spaceBelow, 80) : spaceAbove > spaceBelow; const availableVerticalSpace = renderUpward ? spaceAbove : spaceBelow; const availableViewportHeight = Math.max(0, bounds.height - viewportPadding * 2); const effectiveMaxHeight = Math.max( 0, Math.min(maxHeight, availableVerticalSpace, availableViewportHeight), ); const contentHeightForPlacement = Math.min(effectiveMaxHeight, cappedDesiredHeight); const unclampedTop = renderUpward ? Math.max(bounds.top + viewportPadding, anchorTop - anchorGap - contentHeightForPlacement) : Math.min( anchorBottom + anchorGap, boundsBottom - viewportPadding - contentHeightForPlacement, ); const minTop = bounds.top + viewportPadding; const maxTop = Math.max(minTop, boundsBottom - viewportPadding - contentHeightForPlacement); const top = Math.max(minTop, Math.min(unclampedTop, maxTop)); // Right edge that keeps the clamped assembly inside the bounds. When the // assembly is wider than the available room this goes below the left padding, // so the final clamp pins the popup to the left padding (primary list wins). const maxLeft = boundsRight - viewportPadding - Math.max(0, horizontalClampWidth); const left = Math.max(bounds.left + viewportPadding, Math.min(anchorLeft, maxLeft)); return { renderUpward, top, left, maxHeight: effectiveMaxHeight }; } export interface AutocompleteViewportAnchor { anchorLeft: number; anchorTop: number; anchorBottom: number; expandUpward: boolean; } const ESTIMATED_ROW_HEIGHT_PX = 28; const POPUP_CHROME_PADDING_PX = 8; function estimatePopupHeight(itemCount: number): number { return itemCount * ESTIMATED_ROW_HEIGHT_PX + POPUP_CHROME_PADDING_PX; } function shouldExpandAutocompleteUpward( cursorY: number, spaceBelowPx: number, spaceAbovePx: number, estimatedPopupHeight: number, ): boolean { if (spaceBelowPx >= estimatedPopupHeight) return false; if (spaceAbovePx >= estimatedPopupHeight) return true; return cursorY > 2 && spaceAbovePx >= spaceBelowPx; } /** Predicted cursor cell for popup anchoring (column within the row + row). */ export type AutocompleteCursorCell = { column: number; row: number; }; function clampAutocompleteViewportRow(row: number, termRows: number): number { if (Number.isFinite(termRows) && termRows > 0) { return Math.max(0, Math.min(row, termRows - 1)); } return Math.max(0, row); } /** Absolute buffer index of the first physical row of the current wrapped line. */ function resolveWrappedCommandStartAbsY(term: XTerm): number { const buffer = term.buffer.active; let startAbsY = buffer.cursorY + buffer.baseY; let startLine = buffer.getLine(startAbsY); while (startLine?.isWrapped && startAbsY > 0) { startAbsY -= 1; startLine = buffer.getLine(startAbsY); } return startAbsY; } /** * Viewport row of the command start (prompt / first physical line). After a * wrap at the bottom, xterm keeps the cursor on `term.rows - 1` and scrolls; * this row moves up with the wrapped command so the popup cannot cover it. */ export function resolveAutocompleteCommandStartRow(term: XTerm): number { const buffer = term.buffer.active; const startAbsY = resolveWrappedCommandStartAbsY(term); const viewportOrigin = Number.isFinite(buffer.viewportY) ? buffer.viewportY : buffer.baseY; return clampAutocompleteViewportRow(startAbsY - viewportOrigin, Number(term.rows)); } /** * Best-effort cursor cell for popup anchoring. xterm's helper textarea and * buffer.cursorX can lag behind the keystroke that triggered completion, so * derive the column from the aligned prompt and wrap onto following rows when * unechoed wide input crosses `term.cols`. * * When the live cursor already sits on a soft-wrapped continuation row, * measure from the logical line start so a still-unechoed `userInput` suffix * advances past the partial wrap instead of anchoring at the lagged cell. * * A wrap past the last visible row scrolls the buffer; xterm keeps the cursor * on `term.rows - 1`. Clamp the predicted row so a completion that resolves * before that scroll does not place the popup one cell below the grid. */ export function resolveAutocompleteCursorCell( term: XTerm, prompt: Pick, ): AutocompleteCursorCell { const buffer = term.buffer.active; const cols = Math.max(1, Number(term.cols) || 80); const termRows = Number(term.rows); const absY = buffer.cursorY + buffer.baseY; const startAbsY = resolveWrappedCommandStartAbsY(term); const startRowY = startAbsY - buffer.baseY; let fromLine = (buffer.cursorY - startRowY) * cols + buffer.cursorX; const cursorLine = buffer.getLine(absY); if (cursorLine) { const lineText = cursorLine.translateToString(false); const tail = lineText.substring(buffer.cursorX).trimEnd(); if (tail.length === 0) { const endCol = Math.max(buffer.cursorX, lineText.trimEnd().length); fromLine = (buffer.cursorY - startRowY) * cols + endCol; } } // Use xterm's active Unicode width so CJK / emoji / fullwidth glyphs in // the synthetic pre-echo userInput advance the popup with the same cell // count as the real cursor (#2813). const fromPrompt = stringCellWidth(prompt.promptText, term) + stringCellWidth(prompt.userInput, term); const rawColumn = Math.max(fromLine, fromPrompt); const predictedRow = Math.max(0, startRowY + Math.floor(rawColumn / cols)); // Only clamp when the terminal reports a real viewport height; missing // `rows` (tests/mocks) must not collapse every wrap onto row 0. return { column: rawColumn % cols, row: clampAutocompleteViewportRow(predictedRow, termRows), }; } /** Column-only helper for callers that do not need the predicted wrap row. */ export function resolveAutocompleteCursorColumn( term: XTerm, prompt: Pick, ): number { return resolveAutocompleteCursorCell(term, prompt).column; } /** Clamp autocomplete popups to the active terminal screen in split workspaces. * * Uses the visible `.xterm-screen` rect as the clamp boundary so the popup * never overflows the *actual* rendered terminal grid. The `.xterm-container` * can be a few pixels taller than the screen (rounding/padding), so falling * back to its rect produced a false positive `spaceBelow` at the bottom row * and caused short suggestion lists to flip downward below the visible area * (see issue #1710). */ export function resolveAutocompleteClampViewport(container: HTMLElement | null): PopupClampViewport { const pane = container?.closest('[data-section="terminal-split-pane"]'); const screen = container?.querySelector(".xterm-screen") ?? null; // Clamp to the rendered screen so the popup cannot spill past the visible // terminal rows. If the screen is not mounted yet, fall back to the split // pane/container rect or the full viewport. const rect = screen?.getBoundingClientRect() ?? pane?.getBoundingClientRect() ?? container?.getBoundingClientRect(); if (rect && rect.width > 0 && rect.height > 0) { return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, }; } return { left: 0, top: 0, width: typeof window !== "undefined" ? window.innerWidth : 1200, height: typeof window !== "undefined" ? window.innerHeight : 800, }; } /** * Resolve the autocomplete anchor in viewport coordinates so split panes and * padded xterm screens stay aligned with the real cursor. * * When `commandStartRow` is above `cursorRow` (a wrapped command), an * upward popup pins to the start row so it cannot cover the first physical * line after a wrap-induced scroll (#3061). Downward popups still pin to * the cursor row so they sit below the whole command. */ export function resolveAutocompleteAnchorInViewport( term: XTerm, container: HTMLElement | null, itemCount: number, cursorColumn = term.buffer.active.cursorX, cursorRow = term.buffer.active.cursorY, commandStartRow = cursorRow, ): AutocompleteViewportAnchor { const empty: AutocompleteViewportAnchor = { anchorLeft: 0, anchorTop: 0, anchorBottom: 0, expandUpward: false, }; if (!container || !term.element) return empty; const rows = Math.max(1, term.rows); const estimatedPopupHeight = estimatePopupHeight(itemCount); const dims = getXTermCellDimensions(term); const screen = container.querySelector(".xterm-screen") ?? term.element.querySelector(".xterm-screen") ?? container; const screenRect = screen.getBoundingClientRect(); const upwardRow = Math.min(commandStartRow, cursorRow); const downwardRow = Math.max(commandStartRow, cursorRow); const spaceBelow = Math.max(0, (rows - downwardRow - 1) * dims.height); const spaceAbove = Math.max(0, upwardRow * dims.height); const expandUpward = shouldExpandAutocompleteUpward( downwardRow, spaceBelow, spaceAbove, estimatedPopupHeight, ); const anchorRow = expandUpward ? upwardRow : downwardRow; const anchorLeft = screenRect.left + cursorColumn * dims.width; const anchorTop = screenRect.top + anchorRow * dims.height; const anchorBottom = screenRect.top + (anchorRow + 1) * dims.height; return { anchorLeft, anchorTop, anchorBottom, expandUpward, }; } /** Popup viewport anchor using the live wrapped command-start row (#3061). */ export function resolveAutocompletePopupAnchorInViewport( term: XTerm, container: HTMLElement | null, itemCount: number, cursorColumn: number, cursorRow: number, ): AutocompleteViewportAnchor { return resolveAutocompleteAnchorInViewport( term, container, itemCount, cursorColumn, cursorRow, resolveAutocompleteCommandStartRow(term), ); } /** * Next stored popup viewport when the command-start / cursor anchor moves. * Returns `prev` when nothing changed so callers can skip a React update. */ export function nextAutocompletePopupAnchorViewport( prev: { left: number; top: number; bottom: number }, expandUpward: boolean, anchor: AutocompleteViewportAnchor, ): { viewport: { left: number; top: number; bottom: number }; expandUpward: boolean } | null { const viewport = { left: anchor.anchorLeft, top: anchor.anchorTop, bottom: anchor.anchorBottom, }; if ( prev.left === viewport.left && prev.top === viewport.top && prev.bottom === viewport.bottom && expandUpward === anchor.expandUpward ) { return null; } return { viewport, expandUpward: anchor.expandUpward }; }