[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
610
components/terminal/autocomplete/terminalAutocompleteLayout.ts
Normal file
610
components/terminal/autocomplete/terminalAutocompleteLayout.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
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<PromptDetectionResult, "promptText" | "userInput">,
|
||||
): 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<PromptDetectionResult, "promptText" | "userInput">,
|
||||
): 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<HTMLElement>('[data-section="terminal-split-pane"]');
|
||||
const screen = container?.querySelector<HTMLElement>(".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<HTMLElement>(".xterm-screen")
|
||||
?? term.element.querySelector<HTMLElement>(".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 };
|
||||
}
|
||||
Reference in New Issue
Block a user