[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,613 @@
/**
* 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<HTMLDivElement | null>;
/** 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<SuggestionSource, { label: string; fullLabel: string; fallbackColor: string }> = {
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<string, { Icon: React.FC<{ size?: number; color?: string }>; 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 (
<span
style={{
width: "18px",
height: "18px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<cfg.Icon size={14} color={cfg.color} />
</span>
);
};
/** Chevron indicator for expandable directory items */
const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ visible, color }) => (
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}></span>
);
/** 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 }) => (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxSizing: "border-box",
height: "16px",
minWidth: "16px",
padding: "0 4px",
fontSize: "11px",
lineHeight: 1,
borderRadius: "4px",
border: `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
color: `color-mix(in srgb, ${color} 80%, ${bg})`,
backgroundColor: `color-mix(in srgb, ${color} 12%, ${bg})`,
flexShrink: 0,
fontFamily:
'ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif',
}}
>
{label}
</span>
);
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
suggestions,
selectedIndex,
anchorViewport,
visible,
expandUpward = false,
themeColors,
onSelect,
maxHeight = 240,
subDirPanels = [],
subDirFocusLevel = -1,
containerRef,
onRequestReposition,
searchBarOffset: _searchBarOffset = 30,
onDismiss,
}) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(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 (
<div
ref={wrapperRef}
style={{
position: "fixed",
left: `${finalGeometry.left}px`,
top: `${finalGeometry.top}px`,
zIndex: 10000,
display: "flex",
alignItems: renderUpward ? "flex-end" : "flex-start",
gap: "4px",
pointerEvents: "auto", // Re-enable on popup itself (parent is pointer-events-none)
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{/* Main suggestion list */}
<div
ref={listRef}
className="xterm-autocomplete-popup"
style={{
...sharedBoxStyle,
maxHeight: `${effectiveMaxHeight}px`,
minWidth: "180px",
maxWidth: "400px",
overflowY: "auto",
overflowX: "hidden",
padding: "4px 0",
userSelect: "none",
}}
>
{suggestions.map((suggestion, index) => {
const isSelected = index === selectedIndex;
const isHovered = index === hoveredIndex;
const sourceInfo = SOURCE_LABELS[suggestion.source];
return (
<div
key={`${suggestion.text}-${index}`}
ref={isSelected ? selectedRef : undefined}
style={{
display: "flex",
alignItems: "center",
padding: "5px 10px",
cursor: "pointer",
backgroundColor: isSelected ? selectedBg : isHovered ? hoverBg : "transparent",
// Accent rail on the active row so the highlight reads as the
// theme accent. Inset shadow avoids shifting row layout.
boxShadow: isSelected ? `inset 2px 0 0 0 ${selectedBorderAccent}` : undefined,
gap: "8px",
lineHeight: "1.4",
}}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(-1)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onSelect(suggestion);
}}
>
{/* Source / file type indicator */}
{suggestion.source === "path" && suggestion.fileType ? (
<FileTypeIcon fileType={suggestion.fileType} />
) : (
<span
role="img"
aria-label={sourceInfo.fullLabel}
title={sourceInfo.fullLabel}
style={{
width: "18px",
height: "18px",
borderRadius: "3px",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "10px",
fontWeight: 600,
color: sourceInfo.fallbackColor,
backgroundColor: `${sourceInfo.fallbackColor}15`,
flexShrink: 0,
}}
>
{sourceInfo.label}
</span>
)}
{/* Command text */}
<span
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: textColor,
fontWeight: isSelected ? 500 : 400,
}}
>
{suggestion.displayText}
</span>
{/* Inline description (truncated). Snippets show only their label
in the row — the full command lives in the detail preview. */}
{suggestion.source !== "snippet" && suggestion.description && (
<span
style={{
fontSize: "11px",
color: dimTextColor,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "160px",
flexShrink: 0,
}}
>
{suggestion.description}
</span>
)}
{/* Frequency badge for history */}
{suggestion.frequency && suggestion.frequency > 1 && (
<span
style={{
fontSize: "10px",
color: dimTextColor,
flexShrink: 0,
}}
>
×{suggestion.frequency}
</span>
)}
{/* Expand indicator for directories */}
{suggestion.source === "path" && suggestion.fileType === "directory" && (
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
)}
{/* Key hint on the selected row: → expands directories, ↵ runs. */}
{isSelected && (
<span style={{ display: "flex", gap: "3px", marginLeft: "4px", flexShrink: 0 }}>
{suggestion.source === "path" && suggestion.fileType === "directory" && (
<KeyCap label="→" color={dimTextColor} bg={popupBg} />
)}
<KeyCap label="⏎" color={dimTextColor} bg={popupBg} />
</span>
)}
</div>
);
})}
</div>
{/* Cascading sub-directory panels */}
{subDirPanels.map((panel, level) => (
<div
key={panel.dirPath}
style={{
...sharedBoxStyle,
maxHeight: `${effectiveMaxHeight}px`,
minWidth: "150px",
maxWidth: "240px",
overflowY: "auto",
overflowX: "hidden",
padding: "4px 0",
userSelect: "none",
alignSelf: "flex-start",
}}
>
{panel.entries.map((entry, idx) => {
const isFocused = level === subDirFocusLevel;
const isSubSelected = isFocused && idx === panel.selectedIndex;
return (
<div
key={entry.name}
ref={isSubSelected ? (el) => { 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();
}}
>
<FileTypeIcon fileType={entry.type} />
<span style={{
flex: 1, overflow: "hidden", textOverflow: "ellipsis",
whiteSpace: "nowrap", color: textColor,
}}>
{entry.name}{entry.type === "directory" ? "/" : ""}
</span>
{entry.type === "directory" && (
<DirExpandIndicator visible={isSubSelected || (idx === panel.selectedIndex && level < subDirFocusLevel)} color={dimTextColor} />
)}
</div>
);
})}
</div>
))}
{/* Detail tooltip panel — shows full description for non-path items */}
{showDetail && detailItem && detailItem.source !== "path" && (
<div
style={{
...sharedBoxStyle,
padding: "10px 12px",
maxWidth: "280px",
minWidth: "160px",
// Bound the tooltip too: a long multi-line snippet description must
// scroll, not push the panel past the viewport edge (#1202).
maxHeight: `${effectiveMaxHeight}px`,
overflowY: "auto",
alignSelf: renderUpward ? "flex-end" : "flex-start",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
<span style={{ fontWeight: 600, fontSize: "13px" }}>{detailItem.displayText}</span>
<span style={{
fontSize: "10px",
color: SOURCE_LABELS[detailItem.source].fallbackColor,
padding: "1px 5px",
borderRadius: "3px",
backgroundColor: `${SOURCE_LABELS[detailItem.source].fallbackColor}15`,
}}>
{SOURCE_LABELS[detailItem.source].fullLabel}
</span>
</div>
<div style={{ fontSize: "12px", color: dimTextColor, lineHeight: "1.5", wordBreak: "break-word" }}>
{detailItem.source === "snippet" ? (
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "var(--terminal-font, monospace)",
fontSize: "11px",
lineHeight: 1.4,
}}
>
{detailItem.description}
</pre>
) : (
detailItem.description
)}
</div>
</div>
)}
</div>
);
};
export default memo(AutocompletePopup);

View File

@@ -0,0 +1,537 @@
/**
* Ghost Text addon for xterm.js.
* Renders inline suggestion text after the cursor in a dimmed style,
* similar to fish shell's autosuggestions.
*
* Uses a CSS overlay positioned relative to the terminal cursor,
* avoiding modification of the terminal buffer.
*/
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
import { lineHasUntrackedTrailingInput } from "./ghostTextConsistency";
import { stringCellWidth } from "./terminalStringCellWidth";
function commonPrefixLength(a: string, b: string): number {
const max = Math.min(a.length, b.length);
let i = 0;
while (i < max && a[i] === b[i]) i += 1;
return i;
}
/** Longest prefix of `input` that is already a suffix of `beforeCursor`. */
function echoedInputPrefixLength(beforeCursor: string, input: string): number {
let n = Math.min(beforeCursor.length, input.length);
while (n > 0 && !beforeCursor.endsWith(input.slice(0, n))) {
n -= 1;
}
return n;
}
function hasVisibleGhostPrefix(ghostText: string, afterCursor: string): boolean {
if (!ghostText || !afterCursor) return false;
const visibleAfterCursor = afterCursor.trimEnd();
const overlap = commonPrefixLength(ghostText, visibleAfterCursor);
if (overlap <= 0) return false;
if (ghostText.slice(0, overlap).trim().length === 0) return false;
return (
overlap === ghostText.length ||
overlap === visibleAfterCursor.length ||
afterCursor[overlap] === " "
);
}
type BufferLineLike = {
isWrapped?: boolean;
translateToString?: (
trimRight?: boolean,
startColumn?: number,
endColumn?: number,
) => string;
};
type ActiveBufferLike = {
baseY: number;
cursorY: number;
cursorX: number;
getLine?: (y: number) => BufferLineLike | undefined;
};
/**
* Text before the cursor across wrapped physical rows. `getLine` only returns
* one row, so a wrapped command's current row cannot end with the full
* `currentInput` — callers must reconstruct the logical line or they will
* treat already-echoed text as unechoed.
*/
function readBeforeCursorAcrossWraps(
buf: ActiveBufferLike,
cols: number,
): string | null {
if (typeof buf.getLine !== "function") return null;
const absY = buf.baseY + buf.cursorY;
let line = buf.getLine(absY);
if (!line || typeof line.translateToString !== "function") return null;
// cursorX is a cell column, not a UTF-16 offset — slice() breaks on
// wide / multi-code-unit graphemes (emoji prompts, CJK).
let beforeCursor = line.translateToString(false, 0, buf.cursorX);
let y = absY;
while (line.isWrapped && y > 0) {
y -= 1;
line = buf.getLine(y);
if (!line || typeof line.translateToString !== "function") break;
// Keep wrap seams aligned with the terminal width (do not trimRight).
const rowCols = cols > 0 ? cols : undefined;
const rowText = rowCols === undefined
? line.translateToString(false)
: line.translateToString(false, 0, rowCols);
beforeCursor = rowText + beforeCursor;
}
return beforeCursor;
}
export class GhostTextAddon implements IDisposable {
private term: XTerm | null = null;
private ghostElement: HTMLSpanElement | null = null;
private hintElement: HTMLSpanElement | null = null;
private hintActive = false;
private containerElement: HTMLDivElement | null = null;
private currentSuggestion: string = "";
private currentInput: string = "";
/** Cursor column captured at show() time — the anchor the ghost was painted from. */
private anchorCursorX = 0;
/** Cursor row captured at show() time. */
private anchorCursorY = 0;
/** Length of currentInput at show() time — lets adjustToInput shift left
* by (newInput.length - anchorInputLength) cells without having to
* re-read xterm's cursorX (which hasn't advanced yet at keystroke time). */
private anchorInputLength = 0;
private disposed = false;
private disposables: IDisposable[] = [];
private lastLeft = -1;
private lastTop = -1;
activate(term: XTerm): void {
this.term = term;
const termElement = term.element;
if (!termElement) return;
this.containerElement = document.createElement("div");
this.containerElement.className = "xterm-ghost-text-container";
Object.assign(this.containerElement.style, {
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%",
pointerEvents: "none",
overflow: "hidden",
// Sit above xterm's canvas — xterm's default renderer paints its
// theme.background across every cell including empty ones, so a
// ghost placed beneath the canvas would be completely occluded.
zIndex: "1",
});
this.ghostElement = document.createElement("span");
this.ghostElement.className = "xterm-ghost-text";
Object.assign(this.ghostElement.style, {
position: "absolute",
opacity: "0.4",
pointerEvents: "none",
whiteSpace: "pre",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
color: "inherit",
display: "none",
});
this.containerElement.appendChild(this.ghostElement);
// Read-only inline hint (e.g. sudo "press Enter to paste password"). Shown
// independently of autocomplete suggestions and never accepted as input.
this.hintElement = document.createElement("span");
this.hintElement.className = "xterm-inline-hint";
Object.assign(this.hintElement.style, {
position: "absolute",
opacity: "0.4",
pointerEvents: "none",
whiteSpace: "pre",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
color: "inherit",
display: "none",
});
this.containerElement.appendChild(this.hintElement);
const screenEl = termElement.querySelector(".xterm-screen");
if (screenEl) {
screenEl.appendChild(this.containerElement);
} else {
termElement.appendChild(this.containerElement);
}
this.disposables.push(
term.onRender(() => {
if (this.hintActive) this.updateHintPosition();
if (!this.isVisible()) return;
// Fail-safe: if the device echoed input we didn't track (some bastion
// hosts / network OS, #1013/#1060), hide rather than draw the ghost
// over already-visible text. Done here (post-echo render) rather than
// in show()/adjustToInput so it never fights the keystroke-time path.
if (this.realLineHasUntrackedInput()) {
this.hide();
return;
}
this.updatePosition();
}),
);
// Invalidate cell dimension cache on resize so measurements stay
// accurate, and force a pixel-coord recompute on the next render —
// otherwise the lastLeft/lastTop short-circuit in updatePosition
// would keep the ghost at stale pixel coordinates until the user
// typed again.
this.disposables.push(
term.onResize(() => {
invalidateCellDimensionCache();
this.lastLeft = -1;
this.lastTop = -1;
if (this.isVisible()) this.updatePosition();
if (this.hintActive) this.updateHintPosition();
}),
);
}
/**
* Show ghost text suggestion.
* @param fullSuggestion The complete suggested command
* @param currentInput The text the user has typed so far
*/
show(fullSuggestion: string, currentInput: string): void {
if (this.disposed || !this.ghostElement || !this.term) return;
const ghostText = fullSuggestion.startsWith(currentInput)
? fullSuggestion.substring(currentInput.length)
: "";
if (!ghostText) {
this.hide();
return;
}
this.currentSuggestion = fullSuggestion;
this.currentInput = currentInput;
const buf = this.term.buffer.active;
const liveX = buf.cursorX;
// When show() runs before the shell echoes `currentInput` (CJK IME /
// high-latency SSH), live cursorX is still at the prompt. Advance the
// anchor by the pending input's cell width so the ghost sits after it
// instead of painting over it. Skip the probe when getLine is unavailable
// (unit fakes) so those tests keep the legacy "cursor already at end"
// contract.
let anchorX = liveX;
if (
currentInput.length > 0 &&
typeof buf.getLine === "function"
) {
const beforeCursor = readBeforeCursorAcrossWraps(
buf as ActiveBufferLike,
this.term.cols,
);
if (beforeCursor !== null && !beforeCursor.endsWith(currentInput)) {
// Shell may have echoed only a prefix (e.g. "$ doc" while
// currentInput is "docker"). Advance by the unechoed suffix only —
// adding the full input width on top of a partially-advanced liveX
// overshoots and Math.max self-heal cannot move the ghost left.
const unechoed = currentInput.slice(
echoedInputPrefixLength(beforeCursor, currentInput),
);
anchorX = liveX + stringCellWidth(unechoed, this.term);
}
}
this.anchorCursorX = anchorX;
this.anchorCursorY = buf.cursorY;
this.anchorInputLength = currentInput.length;
// Force position recalc since the text also changed.
this.lastLeft = -1;
this.lastTop = -1;
this.updatePosition();
this.ghostElement.textContent = ghostText;
this.ghostElement.style.display = "block";
// Set font properties once per show (not per frame in updatePosition)
this.ghostElement.style.fontSize = `${this.term.options.fontSize}px`;
this.ghostElement.style.fontFamily = this.term.options.fontFamily || "inherit";
}
hide(): void {
if (this.ghostElement) {
this.ghostElement.style.display = "none";
this.ghostElement.textContent = "";
}
this.currentSuggestion = "";
this.currentInput = "";
this.anchorInputLength = 0;
}
/** Show a read-only inline hint at the cursor (e.g. a sudo password prompt
* hint). Independent of autocomplete suggestions; never accepted as input. */
showHint(text: string): void {
if (this.disposed || !this.hintElement || !this.term) return;
this.hintActive = true;
this.hintElement.textContent = text;
this.hintElement.style.display = "block";
this.hintElement.style.fontSize = `${this.term.options.fontSize}px`;
this.hintElement.style.fontFamily = this.term.options.fontFamily || "inherit";
this.updateHintPosition();
}
hideHint(): void {
this.hintActive = false;
if (this.hintElement) {
this.hintElement.style.display = "none";
this.hintElement.textContent = "";
}
}
isHintActive(): boolean {
return this.hintActive;
}
private updateHintPosition(): void {
if (!this.term || !this.hintElement) return;
const dims = getXTermCellDimensions(this.term);
const buf = this.term.buffer.active;
this.hintElement.style.left = `${buf.cursorX * dims.width}px`;
this.hintElement.style.top = `${buf.cursorY * dims.height}px`;
this.hintElement.style.lineHeight = `${dims.height}px`;
this.hintElement.style.height = `${dims.height}px`;
}
/**
* Re-align the ghost against a freshly-updated user input synchronously.
* Called from handleInput on every keystroke that mutates the typed
* buffer so ghost text never falls out of sync with what the user has
* actually typed.
*
* Implementation relies on the predict-anchor-shift trick rather than
* re-reading xterm's live cursorX: xterm hasn't echoed the triggering
* keystroke yet at this point, so cursorX still points at the
* pre-keystroke column. Instead we track the cursor column captured
* at show() time and advance the ghost's left by the number of chars
* typed since — so the tail aligns with where the real cursor *will*
* land once the echo arrives, even across SSH round-trip latency.
*/
adjustToInput(newInput: string): void {
if (this.disposed || !this.ghostElement || !this.currentSuggestion) return;
if (!this.currentSuggestion.startsWith(newInput)) {
this.hide();
return;
}
this.currentInput = newInput;
const ghostText = this.currentSuggestion.substring(newInput.length);
if (!ghostText) {
this.hide();
return;
}
// Force position recomputation — updatePosition skips DOM writes
// when the left/top cache hasn't changed, but we also need the new
// textContent to flush.
this.lastLeft = -1;
this.lastTop = -1;
this.ghostElement.textContent = ghostText;
this.updatePosition();
this.ghostElement.style.display = "block";
}
/**
* Apply a single keystroke's effect to the ghost without consulting the
* outer typed-input buffer. Used when that buffer's reliability flag is
* off (post-Tab, history recall, cursor moves) — without this hook the
* gate at handleInput's adjustToInput call would freeze the ghost at
* the previous show()'s tail, and a subsequent → -accept would paste
* that stale tail on top of the chars typed in the meantime
* (sttop/dduplicate-glyph bug, issue #906).
*
* Only forwards events the ghost can locally re-derive: a printable
* char appends, Backspace/DEL slices off one char, Ctrl-W performs
* the same trailing-word erase as zsh/bash. Anything else (escape
* sequences, other control codes) is treated as a no-op — those
* paths already clearState() in handleInput, so by the time the user
* could trigger an accept, the ghost is gone.
*/
applyKeystroke(data: string): void {
if (this.disposed || !this.currentSuggestion || !data) return;
let nextInput: string;
if (data === "\x7f" || data === "\b") {
if (this.currentInput.length === 0) return;
nextInput = this.currentInput.slice(0, -1);
} else if (data === "\x17") {
const erased = this.currentInput.replace(/\s*\S+\s*$/, "");
if (erased === this.currentInput) return;
nextInput = erased;
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
nextInput = this.currentInput + data;
} else {
return;
}
this.adjustToInput(nextInput);
}
getSuggestion(): string {
return this.currentSuggestion;
}
isVisible(): boolean {
return !!(this.ghostElement && this.ghostElement.style.display !== "none" &&
this.currentSuggestion);
}
/**
* True when the ghost has a live suggestion even if it's momentarily
* shown underneath the real text while the user keeps typing within
* the prediction. Accept-path gates should use this instead of
* isVisible() so the suggestion remains available even while its
* leading characters are fully covered by real glyphs.
*/
isActive(): boolean {
return !this.disposed && !!this.currentSuggestion;
}
getGhostText(): string {
if (!this.currentSuggestion) return "";
return this.currentSuggestion.startsWith(this.currentInput)
? this.currentSuggestion.substring(this.currentInput.length)
: "";
}
getNextWord(): string {
const ghost = this.getGhostText();
if (!ghost) return "";
const trimmed = ghost.replace(/^\s+/, "");
const leadingSpace = ghost.length - trimmed.length;
if (trimmed.length === 0) return ghost; // Only whitespace
// Search for word boundary starting from index 1 (skip leading separator chars like /)
const wordEnd = trimmed.substring(1).search(/[\s/\\-]/);
if (wordEnd < 0) return ghost; // Single word, accept all
// Include leading whitespace + the word up to (and including) the separator
return ghost.substring(0, leadingSpace + 1 + wordEnd + 1);
}
/**
* True when the real terminal line has input we did not track, or already
* visible text exactly matches the ghost we are about to paint. See
* ./ghostTextConsistency and issues #1013 and #1060. Returns false on
* hosts/inputs we can't judge (non-ASCII, echo still catching up), so the
* ghost only gets suppressed when corruption is actually imminent.
*/
private realLineHasUntrackedInput(): boolean {
if (!this.term) return false;
const buf = this.term.buffer.active;
if (typeof buf?.getLine !== "function") return false;
const line = buf.getLine(buf.baseY + buf.cursorY);
if (!line || typeof line.translateToString !== "function") return false;
const lineText = line.translateToString(false);
const beforeCursor = lineText.slice(0, buf.cursorX);
const afterCursor = lineText.slice(buf.cursorX);
const ghostText = this.getGhostText();
if (hasVisibleGhostPrefix(ghostText, afterCursor)) return true;
if (!this.currentInput) return false;
return lineHasUntrackedTrailingInput(this.currentInput, beforeCursor);
}
private updatePosition(): void {
if (!this.term || !this.ghostElement) return;
// Self-heal a stale anchor: when show() fired during the SSH
// keystroke→echo gap without a line probe, cursorX may still be the
// pre-echo column. While no adjustToInput has moved us from the
// show-time baseline, adopt a live cursor that has advanced (echo
// caught up). Use max on the same row so a cell-width-predicted
// pre-echo anchor is not collapsed back onto the prompt before echo
// arrives. When the live row advances, the predicted X may already
// encode a wrap (column >= cols); adopting the live X/Y pair avoids
// counting that wrap again in the modulo math below.
// When the predicted wrap happens on the bottom row, the echo scrolls
// the buffer and Y stays put — adopt live X/Y once it matches the
// normalized wrap column so Math.max cannot keep the unnormalized X.
if (this.currentInput.length === this.anchorInputLength) {
const liveX = this.term.buffer.active.cursorX;
const liveY = this.term.buffer.active.cursorY;
const cols = Math.max(1, this.term.cols);
if (liveY !== this.anchorCursorY) {
this.anchorCursorX = liveX;
this.anchorCursorY = liveY;
} else if (
this.anchorCursorX >= cols &&
liveX === this.anchorCursorX % cols
) {
this.anchorCursorX = liveX;
this.anchorCursorY = liveY;
} else {
this.anchorCursorX = Math.max(this.anchorCursorX, liveX);
}
}
const dims = getXTermCellDimensions(this.term);
// Advance (or walk back) the anchor column by the cell width of
// whatever the user has typed since show() was called. Using cell
// width (not code-unit length) lets CJK / emoji / fullwidth glyphs
// advance by 2 cells instead of 1. Backspace / Ctrl-W produces a
// negative delta by shrinking currentInput below anchorInputLength.
const cellDelta = this.currentInput.length >= this.anchorInputLength
? stringCellWidth(this.currentInput.slice(this.anchorInputLength), this.term)
: -stringCellWidth(
// currentSuggestion[0..anchorInputLength] equals what was typed
// when show() fired (prefix-match invariant), so its slice gives
// the correct cell widths for the deleted glyphs.
this.currentSuggestion.slice(this.currentInput.length, this.anchorInputLength),
this.term,
);
const cols = Math.max(1, this.term.cols);
const targetCol = this.anchorCursorX + cellDelta;
// Wrap the predicted cursor position across line boundaries in both
// directions — the real xterm cursor wraps to the next row once it
// crosses cols forward, and to the previous row when a deletion
// crosses back past column 0. JS `%` returns negative for negative
// dividends, so normalize both col and rowOffset explicitly.
let col = targetCol % cols;
let rowOffset = Math.floor(targetCol / cols);
if (col < 0) {
col += cols;
}
// Clamp to the visible top row so a runaway negative delta (e.g.
// deleted past the prompt) doesn't render above the terminal.
const top = Math.max(0, this.anchorCursorY + rowOffset) * dims.height;
const left = col * dims.width;
// Skip DOM writes if position hasn't changed (avoids unnecessary style recalc)
if (left === this.lastLeft && top === this.lastTop) return;
this.lastLeft = left;
this.lastTop = top;
this.ghostElement.style.left = `${left}px`;
this.ghostElement.style.top = `${top}px`;
this.ghostElement.style.lineHeight = `${dims.height}px`;
this.ghostElement.style.height = `${dims.height}px`;
}
dispose(): void {
this.disposed = true;
for (const d of this.disposables) d.dispose();
this.disposables = [];
this.containerElement?.remove();
this.containerElement = null;
this.ghostElement = null;
this.hintElement = null;
this.term = null;
}
}

View File

@@ -0,0 +1,439 @@
/**
* Persistent command history store for terminal autocomplete.
* Stores commands per host with frequency tracking and timestamp ordering.
* Uses localStorageAdapter as the persistence layer (works in renderer process).
*/
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
const STORAGE_KEY = "netcatty:commandHistory";
const MAX_ENTRIES = 10000;
const MAX_ENTRIES_PER_HOST = 5000;
export interface HistoryEntry {
command: string;
hostId: string;
/** OS type for cross-host matching */
os: "linux" | "windows" | "macos";
/** Number of times this exact command was executed */
frequency: number;
/** Timestamp of last execution */
lastUsedAt: number;
/** Timestamp of first execution */
createdAt: number;
}
interface HistoryStore {
entries: HistoryEntry[];
version: number;
}
let cachedStore: HistoryStore | null = null;
function loadStore(): HistoryStore {
if (cachedStore) return cachedStore;
try {
const parsed = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (parsed) {
cachedStore = parsed;
return parsed;
}
} catch {
// Corrupted data, reset
}
cachedStore = { entries: [], version: 1 };
return cachedStore;
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function persistStoreNow(store: HistoryStore): boolean {
const ok = localStorageAdapter.write(STORAGE_KEY, store);
if (ok) return true;
// Storage full — evict lowest scored entries (not just oldest by insertion)
const now = Date.now();
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
return localStorageAdapter.write(STORAGE_KEY, store);
}
function saveStore(store: HistoryStore): void {
cachedStore = store;
// Debounce saves to avoid excessive writes
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
persistStoreNow(store);
saveTimer = null;
}, 500);
}
/**
* Flush any pending debounced history write immediately.
* Used after bulk imports (e.g. local histfile seeding) so a seed-complete
* flag is not persisted before the imported commands land in storage.
* Returns false when the write could not be persisted.
*/
export function flushCommandHistoryStore(): boolean {
if (!cachedStore) return true;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
return persistStoreNow(cachedStore);
}
/**
* Record a command execution. Updates frequency if the command already exists
* for this host, otherwise creates a new entry.
*/
export function recordCommand(
command: string,
hostId: string,
os: "linux" | "windows" | "macos" = "linux",
): void {
const trimmed = command.trim();
if (!trimmed || trimmed.length > 2000) return;
const store = loadStore();
const now = Date.now();
// Find existing entry for same command + host
const existingIdx = store.entries.findIndex(
(e) => e.command === trimmed && e.hostId === hostId,
);
if (existingIdx >= 0) {
store.entries[existingIdx].frequency++;
store.entries[existingIdx].lastUsedAt = now;
} else {
store.entries.push({
command: trimmed,
hostId,
os,
frequency: 1,
lastUsedAt: now,
createdAt: now,
});
}
// Enforce per-host limit (evict by score, not insertion order)
const hostEntries = store.entries.filter((e) => e.hostId === hostId);
if (hostEntries.length > MAX_ENTRIES_PER_HOST) {
hostEntries.sort((a, b) => scoreEntryAt(a, now) - scoreEntryAt(b, now));
const toRemove = new Set(
hostEntries.slice(0, hostEntries.length - MAX_ENTRIES_PER_HOST).map((e) => e.command),
);
store.entries = store.entries.filter(
(e) => e.hostId !== hostId || !toRemove.has(e.command),
);
}
// Enforce global limit
if (store.entries.length > MAX_ENTRIES) {
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
store.entries = store.entries.slice(0, MAX_ENTRIES);
}
saveStore(store);
}
/** Remove one command from autocomplete history for a specific host. */
export function removeCommandHistoryEntry(command: string, hostId: string): boolean {
const trimmed = command.trim();
if (!trimmed) return false;
const store = loadStore();
const nextEntries = store.entries.filter(
(entry) => entry.command !== trimmed || entry.hostId !== hostId,
);
if (nextEntries.length === store.entries.length) return false;
store.entries = nextEntries;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
return persistStoreNow(store);
}
/**
* Score an entry for ranking at a specific timestamp.
* Caches Date.now() at query boundaries to avoid repeated syscalls during sort.
*/
function scoreEntryAt(entry: HistoryEntry, now: number): number {
const ageMs = now - entry.lastUsedAt;
const ageHours = ageMs / (1000 * 60 * 60);
// Exponential decay: halve relevance every 24 hours
const recencyScore = Math.pow(0.5, ageHours / 24);
return entry.frequency * recencyScore;
}
export interface HistoryQueryOptions {
/** Filter by host ID (strict isolation — only this host's history) */
hostId?: string;
/** Maximum number of results */
limit?: number;
}
export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
/** Base command name, e.g. `cd` or `ls` */
commandName: string;
/** Exact command text to exclude from results */
excludeCommand?: string;
/** Optional path prefix to require on the current argument */
argumentPrefix?: string;
}
/**
* Query history entries matching a prefix.
* Returns entries sorted by relevance (frequency * recency).
*/
export function queryHistory(
prefix: string,
options: HistoryQueryOptions = {},
): HistoryEntry[] {
const { hostId, limit = 20 } = options;
if (limit <= 0) return [];
const store = loadStore();
const lowerPrefix = prefix.toLowerCase();
const now = Date.now(); // Cache once per query
const filtered = store.entries.filter((entry) => {
// Must match prefix
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
// Must not be identical to prefix
if (entry.command === prefix) return false;
// Host filtering: strict per-host isolation
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
// Sort by score (frequency * recency)
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
// Deduplicate by command text (keep highest scored)
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
/**
* Fuzzy query: matches commands containing all characters of the query
* in order (not necessarily contiguous). Used as a fallback when prefix
* matching yields few results.
*/
export function fuzzyQueryHistory(
query: string,
options: HistoryQueryOptions = {},
): HistoryEntry[] {
const { hostId, limit = 10 } = options;
if (limit <= 0) return [];
const store = loadStore();
const lowerQuery = query.toLowerCase();
const now = Date.now(); // Cache once per query
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
for (const entry of store.entries) {
// Host filtering
if (hostId) {
if (entry.hostId !== hostId) continue;
}
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
if (matchScore > 0 && entry.command !== query) {
scored.push({ entry, matchScore });
}
}
scored.sort((a, b) =>
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const { entry } of scored) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
/**
* Query the most recently used history entries for the same command name.
* Useful when the user is currently completing a path argument and wants
* a few recent command-line examples (e.g. recent `cd ...` commands).
*/
export function queryRecentHistoryByCommand(
options: RecentHistoryQueryOptions,
): HistoryEntry[] {
const {
commandName,
excludeCommand,
argumentPrefix,
hostId,
limit = 3,
} = options;
if (!commandName || limit <= 0) return [];
const store = loadStore();
const trimmedCommandName = commandName.trim().toLowerCase();
const commandPrefix = `${trimmedCommandName} `;
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
const filtered = store.entries.filter((entry) => {
const lowerCommand = entry.command.toLowerCase();
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
return false;
}
if (excludeCommand && entry.command === excludeCommand) return false;
if (normalizedArgumentPrefix) {
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
return false;
}
}
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
function getCurrentCommandToken(command: string): string {
const tokens = tokenizeShellLike(command);
return tokens.length > 0 ? (tokens[tokens.length - 1] || "") : "";
}
function normalizeArgumentToken(token: string): string {
return token
.trim()
.replace(/^['"]/, "")
.replace(/['"]$/, "")
.replace(/\\ /g, " ")
.toLowerCase();
}
function tokenizeShellLike(input: string): string[] {
const tokens: string[] = [];
let current = "";
let inSingleQuote = false;
let inDoubleQuote = false;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (escaped) {
current += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
current += ch;
continue;
}
if (ch === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
current += ch;
continue;
}
if (ch === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
current += ch;
continue;
}
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
if (current.length > 0) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
tokens.push(current);
return tokens;
}
/**
* Compute a fuzzy match score. Returns 0 for no match.
* Higher score = better match quality.
* Rewards: first-char match, consecutive matches, word-boundary matches.
*/
function fuzzyScore(query: string, target: string): number {
if (query.length === 0) return 0;
if (query.length > target.length) return 0;
let score = 0;
let queryIdx = 0;
let prevMatchIdx = -2;
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
if (target[i] === query[queryIdx]) {
queryIdx++;
// First character bonus
if (i === 0) score += 10;
// Consecutive match bonus
if (i === prevMatchIdx + 1) score += 5;
// Word boundary bonus
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
target[i - 1] === "-" || target[i - 1] === "_") {
score += 3;
}
score += 1;
prevMatchIdx = i;
}
}
// All query characters must be matched
return queryIdx === query.length ? score : 0;
}
/**
* Clear all history for a specific host, or all history if no hostId given.
*/
export function clearHistory(hostId?: string): void {
const store = loadStore();
if (hostId) {
store.entries = store.entries.filter((e) => e.hostId !== hostId);
} else {
store.entries = [];
}
saveStore(store);
}

View File

@@ -0,0 +1,761 @@
/**
* Context-aware completion engine.
* Combines multiple data sources:
* 1. Context-aware path completions and @withfig/autocomplete specs
* 2. Command history
* 3. Fuzzy history matching (fallback)
*
* Parses the current command line to determine context (command, subcommand,
* option, or argument position) and provides appropriate suggestions.
*/
import {
queryHistory,
queryRecentHistoryByCommand,
fuzzyQueryHistory,
type HistoryQueryOptions,
} from "./commandHistoryStore";
import {
loadSpec,
hasSpec,
getAvailableSpecs,
normalizeCommandName,
resolveNames,
type FigSpec,
type FigSubcommand,
type FigOption,
} from "./figSpecLoader";
import {
shouldDoPathCompletion,
getPathSuggestions,
resolvePathComponents,
} from "./remotePathCompleter";
import { getSnippetSuggestions } from "./snippetCompleter";
import type { AutocompleteHistoryScope, Snippet } from "../../../domain/models";
import type { AutocompleteCwdSource } from "./terminalAutocompleteLayout";
/** Source indicator for where a suggestion came from */
export type SuggestionSource = "history" | "command" | "subcommand" | "option" | "arg" | "path" | "snippet" | "plugin";
export interface CompletionSuggestion {
/** The text to insert */
text: string;
/** Display text (may differ from insert text) */
displayText: string;
/** Optional description */
description?: string;
/** Source of this suggestion */
source: SuggestionSource;
/** Relevance score (higher = more relevant) */
score: number;
/** For history entries: execution frequency */
frequency?: number;
/** Matching rule used by recent history surfaced during path completion. */
historyMatch?: "path-argument";
/** For path suggestions: file type */
fileType?: "file" | "directory" | "symlink";
/** For snippet suggestions: the source snippet (used by the accept path). */
snippet?: Snippet;
/** For plugin suggestions: the owning Provider contribution. */
providerId?: string;
}
export interface CompletionContext {
/** Full command line text */
commandLine: string;
/** Current word being typed */
currentWord: string;
/** Index of the current word in the parsed tokens */
wordIndex: number;
/** Parsed command tokens */
tokens: string[];
/** The base command name (first token) */
commandName: string;
/** Whether the current position is after a recognized option that expects an argument */
isOptionArg: boolean;
}
/**
* Soft wait for remote/local path listings. History, fig specs, and snippets are
* local and should paint without waiting on high-latency SSH exec (#2830).
* Timed-out listings still finish in the background: cacheable paths warm the
* shared cache, and cache-bypassed relative SSH paths notify via onLateResult
* so the UI can merge path suggestions when the listing finally resolves.
*/
export const PATH_COMPLETION_BUDGET_MS = 150;
type PathSuggestionEntry = { name: string; type: "file" | "directory" | "symlink" };
/** @internal Exported for unit tests covering the soft path-listing budget. */
export async function getPathSuggestionsWithinBudget(
pathPromise: Promise<PathSuggestionEntry[]>,
budgetMs: number,
onLateResult?: (entries: PathSuggestionEntry[]) => void,
): Promise<PathSuggestionEntry[]> {
if (!Number.isFinite(budgetMs) || budgetMs < 0) {
return pathPromise;
}
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
const raced = await Promise.race([
// Settle rejections here so a late failure after timeout cannot surface
// as an unhandled rejection from the losing Promise.race branch.
pathPromise.then(
(entries) => ({ kind: "entries" as const, entries }),
() => ({ kind: "entries" as const, entries: [] as PathSuggestionEntry[] }),
),
new Promise<{ kind: "timeout" }>((resolve) => {
timeoutId = setTimeout(() => resolve({ kind: "timeout" }), budgetMs);
}),
]);
if (raced.kind === "entries") return raced.entries;
// Keep the listing in flight. Cacheable paths warm the shared cache for a
// later keystroke; bypassed relative SSH paths have no cache, so surface
// the late result to the caller instead of discarding it.
void pathPromise.then(
(entries) => {
if (entries.length > 0) onLateResult?.(entries);
},
() => {},
);
return [];
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId);
}
}
function buildPathCompletionSuggestions(
ctx: CompletionContext,
pathEntries: PathSuggestionEntry[],
cwd: string | undefined,
): CompletionSuggestion[] {
if (pathEntries.length === 0) return [];
const { pathPrefix, quoteSuffix } = resolvePathComponents(ctx.currentWord, cwd);
const isQuotedPath = ctx.currentWord.startsWith('"') || ctx.currentWord.startsWith("'");
const suggestions: CompletionSuggestion[] = [];
for (const entry of pathEntries) {
const insertName = isQuotedPath || !/[\\$'"|!<>;#~` ]/.test(entry.name)
? entry.name
: shellEscape(entry.name);
const suffix = entry.type === "directory" ? "/" : "";
const fullPath = pathPrefix + insertName + suffix + quoteSuffix;
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, fullPath),
displayText: entry.name + suffix,
source: "path",
score: 750,
fileType: entry.type,
});
}
return suggestions;
}
interface SpecSuggestionResult {
suggestions: CompletionSuggestion[];
pathArgs?: FigSubcommand["args"];
}
export function shellEscape(name: string): string {
if (!name) return name;
if (/[\\$'"|!<>;#~` ]/.test(name)) {
return `'${name.replace(/'/g, "'\\''")}'`;
}
return name;
}
/**
* Parse a command line string into tokens, handling quoting.
*/
function tokenize(input: string): string[] {
const tokens: string[] = [];
let current = "";
let inSingleQuote = false;
let inDoubleQuote = false;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (escaped) {
current += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
current += ch;
continue;
}
if (ch === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
current += ch;
continue;
}
if (ch === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
current += ch;
continue;
}
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
if (current.length > 0) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
// Always include the last token (even if empty, to indicate trailing space)
tokens.push(current);
return tokens;
}
/**
* Parse the current command line into a CompletionContext.
*/
export function parseCommandLine(input: string): CompletionContext {
const tokens = tokenize(input);
const wordIndex = tokens.length - 1;
const currentWord = tokens[wordIndex] || "";
const commandName = tokens.length > 0 ? normalizeCommandName(tokens[0]) : "";
return {
commandLine: input,
currentWord,
wordIndex,
tokens,
commandName,
isOptionArg: false,
};
}
/**
* Main completion function. Returns sorted suggestions from all sources.
* Ghost text should use completions[0].text instead of a separate query.
*/
export async function getCompletions(
input: string,
options: {
hostId?: string;
hostGroup?: string;
os?: "linux" | "windows" | "macos";
maxResults?: number;
/** Session ID for remote path completion */
sessionId?: string;
/** Connection protocol (ssh, local, telnet, serial) */
protocol?: string;
/** Current working directory (from OSC 7) */
cwd?: string;
cwdSource?: AutocompleteCwdSource;
/** Custom snippets to surface at the command position */
snippets?: Snippet[];
/** Which history pool to query (default: current host only). */
historyScope?: AutocompleteHistoryScope;
/**
* Soft budget for path listings (ms). Local suggestions return when this
* elapses even if remote `find` is still running. Use `Infinity` in tests
* that need the full remote listing.
*/
pathBudgetMs?: number;
/**
* Invoked when a path listing finishes after the soft budget elapsed.
* Needed for cache-bypassed relative SSH cwd lookups, which cannot warm
* the shared directory cache for a later keystroke.
*/
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
} = {},
): Promise<CompletionSuggestion[]> {
const { hostId, maxResults = 15, historyScope = "host" } = options;
const pathBudgetMs = options.pathBudgetMs ?? PATH_COMPLETION_BUDGET_MS;
if (!input || input.trim().length === 0) return [];
const ctx = parseCommandLine(input);
const specResult: SpecSuggestionResult = ctx.commandName && ctx.wordIndex >= 0
? await getSpecSuggestions(ctx)
: { suggestions: [] };
const suggestions: CompletionSuggestion[] = [];
const seenSuggestionTexts = new Set<string>();
const pathCheck = ctx.commandName && ctx.wordIndex >= 1
? shouldDoPathCompletion(ctx, specResult.pathArgs)
: { shouldComplete: false, foldersOnly: false };
const preferPathSuggestions = pathCheck.shouldComplete;
const resultLimit = preferPathSuggestions ? Math.max(maxResults, 24) : maxResults;
// History queries honor historyScope; snippets still stay host-scoped.
const historyHostId = historyScope === "global" ? undefined : hostId;
// 1. History suggestions (full command line prefix match)
// Cap history to leave room for spec suggestions in the popup
const historyOpts: HistoryQueryOptions = {
hostId: historyHostId,
limit: preferPathSuggestions ? 0 : 5,
};
const historyMatches = queryHistory(input, historyOpts);
for (const entry of historyMatches) {
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 1000 + entry.frequency,
frequency: entry.frequency,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
if (preferPathSuggestions && ctx.commandName) {
// When path completion is active (file-related commands like cat, vim, cd),
// recent history is still useful but should rank below actual path matches
// from the current directory.
const recentHistory = queryRecentHistoryByCommand({
commandName: ctx.commandName,
excludeCommand: input,
argumentPrefix: normalizeHistoryPathPrefix(ctx.currentWord),
hostId: historyHostId,
limit: 5,
});
for (let index = 0; index < recentHistory.length; index++) {
const entry = recentHistory[index];
if (seenSuggestionTexts.has(entry.command)) continue;
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 720 - index,
frequency: entry.frequency,
historyMatch: "path-argument",
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
}
const canQueryPaths = options.protocol === "local" || options.sessionId !== undefined;
const pathEntries = canQueryPaths && pathCheck.shouldComplete
? await getPathSuggestionsWithinBudget(
getPathSuggestions(ctx, {
sessionId: options.sessionId,
protocol: options.protocol,
os: options.os,
cwd: options.cwd,
cwdSource: options.cwdSource,
foldersOnly: pathCheck.foldersOnly,
}),
pathBudgetMs,
(lateEntries) => {
if (!options.onLatePathSuggestions) return;
const latePathSuggestions = buildPathCompletionSuggestions(
ctx,
lateEntries,
options.cwd,
);
if (latePathSuggestions.length > 0) {
options.onLatePathSuggestions(latePathSuggestions);
}
},
)
: [];
for (const suggestion of specResult.suggestions) {
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
for (const suggestion of buildPathCompletionSuggestions(ctx, pathEntries, options.cwd)) {
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
// 3. Fuzzy history fallback while typing the command name. Once arguments
// are present, history completion is prefix-only: fuzzy matching the whole
// line can borrow characters from later paths and keep an incompatible
// middle argument visible (issue #3088).
if (
ctx.wordIndex === 0 &&
!preferPathSuggestions &&
suggestions.length < 3 &&
input.length >= 2
) {
const fuzzyMatches = fuzzyQueryHistory(input, {
...historyOpts,
limit: 5,
});
for (const entry of fuzzyMatches) {
if (seenSuggestionTexts.has(entry.command)) continue;
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 500 + entry.frequency,
frequency: entry.frequency,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
}
// Snippets: only at the command position (typing the command name).
// Push without the early seen-text skip: snippets score above history, so if
// a snippet's label collides with an existing history entry's text, the
// score-sort + final dedup below keeps the snippet (the higher-scored one).
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, {
hostId,
hostGroup: options.hostGroup,
})) {
suggestions.push(snippetSuggestion);
}
}
// Sort by score descending
suggestions.sort((a, b) => b.score - a.score);
// Deduplicate
const seen = new Set<string>();
const unique: CompletionSuggestion[] = [];
for (const s of suggestions) {
if (seen.has(s.text)) continue;
seen.add(s.text);
unique.push(s);
if (unique.length >= resultLimit) break;
}
return unique;
}
function normalizeHistoryPathPrefix(token: string): string {
return token
.trim()
.replace(/^['"]/, "")
.replace(/['"]$/, "")
.replace(/\\ /g, " ");
}
/**
* Get suggestions from Fig spec + return resolved args (for path detection reuse).
*/
async function getSpecSuggestions(ctx: CompletionContext): Promise<SpecSuggestionResult> {
const suggestions: CompletionSuggestion[] = [];
const specAvailable = await hasSpec(ctx.commandName);
if (!specAvailable) {
if (ctx.wordIndex === 0 && ctx.currentWord.length >= 1) {
return { suggestions: await getCommandNameSuggestions(ctx.currentWord) };
}
return { suggestions };
}
const spec = await loadSpec(ctx.commandName);
if (!spec) return { suggestions };
// If we're still typing the command name (partial match, not yet complete)
if (ctx.wordIndex === 0) {
const typedLower = ctx.currentWord.toLowerCase();
const specNames = resolveNames(spec.name);
const isExactMatch = specNames.some((n) => n.toLowerCase() === typedLower);
if (!isExactMatch) return { suggestions };
// Show subcommands as preview (user typed full command but no space yet)
if (spec.subcommands) {
for (const sub of spec.subcommands) {
const names = resolveNames(sub.name);
suggestions.push({
text: ctx.currentWord + " " + names[0],
displayText: names[0],
description: sub.description,
source: "subcommand",
score: 800,
});
if (suggestions.length >= 10) break;
}
}
return { suggestions };
}
// Navigate the spec tree based on typed tokens
const resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
const currentToken = ctx.currentWord;
// Check if currentToken exactly matches a subcommand — if so, navigate into it
// and show its children as preview (e.g., "git commit" shows commit's options)
if (currentToken && resolved.subcommands) {
const exactMatch = resolved.subcommands.find((s) => {
const names = resolveNames(s.name);
return names.includes(currentToken);
});
if (exactMatch) {
// Navigate into the matched subcommand and show its children
const childResolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex + 1));
// Show child subcommands
if (childResolved.subcommands) {
for (const sub of childResolved.subcommands) {
const names = resolveNames(sub.name);
suggestions.push({
text: ctx.commandLine + " " + names[0],
displayText: names[0],
description: sub.description,
source: "subcommand",
score: 800,
});
if (suggestions.length >= 10) break;
}
}
// Show child options
appendOptionPreviewSuggestions(
suggestions,
ctx.commandLine,
childResolved.options?.length ? childResolved.options : childResolved.fallbackOptions,
15,
);
return { suggestions };
}
}
// Suggest subcommands (prefix match, excluding exact matches)
if (resolved.subcommands) {
for (const sub of resolved.subcommands) {
const names = resolveNames(sub.name);
for (const name of names) {
if (name.startsWith(currentToken) && name !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
displayText: name,
description: sub.description,
source: "subcommand",
score: 800,
});
}
}
}
}
// Suggest options
const hasDirectOptionSuggestions = appendOptionSuggestions(
suggestions,
ctx,
currentToken,
resolved.options,
);
if (!hasDirectOptionSuggestions) {
appendOptionSuggestions(suggestions, ctx, currentToken, resolved.fallbackOptions);
}
// Suggest argument values from suggestions in the spec
if (resolved.args) {
const args = Array.isArray(resolved.args) ? resolved.args : [resolved.args];
for (const arg of args) {
if (arg.suggestions) {
for (const sug of arg.suggestions) {
const sugName = typeof sug === "string" ? sug : (Array.isArray(sug.name) ? sug.name[0] : sug.name);
const sugDesc = typeof sug === "string" ? undefined : sug.description;
if (sugName.startsWith(currentToken) && sugName !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, sugName),
displayText: sugName,
description: sugDesc,
source: "arg",
score: 600,
});
}
}
}
}
}
return {
suggestions,
pathArgs: resolved.args,
};
}
/**
* Get command name suggestions by matching against available specs.
* Uses the already-imported getAvailableSpecs directly (no dynamic self-import).
*/
async function getCommandNameSuggestions(prefix: string): Promise<CompletionSuggestion[]> {
const specs = await getAvailableSpecs();
const lower = prefix.toLowerCase();
const suggestions: CompletionSuggestion[] = [];
for (const name of specs) {
// Skip sub-path specs like "aws/s3", "dotnet/dotnet-build" — not direct shell commands
if (name.includes("/")) continue;
if (name.startsWith(lower) && name !== lower) {
suggestions.push({
text: name,
displayText: name,
source: "command",
score: 600,
});
if (suggestions.length >= 10) break;
}
}
return suggestions;
}
interface ResolvedContext {
subcommands?: FigSubcommand[];
options?: FigOption[];
fallbackOptions?: FigOption[];
args?: FigSubcommand["args"];
}
/**
* Walk the spec tree following the typed tokens to find the current context.
* Handles options with arguments (e.g., --name value) by skipping the value token.
*/
function resolveSpecContext(spec: FigSpec, consumedTokens: string[]): ResolvedContext {
let current: FigSubcommand = spec;
let inheritedOptions: FigOption[] = [];
let skipNext = false;
let lastOptionArgs: FigSubcommand["args"] | undefined;
for (const token of consumedTokens) {
// Skip this token if it's the argument value of a previous option
if (skipNext) {
skipNext = false;
lastOptionArgs = undefined;
continue;
}
// Handle option flags
if (token.startsWith("-")) {
// Check if this option expects an argument
const opt = [...(current.options ?? []), ...inheritedOptions].find((candidate) => {
const names = resolveNames(candidate.name);
return names.includes(token);
});
if (opt?.args) {
// This option expects an argument — the next token is its value
const args = Array.isArray(opt.args) ? opt.args : [opt.args];
if (args.length > 0 && !args[0].isOptional) {
skipNext = true;
lastOptionArgs = opt.args; // Track for the case where next token is currentWord
}
}
continue;
}
// Try to find a matching subcommand
if (current.subcommands) {
const sub = current.subcommands.find((s) => {
const names = resolveNames(s.name);
return names.includes(token);
});
if (sub) {
inheritedOptions = mergeOptionLists(inheritedOptions, current.options);
current = sub;
continue;
}
}
// If no subcommand matched, we're at the args level
break;
}
// If skipNext is still true, the currentWord is an option's arg value
// (e.g., "git archive --format |" — currentWord is the format value)
// Return the option's args instead of the subcommand's args.
if (skipNext && lastOptionArgs) {
return {
subcommands: undefined,
options: undefined,
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
args: lastOptionArgs,
};
}
return {
subcommands: current.subcommands,
options: current.options ? [...current.options] : undefined,
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
args: current.args,
};
}
function mergeOptionLists(
left: FigOption[] | undefined,
right: FigOption[] | undefined,
): FigOption[] {
const merged: FigOption[] = [];
const seen = new Set<string>();
for (const option of [...(left ?? []), ...(right ?? [])]) {
const key = resolveNames(option.name).sort().join("\0");
if (seen.has(key)) continue;
seen.add(key);
merged.push(option);
}
return merged;
}
function appendOptionSuggestions(
suggestions: CompletionSuggestion[],
ctx: CompletionContext,
currentToken: string,
options: FigOption[] | undefined,
): boolean {
if (!options || options.length === 0) return false;
let added = false;
for (const opt of options) {
const names = resolveNames(opt.name);
for (const name of names) {
if (name.startsWith(currentToken) && name !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
displayText: name,
description: opt.description,
source: "option",
score: 700,
});
added = true;
}
}
}
return added;
}
function appendOptionPreviewSuggestions(
suggestions: CompletionSuggestion[],
commandLine: string,
options: FigOption[] | undefined,
limit: number,
): void {
if (!options || options.length === 0 || suggestions.length >= limit) return;
for (const opt of options) {
const names = resolveNames(opt.name);
suggestions.push({
text: commandLine + " " + names[0],
displayText: names[0],
description: opt.description,
source: "option",
score: 700,
});
if (suggestions.length >= limit) break;
}
}
/**
* Rebuild the full command text with a replacement at a specific token index.
*/
function rebuildCommand(tokens: string[], replaceIndex: number, replacement: string): string {
const rebuilt = [...tokens];
rebuilt[replaceIndex] = replacement;
return rebuilt.join(" ");
}

View File

@@ -0,0 +1,203 @@
/**
* Loader for @withfig/autocomplete command specifications.
* Loads specs via Electron main process IPC (Node.js require),
* which reliably accesses node_modules in both dev and production.
*/
/** Minimal Fig spec types — mirrors @withfig/autocomplete-types */
export interface FigOption {
name: string | string[];
description?: string;
args?: FigArg | FigArg[];
isRequired?: boolean;
isPersistent?: boolean;
exclusiveOn?: string[];
}
export interface FigArg {
name?: string;
description?: string;
suggestions?: (string | FigSuggestion)[];
template?: string | string[];
isOptional?: boolean;
isVariadic?: boolean;
generators?: unknown;
}
export interface FigSuggestion {
name: string | string[];
description?: string;
icon?: string;
type?: string;
priority?: number;
}
export interface FigSubcommand {
name: string | string[];
description?: string;
subcommands?: FigSubcommand[];
options?: FigOption[];
args?: FigArg | FigArg[];
}
export interface FigSpec extends FigSubcommand {
// Top-level spec may include additional metadata
}
// Bridge type augmentation
interface FigSpecBridge {
listFigSpecs?: () => Promise<string[]>;
loadFigSpec?: (commandName: string) => Promise<FigSpec | null>;
}
function getBridge(): FigSpecBridge | undefined {
if (typeof window === "undefined") return undefined;
return (window as Window & { netcatty?: FigSpecBridge }).netcatty;
}
// Cache loaded specs
const specCache = new Map<string, FigSpec | null>();
// In-flight loading promises to avoid duplicate loads
const inFlightLoads = new Map<string, Promise<FigSpec | null>>();
// All available spec names
let availableSpecs: string[] | null = null;
let availableSpecsSet: Set<string> | null = null;
/**
* Get the list of all available command specs via IPC.
*/
export async function getAvailableSpecs(): Promise<string[]> {
// Only return cache if it has actual specs (not an empty failure)
if (availableSpecs && availableSpecs.length > 0) return availableSpecs;
try {
const bridge = getBridge();
if (bridge?.listFigSpecs) {
const specs = await bridge.listFigSpecs();
if (Array.isArray(specs) && specs.length > 0) {
availableSpecs = specs;
availableSpecsSet = new Set(specs);
return specs;
}
}
} catch (err) {
console.warn("[Autocomplete] figspec bridge error:", err);
}
// Don't cache empty — allow retry on next call
return [];
}
/**
* Load a command specification by name via IPC.
* Uses in-flight deduplication to avoid loading the same spec twice concurrently.
*/
export async function loadSpec(commandName: string): Promise<FigSpec | null> {
if (specCache.has(commandName)) {
return specCache.get(commandName) ?? null;
}
const existing = inFlightLoads.get(commandName);
if (existing) return existing;
const loadPromise = (async (): Promise<FigSpec | null> => {
try {
const bridge = getBridge();
if (!bridge?.loadFigSpec) {
// Don't cache — bridge may not be ready yet (dev reload, non-Electron preview)
return null;
}
const spec = await bridge.loadFigSpec(commandName);
if (spec) {
specCache.set(commandName, spec);
}
// Don't cache null — the load may have failed transiently (bridge not ready, etc.)
// Only cache null when we're confident the spec doesn't exist (hasSpec returned false)
return spec;
} catch {
// Don't cache failures — allow retry on next request
return null;
} finally {
inFlightLoads.delete(commandName);
}
})();
inFlightLoads.set(commandName, loadPromise);
return loadPromise;
}
/**
* Check if a spec exists for a given command name (without loading it).
*/
export async function hasSpec(commandName: string): Promise<boolean> {
// Only trust positive cache hits (spec loaded successfully).
// Null entries may be stale failures from preload — ignore them.
const cached = specCache.get(commandName);
if (cached) return true;
await getAvailableSpecs();
return availableSpecsSet?.has(commandName) ?? false;
}
/**
* Common shell commands preloaded when autocomplete is enabled.
* Includes local overrides under electron/specs/ (e.g. yum, dnf, awk).
*/
export const COMMON_FIG_SPECS = [
"git", "docker", "kubectl", "npm", "yarn", "pnpm",
"ls", "cd", "cat", "grep", "find", "ssh", "scp",
"curl", "wget", "tar", "zip", "unzip", "make",
"python", "python3", "pip", "pip3", "node",
"systemctl", "journalctl", "apt", "yum", "dnf", "brew",
"vim", "nano", "less", "head", "tail", "sort",
"awk", "sed", "chmod", "chown", "cp", "mv", "rm", "mkdir",
] as const;
/**
* Preload commonly used specs in batches to avoid overwhelming IPC.
* Only call this when autocomplete is enabled.
*/
export function preloadCommonSpecs(): void {
const BATCH_SIZE = 8;
let offset = 0;
const loadBatch = () => {
const batch = COMMON_FIG_SPECS.slice(offset, offset + BATCH_SIZE);
if (batch.length === 0) return;
for (const name of batch) {
loadSpec(name).catch(() => {});
}
offset += BATCH_SIZE;
if (offset < COMMON_FIG_SPECS.length) {
if (typeof requestIdleCallback === "function") {
requestIdleCallback(() => loadBatch());
} else {
setTimeout(loadBatch, 100);
}
}
};
setTimeout(loadBatch, 200);
}
/**
* Get normalized name variants (e.g., "git" from "/usr/bin/git").
*/
export function normalizeCommandName(rawCommand: string): string {
const parts = rawCommand.split("/");
let name = parts[parts.length - 1];
name = name.replace(/\.(exe|cmd|bat|sh|bash|zsh|fish)$/i, "");
return name.toLowerCase();
}
/**
* Resolve names from a Fig spec name field (which can be string or string[]).
*/
export function resolveNames(name: string | string[]): string[] {
return Array.isArray(name) ? name : [name];
}

View File

@@ -0,0 +1,24 @@
export type GhostSuggestionDecision =
| { type: "keep" }
| { type: "show"; suggestion: string }
| { type: "hide" };
/**
* Prefer a stable ghost suggestion while the user's typed input still
* falls within the currently shown prediction. This avoids a "jitter"
* effect where freshly fetched suggestions keep replacing the same
* visual prediction one character at a time.
*/
export function decideGhostSuggestion(
activeSuggestion: string | null,
input: string,
nextSuggestion: string | null,
): GhostSuggestionDecision {
if (activeSuggestion && activeSuggestion.startsWith(input)) {
return { type: "keep" };
}
if (nextSuggestion && nextSuggestion.startsWith(input)) {
return { type: "show", suggestion: nextSuggestion };
}
return { type: "hide" };
}

View File

@@ -0,0 +1,42 @@
/**
* Fail-safe consistency check for inline (ghost-text) suggestions.
*
* Ghost text renders `suggestion.substring(trackedInput.length)` after the
* cursor, where `trackedInput` is what the client thinks the user has typed.
* On hosts with non-standard echo (hardware bastion hosts / network OS such as
* `ecOS#`, issue #1013, previously #756 / #906) that tracked value drifts out
* of sync with what is actually on the terminal line, and the ghost ends up
* painted over characters the user already typed (`int` + ghost `terface` →
* `intterface`).
*
* This detects the one direction that produces visible corruption: the real
* line being AHEAD of the tracked input (it contains the tracked input
* followed by more, untracked characters). SSH echo latency is the opposite
* case — the line is a prefix-behind of the tracked input — and is
* intentionally NOT flagged, so the ghost stays responsive on slow links.
*
* Returns true when the caller should hide the ghost.
*/
export function lineHasUntrackedTrailingInput(
trackedInput: string,
lineBeforeCursor: string,
): boolean {
// Single chars match too loosely to judge reliably; let them through.
if (trackedInput.length < 2) return false;
// Column↔string mapping is only unambiguous for narrow (ASCII) input, so the
// existing wide-char (CJK / emoji) handling is left untouched.
if (!/^[\x20-\x7e]+$/.test(trackedInput)) return false;
// Use the last occurrence so a prompt or command that repeats the same token
// earlier on the line doesn't shadow the freshly-typed input.
const idx = lineBeforeCursor.lastIndexOf(trackedInput);
if (idx < 0) {
// Tracked input isn't on screen yet — the echo is still catching up
// (latency). Keep the ghost; reality being behind never corrupts.
return false;
}
// Non-whitespace characters between the tracked input and the cursor mean the
// device echoed input we never tracked → the ghost would overlap real text.
return lineBeforeCursor.slice(idx + trackedInput.length).trimEnd().length > 0;
}

View File

@@ -0,0 +1,6 @@
export { useTerminalAutocomplete, DEFAULT_AUTOCOMPLETE_SETTINGS } from "./useTerminalAutocomplete";
export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandle } from "./useTerminalAutocomplete";
export { default as AutocompletePopup } from "./AutocompletePopup";
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
export { recordCommand, removeCommandHistoryEntry, clearHistory } from "./commandHistoryStore";
export { shellEscape } from "./completionEngine";

View File

@@ -0,0 +1,55 @@
/**
* Compute the keystrokes to send so the terminal input line becomes exactly
* `candidate`, given what is currently on the line. Drives the popup
* autocomplete live-preview (#1005): moving the selection renders the chosen
* suggestion into the command line, and switching / reverting rewrites it.
*
* - Forward prefix (candidate continues the line): append only the new tail.
* - Otherwise: clear the current input, then write the full candidate. POSIX
* shells use Ctrl-U (kill-line); Windows (cmd/PowerShell) uses backspaces
* sized to the current line length.
*/
/**
* Live-preview rewrites inject Ctrl-U / backspaces into the PTY. Vendor
* bastion and network-device CLIs treat those bytes as session-kill, so
* network-device sessions keep the popup but skip the rewrite (#1193).
*/
export function shouldWriteAutocompleteLivePreview(
livePreviewEnabled: boolean,
isNetworkDevice = false,
): boolean {
return livePreviewEnabled && !isNetworkDevice;
}
export function isWindowsShellLineInput(
os: string,
promptText?: string | null,
): boolean {
if (os === "windows") return true;
// Hosts default to os:"linux" and the flag is easy to leave wrong. Windows
// shells do not kill the line on Ctrl-U; PSReadLine renders the raw byte
// literally (e.g. `tkn^Uuv run ...`), so every highlighted suggestion piles
// onto the command line (#3184). The detected prompt is authoritative when
// the flag disagrees: a drive-letter path with a backslash (`PS C:\Users>`,
// `C:\Windows>`) only occurs in a Windows shell prompt.
return typeof promptText === "string" && /(?:^|\s)[A-Za-z]:\\/.test(promptText);
}
export function computeLivePreviewWrite(input: {
currentLine: string;
candidate: string;
os: string;
/** Detected prompt text; lets a mislabeled host OS flag still clear the line (#3184). */
promptText?: string;
}): string {
const { currentLine, candidate, os } = input;
if (candidate === currentLine) return "";
if (candidate.startsWith(currentLine)) {
return candidate.slice(currentLine.length);
}
const clear = isWindowsShellLineInput(os, input.promptText)
? "\b".repeat(currentLine.length)
: "\x15";
return clear + candidate;
}

View File

@@ -0,0 +1,177 @@
import assert from "node:assert/strict";
import test from "node:test";
type LocalStorageMock = {
clear(): void;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
};
function installLocalStorage(): LocalStorageMock {
const store = new Map<string, string>();
const localStorage: LocalStorageMock = {
clear() {
store.clear();
},
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
};
Object.defineProperty(globalThis, "localStorage", {
value: localStorage,
configurable: true,
});
return localStorage;
}
const localStorage = installLocalStorage();
const files = new Map<string, string>();
let bridgeEnabled = true;
const bridge = {
getHomeDir: async () => (bridgeEnabled ? "/Users/demo" : Promise.reject(new Error("no bridge"))),
readLocalFile: async (path: string, options?: { maxBytes?: number }) => {
if (!bridgeEnabled) throw new Error("no bridge");
const text = files.get(path);
if (text === undefined) throw new Error(`ENOENT: ${path}`);
let bytes = new TextEncoder().encode(text);
if (options?.maxBytes && bytes.byteLength > options.maxBytes) {
bytes = bytes.subarray(bytes.byteLength - options.maxBytes);
}
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
},
};
Object.defineProperty(globalThis, "window", {
value: { electron: bridge, netcatty: bridge },
configurable: true,
});
const { clearHistory, queryHistory } = await import("./commandHistoryStore.ts");
const { seedLocalShellHistoryFromHistfiles } = await import("./localShellHistorySeed.ts");
const { getCompletions } = await import("./completionEngine.ts");
test.beforeEach(() => {
localStorage.clear();
clearHistory();
files.clear();
bridgeEnabled = true;
(window as Window & { netcatty?: unknown }).netcatty = bridge;
});
test("seedLocalShellHistoryFromHistfiles imports zsh history for autocomplete prefix match", async () => {
const hostId = "local-terminal";
files.set(
"/Users/demo/.zsh_history",
": 1700000000:0;sudo xattr -rd com.apple.quarantine /Applications/ClashX\\ Meta.app\n",
);
const seeded = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.ok(seeded > 0);
const matches = queryHistory("sudo xattr", { hostId, limit: 5 });
assert.equal(matches.length, 1);
assert.match(matches[0].command, /ClashX/);
const completions = await getCompletions("sudo xattr", {
hostId,
os: "macos",
protocol: "local",
});
assert.ok(
completions.some((c) => c.source === "history" && c.text.includes("ClashX")),
`expected history completion, got ${JSON.stringify(completions.map((c) => ({ s: c.source, t: c.text })))}`,
);
});
test("seedLocalShellHistoryFromHistfiles is idempotent for the same host after a successful import", async () => {
const hostId = "local-terminal";
files.set("/Users/demo/.zsh_history", ": 1700000000:0;pwd\n: 1700000001:0;ls\n");
const first = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
const second = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.equal(first, 2);
assert.equal(second, 0);
});
test("seedLocalShellHistoryFromHistfiles retries when histfiles were empty", async () => {
const hostId = "local-terminal";
const first = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.equal(first, 0);
files.set("/Users/demo/.zsh_history", ": 1700000000:0;echo later\n");
const second = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.equal(second, 1);
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 1);
});
test("seedLocalShellHistoryFromHistfiles no-ops without a bridge and stays retryable", async () => {
const hostId = "local-terminal";
(window as Window & { netcatty?: unknown }).netcatty = undefined;
const first = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.equal(first, 0);
(window as Window & { netcatty?: unknown }).netcatty = bridge;
files.set("/Users/demo/.zsh_history", ": 1700000000:0;pwd\n");
const second = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.equal(second, 1);
});
test("seedLocalShellHistoryFromHistfiles dedupes concurrent calls for the same host", async () => {
const hostId = "local-terminal";
files.set("/Users/demo/.zsh_history", ": 1700000000:0;pwd\n");
// Start the first seed without awaiting so the second call overlaps in-flight.
const firstPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
const secondPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
const [a, b] = await Promise.all([firstPromise, secondPromise]);
assert.equal(a, 1);
assert.equal(b, 1);
assert.equal(queryHistory("pw", { hostId, limit: 5 }).length, 1);
});
test("seedLocalShellHistoryFromHistfiles drops a partial first line from a full-budget histfile tail", async () => {
const hostId = "local-terminal";
// Simulate a main-process maxBytes tail: exactly 512KiB ending mid-command,
// then a complete command on the next line.
const maxBytes = 512 * 1024;
const complete = ": 1700000001:0;echo complete\n";
const partialPrefix = "PARTIAL_TRUNCATED_COMMAND_WITHOUT_NEWLINE";
const overhead = Buffer.byteLength(`${partialPrefix}\n\n${complete}`, "utf8");
const filler = "x".repeat(maxBytes - overhead);
const tail = `${partialPrefix}\n${filler}\n${complete}`;
assert.equal(Buffer.byteLength(tail, "utf8"), maxBytes);
files.set("/Users/demo/.zsh_history", tail);
const seeded = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.ok(seeded >= 1);
assert.equal(queryHistory("echo", { hostId, limit: 5 })[0]?.command, "echo complete");
assert.equal(queryHistory("PARTIAL", { hostId, limit: 5 }).length, 0);
});
test("seedLocalShellHistoryFromHistfiles joins Windows home paths for fish history", async () => {
const hostId = "local-terminal";
const previousHome = bridge.getHomeDir;
bridge.getHomeDir = async () => "C:\\Users\\demo";
try {
files.set(
"C:\\Users\\demo\\.config\\fish\\fish_history",
"- cmd: echo fish\n when: 1700000000\n",
);
const seeded = await seedLocalShellHistoryFromHistfiles(hostId, "windows");
assert.equal(seeded, 1);
assert.equal(queryHistory("echo", { hostId, limit: 5 })[0]?.command, "echo fish");
} finally {
bridge.getHomeDir = previousHome;
}
});

View File

@@ -0,0 +1,163 @@
/**
* Seed autocomplete command history from the local machine's shell histfiles.
*
* Local Terminal sessions previously used a per-session hostId (`local-${sessionId}`),
* so Netcatty's autocomplete history never accumulated across opens. Even with a
* stable hostId, a fresh install / new machine has an empty store until the user
* types commands inside Netcatty — while Ghostty (and similar terminals) surface
* suggestions from ~/.zsh_history / ~/.bash_history immediately.
*
* This module imports those histfiles once per hostId into commandHistoryStore
* so prefix autocomplete can match them.
*/
import {
isNetcattyAiHistoryCommand,
isNetcattyManagedStartupHistoryCommand,
mergeRemoteHistory,
parseBashHistory,
parseFishHistory,
parseZshHistory,
} from "../../../domain/remoteHistory";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { flushCommandHistoryStore, recordCommand } from "./commandHistoryStore";
const SEED_FLAG_PREFIX = "netcatty:localHistSeeded:";
const MAX_SEED_COMMANDS = 500;
/** Cap histfile reads so a multi-MB history does not stall Local Terminal mount. */
const MAX_HISTFILE_BYTES = 512 * 1024;
type LocalFsBridge = {
getHomeDir?: () => Promise<string>;
readLocalFile?: (
path: string,
options?: { maxBytes?: number },
) => Promise<ArrayBuffer | Buffer | Uint8Array | string>;
};
const inFlightSeeds = new Map<string, Promise<number>>();
function getBridge(): LocalFsBridge | undefined {
return (window as Window & { netcatty?: LocalFsBridge }).netcatty;
}
function joinHomePath(home: string, relativeUnix: string): string {
const normalizedHome = home.replace(/[/\\]+$/, "");
const sep = home.includes("\\") && !home.includes("/") ? "\\" : "/";
const relative = sep === "\\" ? relativeUnix.replace(/\//g, "\\") : relativeUnix;
return `${normalizedHome}${sep}${relative}`;
}
function decodeHistfileBytes(bytes: Uint8Array): string {
// Main-process reads already return at most MAX_HISTFILE_BYTES. A buffer that
// fills the budget is treated as a truncated tail, so drop the first
// (possibly partial) line before parsing.
let text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
if (bytes.byteLength >= MAX_HISTFILE_BYTES) {
const firstNewline = text.indexOf("\n");
if (firstNewline >= 0) text = text.slice(firstNewline + 1);
}
return text;
}
async function readTextFile(bridge: LocalFsBridge, path: string): Promise<string | null> {
if (!bridge.readLocalFile) return null;
try {
// Ask the main process to return only the trailing bytes so multi-MB
// histfiles never cross the IPC boundary in full.
const raw = await bridge.readLocalFile(path, { maxBytes: MAX_HISTFILE_BYTES });
if (typeof raw === "string") {
// Bridge returned a string (tests / alternate adapters). Cap by UTF-8
// byte length so this path matches the binary branch.
const encoded = new TextEncoder().encode(raw);
return decodeHistfileBytes(encoded);
}
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
return decodeHistfileBytes(bytes);
} catch {
return null;
}
}
function alreadySeeded(hostId: string): boolean {
return localStorageAdapter.readBoolean(`${SEED_FLAG_PREFIX}${hostId}`) === true;
}
function markSeeded(hostId: string): void {
localStorageAdapter.writeBoolean(`${SEED_FLAG_PREFIX}${hostId}`, true);
}
async function seedLocalShellHistoryFromHistfilesOnce(
hostId: string,
os: "linux" | "windows" | "macos",
): Promise<number> {
if (!hostId || alreadySeeded(hostId)) return 0;
const bridge = getBridge();
if (!bridge?.getHomeDir || !bridge.readLocalFile) return 0;
let home: string;
try {
home = await bridge.getHomeDir();
} catch {
return 0;
}
if (!home) return 0;
const [zshText, bashText, fishText, fishAltText] = await Promise.all([
readTextFile(bridge, joinHomePath(home, ".zsh_history")),
readTextFile(bridge, joinHomePath(home, ".bash_history")),
readTextFile(bridge, joinHomePath(home, ".local/share/fish/fish_history")),
readTextFile(bridge, joinHomePath(home, ".config/fish/fish_history")),
]);
const lists = [
zshText ? parseZshHistory(zshText) : [],
bashText ? parseBashHistory(bashText) : [],
fishText ? parseFishHistory(fishText) : [],
!fishText && fishAltText ? parseFishHistory(fishAltText) : [],
];
const merged = mergeRemoteHistory(lists, MAX_SEED_COMMANDS);
let recorded = 0;
// mergeRemoteHistory returns newest-first; record oldest-first so frequency /
// lastUsedAt ordering stays sensible if the same command appears later.
for (const entry of [...merged].reverse()) {
const command = entry.command.trim();
if (!command) continue;
if (isNetcattyAiHistoryCommand(command)) continue;
if (isNetcattyManagedStartupHistoryCommand(command)) continue;
recordCommand(command, hostId, os);
recorded += 1;
}
// Only persist the seeded flag after we actually imported commands and
// flushed the store. An empty/missing histfile must remain retryable so a
// later Local Terminal open can pick up history once it exists (#2037).
if (recorded > 0 && flushCommandHistoryStore()) {
markSeeded(hostId);
}
return recorded;
}
/**
* Import local shell histfiles into the autocomplete history store for `hostId`.
* Returns the number of commands newly recorded. No-ops when already seeded for
* this hostId, when the local FS bridge is unavailable, or when histfiles are
* empty/missing (those cases stay retryable on the next Local Terminal open).
*/
export async function seedLocalShellHistoryFromHistfiles(
hostId: string,
os: "linux" | "windows" | "macos" = "macos",
): Promise<number> {
if (!hostId || alreadySeeded(hostId)) return 0;
const existing = inFlightSeeds.get(hostId);
if (existing) return existing;
const pending = seedLocalShellHistoryFromHistfilesOnce(hostId, os).finally(() => {
inFlightSeeds.delete(hostId);
});
inFlightSeeds.set(hostId, pending);
return pending;
}

View File

@@ -0,0 +1,973 @@
/**
* Prompt detector for terminal autocomplete.
* Detects whether the user is currently at a shell prompt (vs. inside a running program).
* Uses xterm.js buffer analysis to identify common prompt patterns.
*
* Strategy: scan prompt-looking boundaries ($ # % >, Powerline/Nerd Font glyphs,
* etc.) and choose the most reliable split for prompt text vs. user input.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
import { isSensitiveTerminalChallenge } from "../../../domain/terminalPromptSecurity";
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
import { COMMON_SHELL_COMMANDS, NON_PROMPT_PATTERNS, PROMPT_CHARS } from "./promptDetectorPatterns";
export interface PromptDetectionResult {
/** Whether a prompt is detected on the current line */
isAtPrompt: boolean;
/** The detected prompt text (everything before user input) */
promptText: string;
/** The user's current input (after the prompt) */
userInput: string;
/** The cursor column position within the user input */
cursorOffset: number;
}
const NO_PROMPT: PromptDetectionResult = {
isAtPrompt: false, promptText: "", userInput: "", cursorOffset: 0,
};
export function isNonPromptLine(lineText: string): boolean {
return NON_PROMPT_PATTERNS.some((pattern) => pattern.test(lineText));
}
function isSpecificShellPromptCandidate(
promptText: string,
options: { allowGreaterThanTerminator?: boolean } = {},
): boolean {
const trimmed = promptText.trim();
if (
!options.allowGreaterThanTerminator &&
(trimmed.endsWith(">") || trimmed.endsWith(""))
) {
return false;
}
return trimmed.length >= 6 && /[@:\\/~\])]/.test(trimmed);
}
function isLikelyNoSpaceShellPromptText(promptText: string): boolean {
const trimmed = promptText.trim();
if (/^root[#%$]$/.test(trimmed)) return true;
if (trimmed.length < 3) return false;
const marker = trimmed[trimmed.length - 1];
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return false;
const prev = trimmed[trimmed.length - 2] ?? "";
return /[~:/\\\])]/.test(prev);
}
export interface AlignedPromptResult {
/** The prompt view every consumer should use for parsing / suggestion lookup / line rewrites. */
prompt: PromptDetectionResult;
/**
* The keystroke buffer, but only when it's both marked reliable AND
* can be validated against the live terminal line. Returns null
* otherwise - the single signal downstream uses to decide whether
* to record it as the executed command.
*/
alignedTyped: string | null;
/**
* When false, `prompt.userInput` was filled from the keystroke buffer
* before any shell echo. Empty echo is also what echo-disabled password
* prompts look like, so callers must not surface or accept completions
* (built-in or external) and must not authorize history recording
* (`alignedTyped`). Omitted/true means the live line validated input.
*/
allowExternalProviders?: boolean;
}
function getCursorLinePrefix(term: XTerm): string | null {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const line = buffer.getLine(cursorY);
if (!line) return null;
const lineText = line.translateToString(false);
return sliceStringByCellColumns(lineText, 0, Math.max(0, buffer.cursorX), term);
}
function getWrappedCursorPrefix(term: XTerm): string | null {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const cursorX = buffer.cursorX;
const line = buffer.getLine(cursorY);
if (!line?.isWrapped) return null;
let promptRow = cursorY - 1;
while (promptRow >= 0) {
const prevLine = buffer.getLine(promptRow);
if (!prevLine) return null;
if (!prevLine.isWrapped) break;
promptRow--;
}
const promptLine = buffer.getLine(promptRow);
if (!promptLine) return null;
let prefix = promptLine.translateToString(false);
for (let row = promptRow + 1; row < cursorY; row++) {
const rowLine = buffer.getLine(row);
if (!rowLine) return null;
prefix += rowLine.translateToString(false);
}
const cursorRowText = line.translateToString(false);
return prefix + sliceStringByCellColumns(cursorRowText, 0, Math.max(0, cursorX), term);
}
function inferPromptTextBeforeTypedInput(
cursorPrefix: string,
typedBuffer: string,
allowPartialEcho: boolean,
): string | null {
if (cursorPrefix.endsWith(typedBuffer)) {
const promptText = cursorPrefix.slice(0, cursorPrefix.length - typedBuffer.length);
return promptText.length > 0 ? promptText : null;
}
if (!allowPartialEcho) return null;
const maxEchoLength = Math.min(cursorPrefix.length, typedBuffer.length);
const minPartialEchoLength = Math.max(6, typedBuffer.length - 2);
for (let echoLength = maxEchoLength - 1; echoLength >= minPartialEchoLength; echoLength--) {
const echoedInput = typedBuffer.slice(0, echoLength);
if (!cursorPrefix.endsWith(echoedInput)) continue;
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
if (promptText.length > 0) return promptText;
}
const noSpacePromptMinEchoLength = typedBuffer.trim().length <= 2 ? 1 : 3;
for (
let echoLength = Math.min(maxEchoLength - 1, minPartialEchoLength - 1);
echoLength >= noSpacePromptMinEchoLength;
echoLength--
) {
const echoedInput = typedBuffer.slice(0, echoLength);
if (!cursorPrefix.endsWith(echoedInput)) continue;
const hasReliablePartialEcho =
typedBuffer.trim().length <= 2 ||
echoedInput.endsWith(" ") ||
(echoedInput.includes(" ") && echoedInput.length >= 4);
if (!hasReliablePartialEcho) continue;
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
if (isLikelyNoSpaceShellPromptText(promptText)) return promptText;
}
return null;
}
function hasSwallowedCommandAfterPrompt(promptText: string, promptBoundary: number): boolean {
const candidate = promptText.slice(0, promptBoundary).trimEnd();
const finalIndex = candidate.length - 1;
const finalChar = finalIndex >= 0 ? candidate[finalIndex] : "";
for (let i = 0; i < finalIndex; i++) {
const ch = candidate[i];
if (!PROMPT_CHARS.has(ch) && !isPuaChar(ch)) continue;
const nextChar = i + 1 < candidate.length ? candidate[i + 1] : null;
if (nextChar === null || nextChar === " ") continue;
const earlierPrompt = candidate.slice(0, i + 1);
if (isLikelyNoSpaceShellPromptText(earlierPrompt)) return true;
if (isEmbeddedPromptMarkerAt(candidate, i)) continue;
if (!isSpecificShellPromptCandidate(earlierPrompt)) continue;
if (PROMPT_CHARS.has(nextChar) || isPuaChar(nextChar)) return true;
if (startsWithCommonShellCommand(candidate.slice(i + 1))) return true;
if (finalChar !== "$") return true;
}
return false;
}
function canUseInferredPromptText(promptText: string, rawIsAtPrompt: boolean): boolean {
if (promptText.length === 0) return false;
if (rawIsAtPrompt) return true;
const promptBoundary = findPromptBoundary(promptText);
const promptEndsAtBoundary =
promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
return (
promptEndsAtBoundary &&
!hasSwallowedCommandAfterPrompt(promptText, promptBoundary) &&
isSpecificShellPromptCandidate(promptText)
);
}
function isThemedPromptText(promptText: string): boolean {
for (const ch of promptText) {
if (isPuaChar(ch)) return true;
}
return /[❯❮→➜➤⟩»›]/.test(promptText);
}
function isPromptPathDecoration(trimmed: string): boolean {
return (
trimmed === "~" ||
trimmed.startsWith("~/") ||
trimmed.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(trimmed) ||
trimmed.includes("\\")
);
}
function isPromptBareDirectoryText(trimmed: string): boolean {
if (trimmed.startsWith("./") || trimmed.startsWith("../")) return false;
return /^[\w.-]+$/.test(trimmed);
}
function isPromptStatusToken(token: string): boolean {
return (
/^git:\([^)]*\)$/.test(token) ||
/^[+$#%>!?*]$/.test(token) ||
token === "✗" ||
token === "✔"
);
}
function isPromptStatusText(trimmed: string): boolean {
const [first = "", ...rest] = trimmed.split(/\s+/);
if (rest.length === 0) return false;
if (!isPromptBareDirectoryText(first) && !isPromptPathDecoration(first)) return false;
return rest.every(isPromptStatusToken);
}
function isPromptStatusDecoration(extra: string): boolean {
if (!/^\s+/.test(extra) || !/\s+$/.test(extra)) return false;
return isPromptStatusText(extra.trim());
}
function isPromptDecorationExtra(extra: string, promptText: string): boolean {
const trimmed = extra.trim();
if (trimmed.length === 0) return false;
if (!isThemedPromptText(promptText)) return false;
if (startsWithCommonShellCommand(extra)) return false;
if (/^\s*\S+\s+$/.test(extra)) {
return isPromptPathDecoration(trimmed) || (
isPromptBareDirectoryText(trimmed) &&
!startsWithCommonShellCommand(trimmed)
);
}
if (isPromptStatusDecoration(extra)) return true;
for (const ch of extra) {
if (isPuaChar(ch)) return true;
}
return false;
}
function getFinalPromptBoundary(promptText: string): number {
const trimmedEnd = promptText.trimEnd().length;
if (trimmedEnd === 0) return -1;
const markerIndex = trimmedEnd - 1;
const marker = promptText[markerIndex];
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return -1;
const nextChar = markerIndex + 1 < promptText.length ? promptText[markerIndex + 1] : null;
if (nextChar !== null && nextChar !== " ") return -1;
return nextChar === " " ? markerIndex + 2 : markerIndex + 1;
}
function endsAtFinalPromptBoundary(promptText: string): boolean {
const promptBoundary = getFinalPromptBoundary(promptText);
return promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
}
function getLeadingShellCommandWord(text: string): string | null {
return text.trimStart().match(/^[\w.-]+(?=\s|$)/)?.[0] ?? null;
}
function startsWithCommonShellCommand(text: string): boolean {
const command = getLeadingShellCommandWord(text);
return command !== null && COMMON_SHELL_COMMANDS.has(command);
}
function isCompleteSpecificPrompt(promptText: string): boolean {
const promptBoundary = getFinalPromptBoundary(promptText);
return (
promptBoundary >= 0 &&
promptText.slice(promptBoundary).trim().length === 0 &&
isSpecificShellPromptCandidate(promptText) &&
!isEmbeddedPromptMarker(promptText, promptBoundary)
);
}
function looksLikeCommandAfterCompletePrompt(promptText: string, extra: string): boolean {
return isCompleteSpecificPrompt(promptText) && extra.trim().length > 0;
}
function hasShellCommandAfterOptionalDecoration(text: string): boolean {
const trimmedStart = text.trimStart();
if (startsWithCommonShellCommand(trimmedStart)) return true;
const [, afterDecoration = ""] = trimmedStart.match(/^\S+\s+(.+)$/) ?? [];
return startsWithCommonShellCommand(afterDecoration);
}
function isSingleBareDirectoryExtra(extra: string): boolean {
const trimmed = extra.trim();
return /^\s*\S+\s+$/.test(extra) && isPromptBareDirectoryText(trimmed);
}
function hasExplicitThemedDirectorySpacing(extra: string): boolean {
return /^\s+\S+\s+$/.test(extra);
}
type PromptDecorationReconcileOptions = {
allowSingleWordCommandDirectory?: boolean;
};
function canTreatCommonCommandNameAsThemedDirectory(
extra: string,
typedInput: string,
options: PromptDecorationReconcileOptions = {},
): boolean {
const trimmedInput = typedInput.trim();
return (
isSingleBareDirectoryExtra(extra) &&
(
/\s/.test(trimmedInput) ||
/^(?:ls|cd|pwd)$/.test(trimmedInput) ||
(
options.allowSingleWordCommandDirectory === true &&
hasExplicitThemedDirectorySpacing(extra)
)
)
);
}
function canReconcilePromptDecoration(
prompt: PromptDetectionResult,
typedInput: string,
options: PromptDecorationReconcileOptions = {},
): boolean {
if (
!prompt.isAtPrompt ||
!typedInput ||
prompt.userInput.length <= typedInput.length ||
!prompt.userInput.endsWith(typedInput)
) {
return false;
}
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
if (looksLikeCommandAfterCompletePrompt(prompt.promptText, extra)) return false;
if (
isThemedPromptText(prompt.promptText) &&
canTreatCommonCommandNameAsThemedDirectory(extra, typedInput, options)
) {
return true;
}
if (isThemedPromptText(prompt.promptText) && hasShellCommandAfterOptionalDecoration(extra)) {
return false;
}
const candidatePromptText = prompt.promptText + extra;
const promptEndsAtBoundary =
endsAtFinalPromptBoundary(candidatePromptText) &&
isSpecificShellPromptCandidate(candidatePromptText);
return promptEndsAtBoundary || isPromptDecorationExtra(extra, prompt.promptText);
}
function alignTypedInputFromCursorPrefix(
raw: PromptDetectionResult,
cursorPrefix: string | null,
typedBuffer: string,
): AlignedPromptResult | null {
if (!cursorPrefix) return null;
if (!raw.isAtPrompt && isNonPromptLine(cursorPrefix)) return null;
const promptText = inferPromptTextBeforeTypedInput(cursorPrefix, typedBuffer, !raw.isAtPrompt);
if (!promptText || !canUseInferredPromptText(promptText, raw.isAtPrompt)) {
return null;
}
return {
prompt: {
isAtPrompt: true,
promptText,
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
},
alignedTyped: typedBuffer,
};
}
function canUseReliablePromptPrefix(
raw: PromptDetectionResult,
typedBuffer: string,
): boolean {
// Empty echo alone is not validation: echo-disabled prompts can look like
// a normal shell PS1 (e.g. `read -s -p '$ '`), and treating the keystroke
// buffer as alignedTyped would authorize history recording. Pre-echo
// autocomplete uses a separate path that keeps alignedTyped null.
if (!raw.isAtPrompt || typedBuffer.length === 0 || raw.userInput.length === 0) {
return false;
}
if (typedBuffer.length <= raw.userInput.length) return false;
return isReliableTypedPrefix(raw.userInput, typedBuffer, {
allowShortEcho: allowsShortPromptEcho(raw.promptText),
});
}
function isLikelyBareMongoPromptName(promptName: string): boolean {
return /^(?:test|admin|local|config)$/i.test(promptName);
}
function endsWithHostStyleGreaterThanPrompt(promptText: string): boolean {
const trimmed = promptText.trimEnd();
if (!trimmed.endsWith(">")) return false;
const promptName = trimmed.slice(0, -1).trim();
return /^[\w.-]+$/.test(promptName) && !isLikelyBareMongoPromptName(promptName);
}
function endsWithWindowsPathGreaterThanPrompt(promptText: string): boolean {
const trimmed = promptText.trimEnd();
if (!trimmed.endsWith(">")) return false;
const before = trimmed.slice(0, -1).trimEnd();
// cmd.exe: `C:\path>` / `C:\>`; PowerShell: `PS C:\path>`
if (/^[A-Za-z]:[\\/]/.test(before)) return true;
if (/^PS\s+[A-Za-z]:[\\/]/i.test(before)) return true;
return false;
}
function endsWithStandardShellPrompt(promptText: string): boolean {
const finalChar = promptText.trimEnd().at(-1);
return finalChar === "$" || finalChar === "#" || finalChar === "%";
}
function allowsShortPromptEcho(promptText: string): boolean {
return (
endsWithStandardShellPrompt(promptText) ||
endsWithHostStyleGreaterThanPrompt(promptText) ||
endsWithWindowsPathGreaterThanPrompt(promptText)
);
}
function isReliableTypedPrefix(
echoedInput: string,
typedBuffer: string,
options: { allowShortEcho?: boolean } = {},
): boolean {
if (!typedBuffer.startsWith(echoedInput)) return false;
if (
options.allowShortEcho &&
typedBuffer.trim().length <= 2 &&
echoedInput.trim().length >= 1
) {
return true;
}
return (
echoedInput.length >= Math.max(4, typedBuffer.length - 2) ||
(echoedInput.endsWith(" ") && echoedInput.trim().length >= 2) ||
(echoedInput.includes(" ") && echoedInput.length >= 4)
);
}
function withTypedUserInput(
prompt: PromptDetectionResult,
typedBuffer: string,
): PromptDetectionResult {
return {
...prompt,
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
};
}
function alignThemedDecorationWithPartialEcho(
raw: PromptDetectionResult,
typedBuffer: string,
): AlignedPromptResult | null {
if (!raw.isAtPrompt || !isThemedPromptText(raw.promptText)) return null;
const maxEchoLength = Math.min(raw.userInput.length, typedBuffer.length);
for (let echoLength = maxEchoLength; echoLength > 0; echoLength--) {
const echoedInput = typedBuffer.slice(0, echoLength);
if (!raw.userInput.endsWith(echoedInput)) continue;
const extra = raw.userInput.slice(0, raw.userInput.length - echoLength);
if (extra.length === 0) continue;
const hasReliableThemedDirectoryPrefix =
isSingleBareDirectoryExtra(extra) &&
hasExplicitThemedDirectorySpacing(extra) &&
typedBuffer.trim().length <= 3 &&
echoedInput.trim().length >= 1;
const syntheticPrompt = {
...raw,
userInput: extra + typedBuffer,
cursorOffset: extra.length + typedBuffer.length,
};
if (
!hasReliableThemedDirectoryPrefix &&
!isReliableTypedPrefix(echoedInput, typedBuffer)
) {
continue;
}
if (!canReconcilePromptDecoration(syntheticPrompt, typedBuffer, {
allowSingleWordCommandDirectory: true,
})) continue;
return {
prompt: {
isAtPrompt: true,
promptText: raw.promptText + extra,
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
},
alignedTyped: typedBuffer,
};
}
return null;
}
/**
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
*/
export function detectPrompt(term: XTerm): PromptDetectionResult {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const cursorX = buffer.cursorX;
const line = buffer.getLine(cursorY);
if (!line) return NO_PROMPT;
// translateToString(false) preserves trailing spaces — important for cursor-based
// input extraction (trailing space triggers empty token for option suggestions)
const lineText = line.translateToString(false);
// Check for non-prompt patterns (pagers, editors, etc.)
if (isSensitiveTerminalChallenge(lineText) || isNonPromptLine(lineText)) return NO_PROMPT;
if (line.isWrapped) {
const wrappedPrefix = getWrappedCursorPrefix(term);
if (wrappedPrefix && (isSensitiveTerminalChallenge(wrappedPrefix) || isNonPromptLine(wrappedPrefix))) {
return NO_PROMPT;
}
}
// Empty line
if (lineText.trim().length === 0) return NO_PROMPT;
// cursorX is a cell column; lineText is characters. Wide glyphs (CJK in a
// Windows `C:\Users\用户>` prompt) make substring(cursorX) overshoot into
// xterm's empty-cell padding and poison userInput with spaces (#2813 CMD).
const cursorLinePrefix = sliceStringByCellColumns(lineText, 0, Math.max(0, cursorX), term);
const afterCursor = sliceStringByCellColumns(lineText, Math.max(0, cursorX), undefined, term);
// Try to find the prompt boundary on the current line. xterm buffer rows are
// padded with blank cells; when the cursor is at the visible row end, scan
// only up to the cursor so prompts like "root@host:~#" do not inherit a fake
// trailing space. If there is command text to the right of the cursor, keep
// the full line so "$" / ">" inside mid-line edits are validated against
// their real following character.
const promptScanText = afterCursor.trim().length > 0
? lineText
: cursorLinePrefix;
const promptEnd = findPromptBoundary(promptScanText);
if (promptEnd >= 0) {
const promptText = lineText.substring(0, promptEnd);
// Input is whatever sits between the prompt and the cursor on the cell-
// accurate prefix — don't use cursorX as a character index.
const userInput = cursorLinePrefix.length >= promptEnd
? cursorLinePrefix.substring(promptEnd)
: "";
const cursorOffset = userInput.length;
return { isAtPrompt: true, promptText, userInput, cursorOffset };
}
// Handle wrapped lines: if the prompt is on a previous row (e.g., long path or
// long command wrapped onto multiple rows), look upward for the prompt line.
// The current row's content is continuation of the command.
if (line.isWrapped) {
// Walk up to find the first non-wrapped line (the prompt line)
let promptRow = cursorY - 1;
while (promptRow >= 0) {
const prevLine = buffer.getLine(promptRow);
if (!prevLine) break;
if (!prevLine.isWrapped) break;
promptRow--;
}
const promptLine = buffer.getLine(promptRow);
if (promptLine) {
const promptLineText = promptLine.translateToString(false);
if (isSensitiveTerminalChallenge(promptLineText) || isNonPromptLine(promptLineText)) return NO_PROMPT;
const pEnd = findPromptBoundary(promptLineText);
if (pEnd >= 0) {
const promptText = promptLineText.substring(0, pEnd);
// Concatenate all rows from promptRow to cursorY to get full input
let fullInput = promptLineText.substring(pEnd);
for (let row = promptRow + 1; row <= cursorY; row++) {
const rowLine = buffer.getLine(row);
if (rowLine) fullInput += rowLine.translateToString(false);
}
// Trim to cursor position on the last row
const totalCols = term.cols;
const charsBeforeCursorRow = (cursorY - promptRow) * totalCols - pEnd;
const userInput = fullInput.substring(0, charsBeforeCursorRow + cursorX);
const cursorOffset = userInput.length;
if (isSensitiveTerminalChallenge(promptText + userInput)
|| isNonPromptLine(promptText + userInput)) return NO_PROMPT;
return { isAtPrompt: true, promptText, userInput, cursorOffset };
}
}
}
return NO_PROMPT;
}
/**
* Whether a character lives in the Unicode Private Use Area (U+E000U+F8FF).
* Powerline separators (U+E0B0..) and Nerd Font icons (U+E200.., U+F000..) all
* fall here. A PUA char followed by a space is common in themed prompt
* terminators (oh-my-posh, starship, p10k, etc.), but commands can still echo
* those glyphs, so PUA boundaries are kept lower priority than standard prompt
* characters and reconciled with the typed buffer when available.
*/
function isPuaChar(ch: string): boolean {
if (!ch) return false;
const code = ch.charCodeAt(0);
return code >= 0xE000 && code <= 0xF8FF;
}
function getBoundaryMarkerIndex(lineText: string, boundary: number): number {
if (boundary <= 0) return -1;
return lineText[boundary - 1] === " " ? boundary - 2 : boundary - 1;
}
function isEmbeddedPromptMarkerAt(lineText: string, markerIndex: number): boolean {
if (markerIndex <= 0) return false;
const marker = lineText[markerIndex];
if (marker !== "#" && marker !== "%" && marker !== ">" && marker !== "$") return false;
const prev = lineText[markerIndex - 1];
return !/[\s~:\])}]/.test(prev);
}
function isEmbeddedPromptMarker(lineText: string, boundary: number): boolean {
return isEmbeddedPromptMarkerAt(lineText, getBoundaryMarkerIndex(lineText, boundary));
}
function canSupersedeThemedPromptBoundary(
lineText: string,
previousBoundary: number,
markerIndex: number,
): boolean {
if (!isThemedPromptText(lineText.slice(0, previousBoundary))) return false;
const rawBetween = lineText.slice(previousBoundary, markerIndex);
const between = rawBetween.trim();
return (
between.length === 0 ||
isPromptPathDecoration(between) ||
isPromptStatusText(between) ||
(
/^\s/.test(rawBetween) &&
isPromptBareDirectoryText(between)
)
);
}
function canPromptMarkerSupersedePreviousBoundary(ch: string): boolean {
return ch === "$" || ch === "#" || ch === "%" || ch === ">" || ch === "";
}
function isSpacedPromptSegment(lineText: string, boundary: number): boolean {
const markerIndex = getBoundaryMarkerIndex(lineText, boundary);
if (markerIndex <= 0) return false;
if (lineText[markerIndex - 1] !== " ") return false;
return lineText[markerIndex + 1] === " ";
}
/**
* Find the boundary between prompt and user input.
* Scans left-to-right within the first 200 chars for a prompt character followed by space.
* Avoids false positives: $VAR, $(...), ${...} are not prompt endings.
* Returns the character index where user input begins, or -1 if no prompt detected.
*/
function findPromptBoundary(lineText: string): number {
// Scan for prompt boundary. Take the LAST candidate.
// For ambiguous chars like >, limit scan to first 60% to avoid matching redirections.
// For unambiguous prompt chars ($, #), scan the full line since they're rarely
// confused with shell syntax in a prompt position.
const lineLen = lineText.trimEnd().length;
const scanLimit = Math.min(lineLen, 200);
let lastStandardBoundary = -1;
let lastPuaBoundary = -1;
// Ambiguous chars (>) only scan first 60% to avoid matching redirections
const ambiguousScanLimit = Math.min(scanLimit, Math.max(40, Math.floor(lineLen * 0.6)));
for (let i = 0; i < scanLimit; i++) {
const ch = lineText[i];
const isStandard = PROMPT_CHARS.has(ch);
const isPua = !isStandard && isPuaChar(ch);
if (!isStandard && !isPua) continue;
// For ambiguous prompt chars like >, only accept in the first 60% of the line
if ((ch === ">" || ch === "") && i >= ambiguousScanLimit) continue;
if (
(ch === ">" || ch === "") &&
lastStandardBoundary >= 0 &&
/\s/.test(lineText.slice(0, i).trim()) &&
!isEmbeddedPromptMarker(lineText, lastStandardBoundary) &&
!canSupersedeThemedPromptBoundary(lineText, lastStandardBoundary, i)
) {
continue;
}
// Must be followed by a space or end-of-line.
const nextChar = i + 1 < lineText.length ? lineText[i + 1] : null;
if (nextChar !== null && nextChar !== " ") {
// Special case: cmd.exe prompt `C:\path>command` — allow > without space
// only if preceded by a path-like pattern (drive letter or backslash)
if (ch === ">" && i > 1 && (lineText[i - 1] === "\\" || lineText[i - 1] === "/" || /^[A-Za-z]:/.test(lineText))) {
// Looks like a path ending — accept as prompt
} else {
continue;
}
}
// For '$': exclude shell variable references ($HOME, $PATH, ${...}, $(...))
if (ch === "$") {
// Check what comes AFTER the space — but more importantly check what
// comes BEFORE to see if this looks like a prompt ending vs mid-command $.
// A prompt $ is typically preceded by: space, ), ], digit, username chars, or is at position 0.
// A variable $ is typically inside a command: echo $HOME, export PATH=$PATH:...
//
// Heuristic: if the $ is preceded by a letter/digit/underscore without a space before it
// (i.e., it's part of a token like "echo" or "=$PATH"), it's likely a variable.
if (i > 0) {
const prev = lineText[i - 1];
// If preceded by = or / or another non-separator, it's a variable reference
if (prev === "=" || prev === "/" || prev === ":") continue;
// If preceded by a letter and there's no space between, it could be $HOME-style
// But actually: "user@host:~$ " has letter before $. So check if there's
// a valid prompt pattern before the $.
}
// Check what follows: if after "$ " there's more content with $ in variable positions
// Actually the simplest reliable check: if the character after the space is alphanumeric
// or $ or (, this is likely the START of a command (i.e., this $ IS the prompt ending).
// That's always true for a prompt. So the $ check is really about false positives mid-line.
//
// Better heuristic: if we haven't seen a space before this $ (meaning the $ is inside
// the first token), it's likely a prompt. If we've already passed spaces (meaning
// we're past the first "word"), a $ is more likely a variable.
let seenSpaceBeforeDollar = false;
for (let j = 0; j < i; j++) {
if (lineText[j] === " ") { seenSpaceBeforeDollar = true; break; }
}
// If there was a space before this $, it might be mid-command (like "echo $HOME")
// Only accept if the $ is reasonably close to common prompt patterns
if (seenSpaceBeforeDollar) {
// Check if this looks like a bracketed prompt ending: "]$ " or ")$ "
if (i > 0 && (lineText[i - 1] === "]" || lineText[i - 1] === ")" ||
lineText[i - 1] === " " || lineText[i - 1] === "~")) {
// Likely a prompt ending like [user@host ~]$
} else {
continue; // Skip — likely a variable reference mid-command
}
}
}
// Record this as a candidate boundary. A standard shell prompt terminator
// is more reliable than a later Powerline/Nerd Font glyph in command text.
const boundary = nextChar === " " ? i + 2 : i + 1;
const candidatePromptText = lineText.slice(0, boundary);
if (isStandard && hasSwallowedCommandAfterPrompt(candidatePromptText, boundary)) {
continue;
}
if (isStandard && lastStandardBoundary >= 0) {
const themedPromptCanSupersede = canSupersedeThemedPromptBoundary(
lineText,
lastStandardBoundary,
getBoundaryMarkerIndex(lineText, boundary),
);
const canSupersedePreviousBoundary =
canPromptMarkerSupersedePreviousBoundary(ch) &&
(
isEmbeddedPromptMarker(lineText, lastStandardBoundary) ||
isSpacedPromptSegment(lineText, lastStandardBoundary) ||
themedPromptCanSupersede
) &&
(
themedPromptCanSupersede ||
isSpecificShellPromptCandidate(candidatePromptText, {
allowGreaterThanTerminator: ch === ">" || ch === "",
})
);
if (!canSupersedePreviousBoundary) continue;
}
if (isStandard) {
lastStandardBoundary = boundary;
} else {
lastPuaBoundary = boundary;
}
}
return lastStandardBoundary >= 0 ? lastStandardBoundary : lastPuaBoundary;
}
/**
* Reconcile a buffer-parsed prompt with the user's own keystroke history.
*
* findPromptBoundary stops at the first `PROMPT_CHAR + space` it sees, so
* themes that render additional content after the prompt char — e.g.
* oh-my-zsh's robbyrussell prints "➜ ~ " where `~` is the cwd — get
* parsed as prompt="➜ " + userInput="~ lo". Every consumer downstream
* (history recording, suggestion matching, insertion) then treats the
* theme's cwd marker as part of the user's command, which pollutes
* history with entries like "~ sudo id" and makes Tab insertions prepend
* a phantom "~ " to the typed command (issue #806).
*
* Whenever we have an independent record of what the user actually typed
* since the last Enter (keystroke buffer), we can detect this case: the
* real input is always a suffix of the over-captured userInput. When it
* is, reattribute the leading garbage back to promptText so the rest of
* the pipeline sees the clean split.
*/
export function reconcilePromptWithTypedInput(
prompt: PromptDetectionResult,
typedInput: string,
): PromptDetectionResult {
if (!prompt.isAtPrompt) return prompt;
if (!typedInput) return prompt;
if (prompt.userInput === typedInput) return prompt;
if (
prompt.userInput.length > typedInput.length &&
prompt.userInput.endsWith(typedInput)
) {
if (!canReconcilePromptDecoration(prompt, typedInput, {
allowSingleWordCommandDirectory: true,
})) {
return prompt;
}
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
return {
isAtPrompt: true,
promptText: prompt.promptText + extra,
userInput: typedInput,
cursorOffset: typedInput.length,
};
}
return prompt;
}
export function reconcilePromptWithExternalCommand(
prompt: PromptDetectionResult,
command: string,
): PromptDetectionResult | null {
const typedInput = command.trim();
if (!prompt.isAtPrompt || typedInput.length === 0) return null;
const syntheticPrompt = {
...prompt,
userInput: `${prompt.userInput}${typedInput}`,
cursorOffset: prompt.userInput.length + typedInput.length,
};
if (!canReconcilePromptDecoration(syntheticPrompt, typedInput, {
allowSingleWordCommandDirectory: true,
})) {
return null;
}
const extra = syntheticPrompt.userInput.slice(
0,
syntheticPrompt.userInput.length - typedInput.length,
);
return {
isAtPrompt: true,
promptText: prompt.promptText + extra,
userInput: typedInput,
cursorOffset: typedInput.length,
};
}
/**
* Unified entry point for any autocomplete code path that needs a prompt
* view. Every consumer (fetchSuggestions, insertSuggestion,
* handleSubDirSelect, Enter-record) goes through this one helper so the
* alignment policy lives in exactly one place — if another out-of-band
* line-rewrite path gets added later and forgets to notify the keystroke
* buffer, the worst that happens is reconcile no-ops and we degrade to
* pre-#806 behavior, not a worse pollution.
*
* Alignment rule: the keystroke buffer is usable only when it's marked
* reliable and it can be reconciled with the live line. Exact raw
* matches are safe, over-captured prompt chrome can be moved back into
* promptText, and no-space prompts can be inferred from the cursor line
* when the inferred prompt still looks like a shell prompt. Otherwise
* the buffer is ignored and the raw detector result passes through.
*/
export function getAlignedPrompt(
term: XTerm | null,
typedBuffer: string,
typedReliable: boolean,
): AlignedPromptResult {
if (!term) return { prompt: NO_PROMPT, alignedTyped: null };
const raw = detectPrompt(term);
if (!typedReliable || typedBuffer.length === 0) {
return { prompt: raw, alignedTyped: null };
}
if (raw.isAtPrompt) {
if (raw.userInput === typedBuffer) {
return { prompt: raw, alignedTyped: typedBuffer };
}
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
const prompt = reconcilePromptWithTypedInput(raw, typedBuffer);
if (prompt === raw) return { prompt: raw, alignedTyped: null };
return {
prompt,
alignedTyped: typedBuffer,
};
}
const themedDecorationAlignment = alignThemedDecorationWithPartialEcho(raw, typedBuffer);
if (themedDecorationAlignment) return themedDecorationAlignment;
if (canUseReliablePromptPrefix(raw, typedBuffer)) {
return {
prompt: withTypedUserInput(raw, typedBuffer),
alignedTyped: typedBuffer,
};
}
// No echo yet (CJK IME / high-latency SSH): surface the keystroke buffer
// on prompts detectPrompt already recognizes (#2813), but do not set
// alignedTyped. Empty / whitespace-only echo is also what echo-disabled
// password prompts and padded themed PS1s look like, so this path must
// not authorize history recording, built-in suggestion acceptance, or
// third-party completion providers until echo validates the line.
if (
raw.userInput.trim().length === 0 &&
(allowsShortPromptEcho(raw.promptText) || isThemedPromptText(raw.promptText))
) {
return {
prompt: withTypedUserInput(raw, typedBuffer),
alignedTyped: null,
allowExternalProviders: false,
};
}
}
const cursorPrefixCandidates = [
getWrappedCursorPrefix(term),
getCursorLinePrefix(term),
];
for (const cursorPrefix of cursorPrefixCandidates) {
const aligned = alignTypedInputFromCursorPrefix(raw, cursorPrefix, typedBuffer);
if (aligned) return aligned;
}
return { prompt: raw, alignedTyped: null };
}

View File

@@ -0,0 +1,109 @@
/**
* Patterns that indicate the user is NOT at a prompt
* (e.g., inside vim, less, man, top, etc.)
*/
export const NON_PROMPT_PATTERNS = [
/^~$/, // vim empty line marker
/^\s*--\s*More\s*--/, // less/more pager
/^\s*\(END\)/, // less end marker
/^:\s*$/, // vim command mode
/^\s*~\s*$/, // vim tilde lines
/^>{1,3}\s/, // Bare > (bash PS2 continuation), >> or >>> (python REPL)
/^\s{4}(?:->|['"`]>)\s/, // mysql / mariadb continuation prompts
/^(?:mysql|sqlite(?:3)?|redis(?:-cli)?|psql|mariadb)>\s/i, // mysql> / sqlite> / redis-cli> prompts
/^SQL>\s/i, // sqlplus SQL> prompts
/^(?:sftp|ftp|lftp|ghci|node|mongo|mongosh|deno|irb|pry|julia|scala|gdb|lldb|cqlsh|hive|spark-sql|jshell|ksql|trino|presto|duckdb)>\s/i,
/^irb\([^)]*\):\d+[:*]?\d*>\s/i,
/^pry\([^)]*\)>\s/i,
/^\[\d+\]\s+pry\([^)]*\)>\s/i,
/^lftp\s+\S+>\s/i,
/^\s{3}\.{3}>\s/,
/^cqlsh(?::[\w.-]+)?>\s/i,
/^(?:hive|spark-sql)\s+\([^)]+\)>\s/i,
/^(?:\d+:\s*)?jdbc:hive2?:\/\/\S+>\s/i,
/^(?:test|admin|local|config)>\s+(?:db(?:\.|\s*$)|rs\.|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*)/i,
/^[\w.-]+:[A-Z]+>\s+(?:db\.|rs\.|exit\b|(?:const|let|var|await)\b|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
/^(?:[\w.-]+\s+){0,5}\[[^\]]+\]\s+[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
/^(?:[\w.-]+\s+){1,5}[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
/^(?:trino|presto)(?::[\w.-]+){1,2}>\s/i,
/^[\w.-]+@(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3}):\d+>\s/i,
/^(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3})(?::\d+)(?:\[\d+\])?>\s/, // redis host:port> prompts
/^MariaDB\s+\[[^\]]+\]>\s/i, // MariaDB [(none)]> prompts
/^[\w.-]+=[#>]\s/, // postgres=# / postgres=> REPL prompts
/^[\w.-]+[-'"][#>]\s/, // postgres-# / postgres'# continuation prompts
/^[\w.-]+(?:\([^)]*|\*|!|\^|\$[^$]*\$)[#>]\s/, // postgres multiline prompt states
];
export const COMMON_SHELL_COMMANDS = new Set([
"alias",
"awk",
"az",
"brew",
"bun",
"bundle",
"cargo",
"cat",
"cd",
"chmod",
"chown",
"code",
"composer",
"cp",
"curl",
"docker",
"echo",
"emacs",
"env",
"export",
"find",
"gcloud",
"gh",
"git",
"go",
"gradle",
"grep",
"helm",
"java",
"javac",
"kubectl",
"less",
"ls",
"make",
"mkdir",
"mvn",
"mv",
"nano",
"node",
"npm",
"npx",
"nvim",
"php",
"pip",
"pip3",
"pnpm",
"printf",
"python",
"python3",
"rails",
"rm",
"rsync",
"ruby",
"rustc",
"scp",
"screen",
"sed",
"ssh",
"sudo",
"tail",
"tar",
"terraform",
"tmux",
"touch",
"uv",
"vi",
"vim",
"yarn",
]);
/** Characters that commonly end a shell prompt */
export const PROMPT_CHARS = new Set(["$", "#", "%", ">", "", "", "→", "➜", "➤", "⟩", "»", ""]);

View File

@@ -0,0 +1,506 @@
/**
* Remote path completion for terminal autocomplete.
* Lists files/directories on the remote (or local) machine
* when the user types commands that expect path arguments.
*/
import type { CompletionContext } from "./completionEngine";
import type { FigArg } from "./figSpecLoader";
import type { AutocompleteCwdSource } from "./terminalAutocompleteLayout";
/** Directory entry returned from IPC */
export interface DirEntry {
name: string;
type: "file" | "directory" | "symlink";
}
interface ResolvePathOptions {
preferRelativeCwd?: boolean;
}
/** Bridge interface for directory listing */
interface PathBridge {
listAutocompleteRemoteDir?: (
sessionId: string,
path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => Promise<{ success: boolean; entries: DirEntry[] }>;
listAutocompleteLocalDir?: (
path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => Promise<{ success: boolean; entries: DirEntry[] }>;
}
function getBridge(): PathBridge | undefined {
return (window as Window & { netcatty?: PathBridge }).netcatty;
}
// Cache directory listings for 5 seconds. Full-directory cache is shared between
// popup suggestions and cascading sub-directory panels; filtered cache avoids
// repeated round-trips while the user keeps typing within the same directory.
const fullDirCache = new Map<string, { entries: DirEntry[]; timestamp: number }>();
const filteredDirCache = new Map<string, { entries: DirEntry[]; timestamp: number }>();
const inFlightRequests = new Map<string, Promise<DirEntry[]>>();
const CACHE_TTL_MS = 5000;
const MAX_CACHE_SIZE = 30;
const MAX_FILTERED_CACHE_SIZE = 60;
/** Commands that commonly accept file/directory path arguments.
* Subcommand-first tools (docker, kubectl, go, cargo, make) are excluded —
* their path arguments are better handled via Fig specs. */
const PATH_COMMANDS = new Set([
// Navigation & listing
"cd", "pushd", "ls", "ll", "la", "dir", "tree", "exa", "eza", "lsd",
// Viewing & editing
"cat", "less", "more", "head", "tail", "bat", "tac", "nl", "tee",
"vim", "vi", "nvim", "nano", "emacs", "code", "subl", "micro", "helix", "hx", "joe", "mcedit",
// File operations
"cp", "mv", "rm", "mkdir", "rmdir", "touch", "ln", "install", "shred",
// Permissions & metadata
"chmod", "chown", "chgrp", "stat", "file", "lsattr", "chattr",
// Search & filter
"find", "rg", "grep", "egrep", "fgrep", "ag", "fd", "locate",
"wc", "sort", "uniq", "cut", "awk", "sed",
// Archive & compression
"tar", "zip", "unzip", "gzip", "gunzip", "bzip2", "bunzip2", "xz", "unxz", "zstd",
"7z", "rar", "unrar",
// Transfer & sync
"scp", "rsync", "diff", "cmp", "patch",
// Scripting & execution
"source", ".", "bash", "sh", "zsh", "fish",
"python", "python3", "node", "ruby", "perl", "php", "rustc", "gcc", "g++",
"deno", "bun", "tsx", "ts-node",
// Disk & filesystem
"du", "df", "chroot",
// Misc
"realpath", "readlink", "basename", "dirname", "md5sum", "sha256sum", "xxd", "hexdump",
"xdg-open", "open", "start",
]);
/** Commands that only accept directories (not files) */
const FOLDER_ONLY_COMMANDS = new Set(["cd", "mkdir", "rmdir", "pushd"]);
/**
* Check if the current command context expects a path argument.
*/
export function shouldDoPathCompletion(
ctx: CompletionContext,
resolvedArgs?: FigArg | FigArg[],
): { shouldComplete: boolean; foldersOnly: boolean } {
const currentWord = stripWrappingQuotes(ctx.currentWord);
// 1. Typed path trigger: if current word starts with path-like prefix, always complete
if (currentWord.startsWith("/") || currentWord.startsWith("./") ||
currentWord.startsWith("../") || currentWord.startsWith("~/") ||
currentWord === "." || currentWord === ".." || currentWord === "~") {
const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName);
return { shouldComplete: true, foldersOnly };
}
// 2. Fig spec template check
if (resolvedArgs) {
const args = Array.isArray(resolvedArgs) ? resolvedArgs : [resolvedArgs];
for (const arg of args) {
const templates = Array.isArray(arg.template) ? arg.template : arg.template ? [arg.template] : [];
if (templates.includes("filepaths") || templates.includes("folders")) {
return {
shouldComplete: true,
foldersOnly: templates.includes("folders") && !templates.includes("filepaths"),
};
}
}
}
// 3. Hardcoded command list (for commands without fig specs)
if (ctx.wordIndex >= 1 && PATH_COMMANDS.has(ctx.commandName)) {
// Only if we're past the command name and not typing an option
if (!currentWord.startsWith("-")) {
return {
shouldComplete: true,
foldersOnly: FOLDER_ONLY_COMMANDS.has(ctx.commandName),
};
}
}
return { shouldComplete: false, foldersOnly: false };
}
/**
* Parse the current word into directory-to-list and filter prefix.
*/
export function resolvePathComponents(
currentWord: string,
cwd: string | undefined,
options: ResolvePathOptions = {},
): { dirToList: string; filterPrefix: string; pathPrefix: string; quoteSuffix: string } {
const quotePrefix = getLeadingQuote(currentWord);
const quoteSuffix = getTrailingMatchingQuote(currentWord, quotePrefix);
const unquotedWord = stripWrappingQuotes(currentWord);
const preferRelativeCwd = options.preferRelativeCwd === true;
// Handle empty input — list CWD
if (!unquotedWord || unquotedWord === "." || unquotedWord === "~" || unquotedWord === "..") {
const dir = unquotedWord === "~"
? "~"
: unquotedWord === ".."
? resolveDirLookup("../", cwd, preferRelativeCwd)
: resolveDirLookup("", cwd, preferRelativeCwd);
const visiblePrefix = unquotedWord ? `${quotePrefix}${unquotedWord}/` : quotePrefix;
return { dirToList: dir, filterPrefix: "", pathPrefix: visiblePrefix, quoteSuffix };
}
// Find the last path separator
const lastSlash = unquotedWord.lastIndexOf("/");
if (lastSlash >= 0) {
const dirPart = unquotedWord.substring(0, lastSlash + 1); // includes trailing /
const filterPart = unquotedWord.substring(lastSlash + 1);
const decodedDirPart = decodeShellPathFragment(dirPart);
const decodedFilterPart = decodeShellPathFragment(filterPart);
const dirToList = resolveDirLookup(decodedDirPart, cwd, preferRelativeCwd);
return { dirToList, filterPrefix: decodedFilterPart, pathPrefix: quotePrefix + dirPart, quoteSuffix };
}
// No slash — filter CWD entries by the typed prefix
return {
dirToList: resolveDirLookup("", cwd, preferRelativeCwd),
filterPrefix: decodeShellPathFragment(unquotedWord),
pathPrefix: quotePrefix,
quoteSuffix,
};
}
export function normalizePathTokenForLookup(
token: string,
cwd?: string,
options: ResolvePathOptions = {},
): string {
const { dirToList, filterPrefix } = resolvePathComponents(token, cwd, options);
if (!filterPrefix) return dirToList;
if (!dirToList || dirToList === ".") {
return filterPrefix;
}
const needsSeparator = !dirToList.endsWith("/");
return `${dirToList}${needsSeparator ? "/" : ""}${filterPrefix}`;
}
/**
* Get path completion suggestions.
*/
export async function getPathSuggestions(
ctx: CompletionContext,
options: {
sessionId?: string;
protocol?: string;
os?: "linux" | "windows" | "macos";
cwd?: string;
cwdSource?: AutocompleteCwdSource;
foldersOnly: boolean;
},
): Promise<{ name: string; type: DirEntry["type"] }[]> {
const { sessionId, protocol, os, cwd, cwdSource, foldersOnly } = options;
const { dirToList, filterPrefix } = resolvePathComponents(ctx.currentWord, cwd, {
preferRelativeCwd: shouldPreferRemoteShellCwd(protocol, sessionId, os, cwd, cwdSource),
});
const entries = await listDirectoryEntries(dirToList, {
sessionId,
protocol,
os,
foldersOnly,
filterPrefix,
limit: 100,
});
return sortPathEntries(entries);
}
/**
* List directory contents via IPC, with shared caching and in-flight dedup.
*/
export async function listDirectoryEntries(
dirPath: string,
options: {
sessionId?: string;
protocol?: string;
os?: "linux" | "windows" | "macos";
foldersOnly: boolean;
filterPrefix?: string;
limit?: number;
},
): Promise<DirEntry[]> {
const {
sessionId,
protocol,
os,
foldersOnly,
filterPrefix = "",
limit = 100,
} = options;
const normalizedPrefix = filterPrefix.toLowerCase();
const maxEntries = clampLimit(limit);
const baseKey = `${protocol || "auto"}:${sessionId || "local"}:${dirPath}:${foldersOnly}`;
const fullCacheKey = `${baseKey}:all`;
const filteredCacheKey = `${baseKey}:prefix:${normalizedPrefix}:${maxEntries}`;
const bypassCache = shouldBypassCache(protocol, sessionId, os, dirPath);
const requestKey = normalizedPrefix ? filteredCacheKey : fullCacheKey;
// Full directory cache can satisfy both full and filtered lookups.
// Relative SSH cwd paths bypass durable cache and in-flight reuse: the shell
// cwd can move, so a listing started for "." in directory A must not satisfy
// a later lookup after cd into B. Soft-budget timeout + late refresh already
// share one promise at the getCompletions call site.
if (!bypassCache) {
const fullCached = fullDirCache.get(fullCacheKey);
if (isFresh(fullCached)) {
return filterEntries(fullCached.entries, normalizedPrefix, maxEntries);
}
if (normalizedPrefix) {
const filteredCached = filteredDirCache.get(filteredCacheKey);
if (isFresh(filteredCached)) {
return filteredCached.entries;
}
}
const inFlightFull = inFlightRequests.get(fullCacheKey);
if (inFlightFull) {
return filterEntries(await inFlightFull, normalizedPrefix, maxEntries);
}
const inFlight = inFlightRequests.get(requestKey);
if (inFlight) return inFlight;
}
// Make IPC call
const promise = (async (): Promise<DirEntry[]> => {
try {
const bridge = getBridge();
if (!bridge) return [];
let result: { success: boolean; entries: DirEntry[] };
if (protocol === "local" || !sessionId) {
if (!bridge.listAutocompleteLocalDir) return [];
result = await bridge.listAutocompleteLocalDir(
dirPath,
foldersOnly,
normalizedPrefix || undefined,
maxEntries,
);
} else {
if (!bridge.listAutocompleteRemoteDir) return [];
result = await bridge.listAutocompleteRemoteDir(
sessionId,
dirPath,
foldersOnly,
normalizedPrefix || undefined,
maxEntries,
);
}
if (result.success) {
const timestamp = Date.now();
if (bypassCache) {
return result.entries;
}
if (normalizedPrefix) {
filteredDirCache.set(requestKey, { entries: result.entries, timestamp });
evictOldest(filteredDirCache, MAX_FILTERED_CACHE_SIZE);
return result.entries;
}
fullDirCache.set(requestKey, { entries: result.entries, timestamp });
evictOldest(fullDirCache, MAX_CACHE_SIZE);
return result.entries;
}
return [];
} catch {
return [];
} finally {
if (!bypassCache) {
inFlightRequests.delete(requestKey);
}
}
})();
if (!bypassCache) {
inFlightRequests.set(requestKey, promise);
}
return promise;
}
function clampLimit(limit: number): number {
if (!Number.isFinite(limit)) return 100;
return Math.max(1, Math.min(200, Math.floor(limit)));
}
function resolveDirLookup(pathToken: string, cwd: string | undefined, preferRelativeCwd = false): string {
if (!pathToken) return preferRelativeCwd ? "." : (cwd || ".");
if (pathToken.startsWith("/")) return normalizePosixLikePath(pathToken);
if (pathToken === "~" || pathToken.startsWith("~/")) return normalizePosixLikePath(pathToken);
if (preferRelativeCwd) return normalizePosixLikePath(pathToken);
if (cwd) return normalizePosixLikePath(`${cwd}/${pathToken}`);
return normalizePosixLikePath(pathToken);
}
export function shouldPreferRemoteShellCwd(
protocol: string | undefined,
sessionId: string | undefined,
os?: "linux" | "windows" | "macos",
cwd?: string,
cwdSource?: AutocompleteCwdSource,
): boolean {
if (cwdSource === "prompt" && cwd?.startsWith("/")) return false;
return Boolean(sessionId && protocol !== "local" && os === "linux");
}
function shouldBypassCache(
protocol: string | undefined,
sessionId: string | undefined,
os: "linux" | "windows" | "macos" | undefined,
dirPath: string,
): boolean {
if (!shouldPreferRemoteShellCwd(protocol, sessionId, os)) return false;
return !dirPath.startsWith("/") && dirPath !== "~" && !dirPath.startsWith("~/");
}
function normalizePosixLikePath(input: string): string {
if (!input) return ".";
const hasLeadingSlash = input.startsWith("/");
const hasTildeRoot = input === "~" || input.startsWith("~/");
const hasTrailingSlash = input.length > 1 && input.endsWith("/");
const fixedRootSegments = hasTildeRoot ? 1 : 0;
const raw = hasLeadingSlash
? input.slice(1)
: hasTildeRoot
? input.slice(2)
: input;
const segments = hasTildeRoot ? ["~"] : [];
for (const segment of raw.split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") {
if (
segments.length > fixedRootSegments &&
segments[segments.length - 1] !== ".."
) {
segments.pop();
} else if (!hasLeadingSlash || hasTildeRoot) {
segments.push(segment);
}
continue;
}
segments.push(segment);
}
let result: string;
if (hasLeadingSlash) {
result = "/" + segments.join("/");
if (result === "/") return result;
} else if (segments.length > 0) {
result = segments.join("/");
} else if (hasTildeRoot) {
result = "~";
} else {
result = ".";
}
if (hasTrailingSlash && result !== "/" && result !== "." && result !== "~") {
result += "/";
} else if (hasTrailingSlash && result === "~") {
result = "~/";
}
return result;
}
function isFresh(
cached: { entries: DirEntry[]; timestamp: number } | undefined,
): cached is { entries: DirEntry[]; timestamp: number } {
return Boolean(cached && Date.now() - cached.timestamp < CACHE_TTL_MS);
}
function filterEntries(entries: DirEntry[], filterPrefix: string, limit: number): DirEntry[] {
if (!filterPrefix) return entries.slice(0, limit);
const filtered: DirEntry[] = [];
for (const entry of entries) {
if (entry.name.toLowerCase().startsWith(filterPrefix)) {
filtered.push(entry);
if (filtered.length >= limit) break;
}
}
return filtered;
}
function evictOldest(
cache: Map<string, { entries: DirEntry[]; timestamp: number }>,
maxSize: number,
): void {
while (cache.size > maxSize) {
const oldestKey = cache.keys().next().value;
if (!oldestKey) break;
cache.delete(oldestKey);
}
}
function decodeShellPathFragment(value: string): string {
let result = "";
let escaped = false;
for (const ch of value) {
if (escaped) {
result += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
result += ch;
}
if (escaped) result += "\\";
return result;
}
function getLeadingQuote(value: string): string {
return value.startsWith('"') || value.startsWith("'") ? value[0] : "";
}
function getTrailingMatchingQuote(value: string, quotePrefix: string): string {
return quotePrefix && value.endsWith(quotePrefix) ? quotePrefix : "";
}
function stripWrappingQuotes(value: string): string {
if (!value) return value;
let result = value;
if (result.startsWith('"') || result.startsWith("'")) {
result = result.slice(1);
}
if (result.endsWith('"') || result.endsWith("'")) {
result = result.slice(0, -1);
}
return result;
}
function sortPathEntries(entries: DirEntry[]): DirEntry[] {
return [...entries].sort((left, right) => {
const leftRank = left.type === "directory" ? 0 : left.type === "symlink" ? 1 : 2;
const rightRank = right.type === "directory" ? 0 : right.type === "symlink" ? 1 : 2;
if (leftRank !== rightRank) return leftRank - rightRank;
return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
});
}

View File

@@ -0,0 +1,65 @@
/**
* Snippet completion source. Surfaces custom snippets in terminal autocomplete
* when the user is typing the command name. Matches against the snippet label
* and the first line of its command (case-insensitive; prefix matches rank
* above substring matches). Chinese labels also match via pinyin / initials
* through the shared search matcher (#2813). Each suggestion carries the full
* Snippet so the accept path can run it through the canonical executeSnippetCommand.
*/
import type { Snippet } from "../../../domain/models";
import { snippetAppliesToHost } from "../../../domain/snippetTargets";
import { matchesSearchQuery } from "../../../lib/searchMatcher";
import type { CompletionSuggestion } from "./completionEngine";
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
const SNIPPET_PREFIX_BONUS = 100;
function snippetAvailableForAutocomplete(
snippet: Snippet,
host: { hostId?: string; hostGroup?: string },
): boolean {
if (snippet.targetsAllHosts) return true;
const hasScopedTargets = Boolean(
snippet.targets?.length || snippet.targetGroups !== undefined,
);
if (!hasScopedTargets) return true;
if (!host.hostId) return false;
return snippetAppliesToHost(snippet, { id: host.hostId, group: host.hostGroup });
}
export function getSnippetSuggestions(
input: string,
snippets: Snippet[],
options: { hostId?: string; hostGroup?: string } = {},
): CompletionSuggestion[] {
const needle = input.trim().toLowerCase();
if (!needle || !Array.isArray(snippets)) return [];
const out: CompletionSuggestion[] = [];
for (const snippet of snippets) {
if (!snippetAvailableForAutocomplete(snippet, options)) continue;
const label = (snippet.label || "").toLowerCase();
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
const labelPrefix = label.startsWith(needle);
// Literal prefix/substring first (cheap); fall back to shared smart matcher
// so Chinese titles surface for pinyin / initials the same way host search does.
const matches = labelPrefix
|| label.includes(needle)
|| firstLine.startsWith(needle)
|| matchesSearchQuery(needle, snippet.label, firstLine);
if (!matches) continue;
out.push({
text: snippet.label,
displayText: snippet.label,
description: snippet.command,
source: "snippet",
score: SNIPPET_BASE_SCORE + (labelPrefix ? SNIPPET_PREFIX_BONUS : 0),
snippet,
});
}
out.sort((a, b) => b.score - a.score);
return out;
}

View File

@@ -0,0 +1,278 @@
import type { MutableRefObject, RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import type { GhostTextAddon } from "./GhostTextAddon";
import type { AutocompleteSettings } from "./useTerminalAutocomplete";
import { getAlignedPrompt } from "./promptDetector";
import { recordCommand } from "./commandHistoryStore";
import { getCommandToRecordOnEnter } from "./terminalAutocompletePrompt";
interface TerminalAutocompleteInputContext {
settingsRef: MutableRefObject<AutocompleteSettings>;
lastKeystrokeRef: MutableRefObject<number>;
suppressNextEnterRecordRef: MutableRefObject<boolean>;
lastAcceptedCommandRef: MutableRefObject<string | null>;
typedInputBufferRef: MutableRefObject<string>;
typedBufferReliableRef: MutableRefObject<boolean>;
previewBaselineRef: MutableRefObject<string>;
previewActiveRef: MutableRefObject<boolean>;
termRef: RefObject<XTerm | null>;
hostIdRef: MutableRefObject<string>;
hostOsRef: MutableRefObject<"linux" | "windows" | "macos">;
ghostAddonRef: MutableRefObject<GhostTextAddon | null>;
debounceTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>;
clearState: () => void;
syncPopupToInput: (input: string | null) => void;
fetchSuggestions: () => void | Promise<void>;
}
export function handleTerminalAutocompleteInput(
data: string,
context: TerminalAutocompleteInputContext,
): void {
const {
settingsRef,
lastKeystrokeRef,
suppressNextEnterRecordRef,
lastAcceptedCommandRef,
typedInputBufferRef,
typedBufferReliableRef,
previewBaselineRef,
previewActiveRef,
termRef,
hostIdRef,
hostOsRef,
ghostAddonRef,
debounceTimerRef,
clearState,
syncPopupToInput,
fetchSuggestions,
} = context;
if (!settingsRef.current.enabled) {
return;
}
const now = Date.now();
const timeSinceLastKeystroke = now - lastKeystrokeRef.current;
lastKeystrokeRef.current = now;
// Command recording: Enter key
if (data === "\r" || data === "\n") {
// Skip recording if selectAndExecute already recorded this command
if (suppressNextEnterRecordRef.current) {
suppressNextEnterRecordRef.current = false;
} else {
// If user accepted a completion (Tab/→) and immediately pressed Enter,
// the buffer may not reflect the accepted text yet. Use the tracked value.
if (lastAcceptedCommandRef.current) {
recordCommand(lastAcceptedCommandRef.current, hostIdRef.current, hostOsRef.current);
} else {
// Require a live prompt before trusting either keystroke buffer
// or buffer-based detection — otherwise sudo password Enter
// would record the typed password as a command.
const typedBuffer = typedInputBufferRef.current;
const typedBufferReliable = typedBufferReliableRef.current;
const { prompt: livePrompt, alignedTyped } = getAlignedPrompt(
termRef.current,
typedBuffer,
typedBufferReliable,
);
const commandToRecord = getCommandToRecordOnEnter(
livePrompt,
alignedTyped,
typedBuffer,
typedBufferReliable,
);
if (commandToRecord) {
recordCommand(commandToRecord, hostIdRef.current, hostOsRef.current);
}
}
lastAcceptedCommandRef.current = null;
}
typedInputBufferRef.current = "";
typedBufferReliableRef.current = true;
clearState();
return;
}
// Ctrl+C, Ctrl+U — clear. These kill the zle line entirely, so the
// buffer is once again a true reflection of the (empty) line.
if (data === "\x03" || data === "\x15") {
typedInputBufferRef.current = "";
typedBufferReliableRef.current = true;
// Same rationale as the ctrl/escape early returns below: any
// previously-accepted suggestion is gone from the line too, so
// accept → Ctrl-C → type "foo" → Enter must not log the stale
// accepted command via the Enter fast path.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
// Backspace / DEL: drop the last typed char so the buffer stays aligned
// with what the shell actually holds.
if (data === "\x7f" || data === "\b") {
typedInputBufferRef.current = typedInputBufferRef.current.slice(0, -1);
} else if (data === "\x17") {
// Ctrl+W: word-erase — kill the trailing whitespace + word.
typedInputBufferRef.current = typedInputBufferRef.current.replace(/\s*\S+\s*$/, "");
} else if (data.startsWith("\x1b[200~")) {
// Bracketed paste: "\x1b[200~...\x1b[201~". The inner bytes are
// literal input, so newlines stay on the zle line instead of
// executing each segment — meaning we must preserve the whole
// content in the buffer, not just the post-final-newline tail
// (Codex #814 P2).
//
// Reliability is *inherited*, not reset: if the buffer was
// already aligned with the line (reliable=true), appending this
// paste keeps it aligned; if the buffer was unreliable (e.g.
// after ↑ recalled a history command so line ≠ buffer), the
// paste only extends the tail but the head is still whatever
// the shell had, so the buffer stays unreliable. Without this,
// a paste-after-recall flow would flip reliability back on and
// Enter would record just the pasted suffix as the command
// (Codex #814 P1 follow-up).
const endIdx = data.indexOf("\x1b[201~");
const content = endIdx >= 0
? data.slice("\x1b[200~".length, endIdx)
: data.slice("\x1b[200~".length);
typedInputBufferRef.current += content;
// Paste extends the line past whatever was accepted, so the
// Enter fast-path must not record the pre-paste accepted
// command — mirrors the non-bracketed paste branch below.
lastAcceptedCommandRef.current = null;
clearState();
return;
} else if (data.startsWith("\x1b") && data !== "\x1b") {
// Cursor-movement / function keys — we lose track of where the
// cursor sits relative to our append-only buffer. Mark the
// buffer unreliable and drop it; detectPrompt takes over until
// the next Enter / Ctrl-C / Ctrl-U.
typedInputBufferRef.current = "";
typedBufferReliableRef.current = false;
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
typedInputBufferRef.current += data;
} else if (data.length > 1 && !data.startsWith("\x1b")) {
// Paste chunk. Any \r / \n inside executes the preceding text as
// a command in the shell, so keeping the pre-newline portion in
// our buffer would leave stale content that a later Enter could
// record (Codex #814 P2). Drop everything up to and including
// the last terminator and keep only the tail as new content.
// Intermediate executed lines aren't synthesized back into
// recordCommand here — the onCommandExecuted path in
// createXTermRuntime still captures them independently.
const lastCR = data.lastIndexOf("\r");
const lastLF = data.lastIndexOf("\n");
const nlIdx = Math.max(lastCR, lastLF);
if (nlIdx >= 0) {
typedInputBufferRef.current = data.slice(nlIdx + 1);
typedBufferReliableRef.current = true;
// The embedded newline flushed any previously-accepted
// suggestion too — clearing the cache here prevents the next
// Enter from falling into the lastAcceptedCommandRef fast path
// and recording that stale command.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
typedInputBufferRef.current += data;
} else if (data.length === 1 && data.charCodeAt(0) < 32) {
// Any other single control char (Ctrl-A, Ctrl-E, Ctrl-B, Ctrl-F,
// Ctrl-R, Ctrl-P, Ctrl-N, ...) moves the cursor or swaps the
// line in ways this append-only buffer can't follow. Same story
// as escape sequences above — and hide the ghost too, so the
// unreliable-accept fallback doesn't pull a stale tail onto a
// recalled line (Codex #815 follow-up).
typedInputBufferRef.current = "";
typedBufferReliableRef.current = false;
// Null the fast-path accepted-command cache: accept-then-Ctrl-R
// should not let an old accepted command sneak back in via the
// Enter fast path after reverse-search picks a different one.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
// Escape sequences (arrow keys, Home, End, etc.): clear stale suggestions
// since cursor position may have changed, making current suggestions invalid.
// Up/Down/Right/Tab are handled by handleKeyEvent; other sequences land here.
if (data.startsWith("\x1b") && data !== "\x1b") {
// Same fast-path reset as the single-byte ctrl-char branch above —
// accept-then-↑/↓ must not record the stale accepted command if
// the user then presses Enter on a different recalled line.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
// User is typing more — invalidate accepted command fallback since the
// command is being edited further (e.g., accepted "git status" then added " --short")
lastAcceptedCommandRef.current = null;
// The previewed candidate is now edited, so the line is the user's own
// text. Drop preview-active so Escape dismisses the popup without
// reverting these edits back to the stale baseline (#1005).
previewActiveRef.current = false;
if (typedBufferReliableRef.current) {
previewBaselineRef.current = typedInputBufferRef.current;
}
// The popup must follow the edited line immediately, before the debounced
// provider refresh runs. Reconcile stale history rows against the current
// input; an unreliable append-only buffer cannot validate any old row.
if (settingsRef.current.showPopupMenu) {
const currentInput = typedBufferReliableRef.current
? typedInputBufferRef.current
: null;
syncPopupToInput(
currentInput !== null && currentInput.length >= settingsRef.current.minChars
? currentInput
: null,
);
}
// Re-align any visible ghost text to the freshly-updated buffer
// immediately. Without this the ghost keeps the tail it captured at
// show() time; a fast "type + press →" sequence then pastes the
// pre-update tail on top of the new input ("doc" + "cker ls" →
// "doccker ls"). Skip when the user has turned showGhostText off
// mid-session: otherwise a ghost that was active before the toggle
// would keep moving around under a setting the user just said to
// disable (Codex #815 P2).
//
// Reliable buffer: feed adjustToInput the full post-mutation buffer
// so multi-char pastes refresh the ghost as one batch. Unreliable
// buffer (post Tab / cursor-move / history recall): the buffer
// is just the suffix typed since unreliability began, so feeding
// it to adjustToInput would fail the prefix invariant and hide
// the ghost. Instead let the addon evolve its own currentInput
// off the keystroke directly (issue #906) — that input was seeded
// by the last show() with the live xterm reading, which is the
// only post-Tab source-of-truth we have.
if (settingsRef.current.showGhostText) {
if (typedBufferReliableRef.current) {
ghostAddonRef.current?.adjustToInput(typedInputBufferRef.current);
} else {
ghostAddonRef.current?.applyKeystroke(data);
}
}
// Fast typing suppression: if typing faster than threshold, skip this debounce cycle
const isFastTyping = timeSinceLastKeystroke < settingsRef.current.fastTypingThresholdMs;
// Debounced suggestion fetch
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (isFastTyping) {
// Still debounce, but with a longer delay to wait for typing to pause
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
void fetchSuggestions();
}, settingsRef.current.debounceMs * 3);
} else {
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
void fetchSuggestions();
}, settingsRef.current.debounceMs);
}
}

View File

@@ -0,0 +1,387 @@
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import type { GhostTextAddon } from "./GhostTextAddon";
import type { AutocompleteSettings, AutocompleteState, SubDirEntry } from "./useTerminalAutocomplete";
import type { Snippet } from "../../../domain/models";
interface TerminalAutocompleteKeyEventContext {
settingsRef: MutableRefObject<AutocompleteSettings>;
stateRef: MutableRefObject<AutocompleteState>;
ghostAddonRef: MutableRefObject<GhostTextAddon | null>;
typedInputBufferRef: MutableRefObject<string>;
typedBufferReliableRef: MutableRefObject<boolean>;
previewActiveRef: MutableRefObject<boolean>;
lastAcceptedCommandRef: MutableRefObject<string | null>;
setState: Dispatch<SetStateAction<AutocompleteState>>;
expandSubDir: (level: number, entry: SubDirEntry, moveFocus?: boolean) => void;
writeToTerminal: (text: string) => void;
clearState: () => void;
renderSubDirPath: (level: number, entry: SubDirEntry) => void;
handleSubDirSelect: (level: number, entry: SubDirEntry) => void;
fetchSubDirForIndex: (index: number) => void;
renderPreviewSelection: (index: number) => void;
acceptPreviewlessSelection: (index: number) => boolean;
acceptSnippet: (snippet: Snippet) => boolean;
/** Deadline (ms) until which `.` / `_` are treated as readline Meta follow-ups. */
escMetaPrefixUntilRef: MutableRefObject<number>;
now?: () => number;
}
/** Readline keyseq-timeout default; Esc then . within this window is M-. */
export const AUTOCOMPLETE_ESC_META_TIMEOUT_MS = 500;
export function autocompleteEscMetaFollowUpSequence(e: {
key: string;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
}): string | null {
if (e.altKey || e.ctrlKey || e.metaKey) return null;
// `_` is Shift+Minus on a standard keyboard; Shift+. is `>` and must not yank.
if (e.key === "." && !e.shiftKey) return "\x1b.";
if (e.key === "_") return "\x1b_";
return null;
}
const isAutocompleteConfirmEnter = (
e: KeyboardEvent,
settings: AutocompleteSettings,
): boolean => (
e.key === "Enter" &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
(!e.shiftKey || settings.shiftEnterNewlineEnabled === false)
);
export function handleTerminalAutocompleteKeyEvent(
e: KeyboardEvent,
context: TerminalAutocompleteKeyEventContext,
): boolean {
const {
settingsRef,
stateRef,
ghostAddonRef,
typedInputBufferRef,
typedBufferReliableRef,
previewActiveRef,
lastAcceptedCommandRef,
setState,
expandSubDir,
writeToTerminal,
clearState,
renderSubDirPath,
handleSubDirSelect,
fetchSubDirForIndex,
renderPreviewSelection,
acceptPreviewlessSelection,
acceptSnippet,
escMetaPrefixUntilRef,
now = Date.now,
} = context;
if (!settingsRef.current.enabled || e.type !== "keydown") return true;
const metaFollowUp = autocompleteEscMetaFollowUpSequence(e);
if (metaFollowUp && now() < escMetaPrefixUntilRef.current) {
escMetaPrefixUntilRef.current = 0;
e.preventDefault();
writeToTerminal(metaFollowUp);
// Match handleTerminalAutocompleteInput's ESC-sequence path: yank-last-arg
// rewrites the shell line, so the append-only typed buffer is stale.
typedInputBufferRef.current = "";
typedBufferReliableRef.current = false;
lastAcceptedCommandRef.current = null;
return false;
}
if (e.key !== "Escape" && e.key !== "Shift" && e.key !== "Control" && e.key !== "Alt" && e.key !== "Meta") {
escMetaPrefixUntilRef.current = 0;
}
const s = stateRef.current;
const ghost = ghostAddonRef.current;
// Right arrow: if popup has selected directory with sub-dir panel, enter it
// Skip this handler entirely when sub-dir panels are focused — let the
// sub-panel navigation block handle → for deeper expansion.
if (e.key === "ArrowRight" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && s.subDirFocusLevel < 0) {
if (s.popupVisible && s.selectedIndex >= 0 && s.subDirPanels.length > 0) {
const selected = s.suggestions[s.selectedIndex];
if (selected?.fileType === "directory") {
e.preventDefault();
const firstEntry = s.subDirPanels[0]?.entries[0];
setState((prev) => {
const panels = [...prev.subDirPanels];
if (panels[0]) panels[0] = { ...panels[0], selectedIndex: 0 };
return { ...prev, subDirPanels: panels, subDirFocusLevel: 0 };
});
if (firstEntry?.type === "directory") {
expandSubDir(0, firstEntry, false);
}
return false;
}
}
// Otherwise: accept ghost text. Use isActive(), not isVisible(),
// so a fast "type + →" that lands in the hide-until-render gap
// still hits this branch and accepts the pending ghost.
if (ghost?.isActive()) {
e.preventDefault();
const fullSuggestion = ghost.getSuggestion();
// When the keystroke buffer is reliable, recompute the tail
// against the *live* buffer so a fast "type + →" in the
// hide-until-render gap still writes the correct tail. When
// it's not reliable (post history-recall / Ctrl-R), we can't
// treat empty buffer as "nothing typed" — the line actually
// has content we're not tracking — so fall back to the
// ghost's own cached tail instead of writing the entire
// suggestion onto an already-populated line.
let ghostText: string;
let newBuffer: string | null;
if (typedBufferReliableRef.current) {
const live = typedInputBufferRef.current;
if (fullSuggestion && fullSuggestion.startsWith(live)) {
ghostText = fullSuggestion.substring(live.length);
newBuffer = fullSuggestion;
} else {
ghostText = "";
newBuffer = null;
}
} else {
ghostText = ghost.getGhostText();
newBuffer = null; // buffer is unreliable; don't flip it back on
}
if (ghostText) {
writeToTerminal(ghostText);
lastAcceptedCommandRef.current = fullSuggestion;
if (newBuffer !== null) {
typedInputBufferRef.current = newBuffer;
typedBufferReliableRef.current = true;
}
ghost.hide();
clearState();
} else {
ghost.hide();
}
return false;
}
}
// Ctrl+Right / Alt+Right (Mac): accept next word
if (e.key === "ArrowRight" && (e.ctrlKey || e.altKey) && !e.metaKey && !e.shiftKey) {
if (ghost?.isActive()) {
e.preventDefault();
const fullSuggestion = ghost.getSuggestion();
if (!fullSuggestion) {
ghost.hide();
return false;
}
// Determine the baseline the next word should extend. Reliable
// buffer: resync the ghost to the live buffer so getNextWord
// operates on the up-to-date tail. Unreliable buffer (post
// history-recall / Ctrl-R): don't reanchor to "" — that would
// make getNextWord hand back the very first word and the shell
// would duplicate leading tokens on top of the recalled line.
// Fall back to the ghost's existing cached input instead.
if (typedBufferReliableRef.current) {
const live = typedInputBufferRef.current;
if (fullSuggestion.startsWith(live)) {
ghost.show(fullSuggestion, live);
} else {
ghost.hide();
return false;
}
}
const base = ghost.getGhostText().length > 0
? fullSuggestion.substring(0, fullSuggestion.length - ghost.getGhostText().length)
: fullSuggestion;
const nextWord = ghost.getNextWord();
if (nextWord) {
writeToTerminal(nextWord);
// Only extend the buffer if it was already aligned with the
// line — otherwise we'd end up with just the appended word,
// which the next Enter would then record as the command.
if (typedBufferReliableRef.current) {
typedInputBufferRef.current += nextWord;
}
// Shrink the ghost to reflect what's left after the accept.
const newInput = base + nextWord;
if (fullSuggestion.startsWith(newInput) && fullSuggestion.length > newInput.length) {
ghost.show(fullSuggestion, newInput);
} else {
ghost.hide();
}
}
return false;
}
}
// Tab: accept selected popup suggestion. Ghost text is accepted via → only —
// letting Tab pass through lets the shell's native completion (bash/zsh) run,
// which is otherwise shadowed by our single-Tab ghost accept.
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
if (s.popupVisible && s.suggestions.length > 0) {
// #1005: don't intercept Tab. Keep whatever is currently rendered on
// the line and let Tab reach the shell for native completion.
clearState();
previewActiveRef.current = false;
return true;
}
// Hide stale ghost text before Tab reaches the shell — the shell's
// completion will rewrite the line and the old ghost would mislead.
if (ghost?.isActive()) {
ghost.hide();
}
}
// Up/Down/Left/Right: navigate popup + sub-dir panel
if (s.popupVisible && s.suggestions.length > 0) {
const focusLevel = s.subDirFocusLevel;
const focusedPanel = focusLevel >= 0 ? s.subDirPanels[focusLevel] : null;
// Sub-dir panel focused: ↑↓ navigate, ← go back, → go deeper
if (focusLevel >= 0 && focusedPanel) {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const newIdx = e.key === "ArrowUp"
? (focusedPanel.selectedIndex <= 0 ? focusedPanel.entries.length - 1 : focusedPanel.selectedIndex - 1)
: (focusedPanel.selectedIndex >= focusedPanel.entries.length - 1 ? 0 : focusedPanel.selectedIndex + 1);
setState((prev) => {
const panels = [...prev.subDirPanels];
const p = panels[focusLevel];
if (!p) return prev;
panels[focusLevel] = { ...p, selectedIndex: newIdx };
return { ...prev, subDirPanels: panels.slice(0, focusLevel + 1) };
});
// Live-render the highlighted entry's full path into the line (#1005).
const newEntry = focusedPanel.entries[newIdx];
if (newEntry && settingsRef.current.livePreview) renderSubDirPath(focusLevel, newEntry);
// Auto-expand next level if the newly selected item is a directory
if (newEntry?.type === "directory") {
expandSubDir(focusLevel, newEntry);
}
return false;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
setState((prev) => ({
...prev,
subDirPanels: prev.subDirPanels.slice(0, focusLevel + 1),
subDirFocusLevel: focusLevel - 1,
}));
return false;
}
if (e.key === "ArrowRight") {
const entry = focusedPanel.entries[focusedPanel.selectedIndex];
if (entry?.type === "directory") {
e.preventDefault();
expandSubDir(focusLevel, entry, true); // moveFocus = true
return false;
}
}
if (isAutocompleteConfirmEnter(e, settingsRef.current) || e.key === "Tab") {
const entry = focusedPanel.entries[focusedPanel.selectedIndex];
if (entry && focusedPanel.selectedIndex >= 0) {
e.preventDefault();
handleSubDirSelect(focusLevel, entry);
return false;
}
}
if (e.key === "Escape") {
e.preventDefault();
if (focusLevel > 0) {
setState((prev) => ({
...prev,
subDirPanels: prev.subDirPanels.slice(0, focusLevel),
subDirFocusLevel: focusLevel - 1,
}));
} else {
setState((prev) => ({ ...prev, subDirPanels: [], subDirFocusLevel: -1 }));
}
return false;
}
if (
e.key.length === 1 ||
e.key === "Backspace" ||
e.key === "Delete" ||
e.key === "Home" ||
e.key === "End"
) {
clearState();
}
return true;
}
// Main panel navigation. The cycle includes a -1 "no selection" slot so
// ↑ off the top / ↓ off the bottom reverts to the typed baseline. Moving
// the selection live-renders the candidate into the command line (#1005).
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const n = s.suggestions.length;
const cur = s.selectedIndex;
const next =
e.key === "ArrowDown"
? (cur >= n - 1 ? -1 : cur + 1)
: (cur <= -1 ? n - 1 : cur - 1);
setState((prev) => ({
...prev,
selectedIndex: next,
subDirPanels: [], subDirFocusLevel: -1,
}));
if (settingsRef.current.livePreview) renderPreviewSelection(next);
if (next >= 0) fetchSubDirForIndex(next);
return false;
}
// Enter on popup. The selected candidate is already rendered into the
// line by live-preview, so let Enter reach the shell. Don't record here:
// handleInput's Enter path records the *actual* line — it uses
// lastAcceptedCommandRef (set on select) but falls back to the live
// buffer when the user edited the previewed command (typing nulls that
// ref), so recording stays accurate in both cases.
if (isAutocompleteConfirmEnter(e, settingsRef.current)) {
const selected = s.selectedIndex >= 0 ? s.suggestions[s.selectedIndex] : null;
if (selected?.source === "snippet" && selected.snippet) {
if (!acceptSnippet(selected.snippet)) {
clearState();
previewActiveRef.current = false;
return true;
}
e.preventDefault();
previewActiveRef.current = false;
return false; // consume — run the snippet, not the typed text
}
if (!settingsRef.current.livePreview && selected) {
if (acceptPreviewlessSelection(s.selectedIndex)) {
e.preventDefault();
previewActiveRef.current = false;
return false;
}
clearState();
previewActiveRef.current = false;
return true;
}
clearState();
previewActiveRef.current = false;
return true;
}
}
// Escape: close popup and hide ghost text.
// Only consume Escape if popup is visible; don't block Escape for vi-mode shells
// when only ghost text is showing (ghost text is passive/non-intrusive).
// After dismissing the popup, arm a short Meta prefix so Esc+. / Esc+_ still
// reach readline yank-last-arg (issue #2364) without entering vi-cmd mode.
if (e.key === "Escape" && s.popupVisible) {
e.preventDefault();
if (previewActiveRef.current) {
renderPreviewSelection(-1); // restore the typed baseline
}
ghost?.hide();
clearState();
previewActiveRef.current = false;
escMetaPrefixUntilRef.current = now() + AUTOCOMPLETE_ESC_META_TIMEOUT_MS;
return false;
}
return true;
}

View 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 };
}

View File

@@ -0,0 +1,211 @@
import { isSensitiveTerminalChallenge } from "../../../domain/terminalPromptSecurity";
import {
isNonPromptLine,
reconcilePromptWithExternalCommand,
type PromptDetectionResult,
} from "./promptDetector";
import { computeLivePreviewWrite } from "./livePreviewSequence";
const THEMED_PROMPT_MARKERS = /[❯❮→➜➤⟩»›]/;
function hasStandardShellPromptTerminator(promptText: string): boolean {
return /[$#%>]$/.test(promptText.trimEnd());
}
function isSingleThemedPromptTerminator(promptText: string): boolean {
const trimmed = promptText.trim();
if (trimmed.length !== 1) return false;
const code = trimmed.charCodeAt(0);
return THEMED_PROMPT_MARKERS.test(trimmed) || (code >= 0xE000 && code <= 0xF8FF);
}
function isThemedPromptPathToken(token: string): boolean {
return (
token === "~" ||
token.startsWith("~/") ||
token.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(token) ||
token.includes("\\")
);
}
function hasThemedPromptDecorationInInput(prompt: PromptDetectionResult): boolean {
const hasThemedPromptMarker =
THEMED_PROMPT_MARKERS.test(prompt.promptText) ||
Array.from(prompt.promptText).some((ch) => {
const code = ch.charCodeAt(0);
return code >= 0xE000 && code <= 0xF8FF;
});
if (hasThemedPromptMarker && hasStandardShellPromptTerminator(prompt.promptText)) {
return false;
}
if (hasThemedPromptMarker && isSingleThemedPromptTerminator(prompt.promptText)) {
const firstToken = prompt.userInput.trimStart().match(/^\S+/)?.[0] ?? "";
return (
(prompt.userInput.startsWith(" ") || isThemedPromptPathToken(firstToken)) &&
/\S+\s+\S/.test(prompt.userInput)
);
}
return hasThemedPromptMarker && /\S+\s+\S/.test(prompt.userInput);
}
/**
* Command-line text used for autocomplete matching (popup / ghost).
*
* Enter recording keeps a stricter echo-alignment policy so short lagging
* prefixes are not committed as history. Autocomplete can safely prefer the
* reliable keystroke buffer when it is ahead of the remote shell echo —
* otherwise high-latency SSH drops local history/fig matches until the user
* pauses and the echo catches up (#2830).
*/
export function resolveAutocompleteQueryInput(
prompt: PromptDetectionResult,
typedBuffer: string,
typedBufferReliable: boolean,
): string | null {
if (!prompt.isAtPrompt) return null;
// Prefer the keystroke buffer when it is reliably aligned with the remote
// echo as a shared prefix in either direction:
// - buffer ahead of echo (typing faster than SSH echo)
// - echo ahead of buffer (partial/full backspace while echo still lags)
// Without the second case, a lagging echo of deleted characters would keep
// driving completions/accept (e.g. typed `gi` + echo `git` → accept
// ` status` → remote `gi status`). An unreliable empty buffer is different:
// history recall / cursor moves clear the buffer without meaning the line
// is empty, so fall through to prompt.userInput there.
if (
typedBufferReliable &&
(typedBuffer.startsWith(prompt.userInput) ||
prompt.userInput.startsWith(typedBuffer))
) {
return typedBuffer;
}
return prompt.userInput;
}
/**
* Whether an in-flight completion result still belongs to the active query.
*
* Live preview rewrites the typed buffer to the highlighted candidate, so a
* naive `currentInput === queryInput` check would drop late path listings
* while a preview row remains selected.
*/
export function isSameAutocompleteQuery(options: {
queryInput: string;
currentInput: string | null;
previewActive: boolean;
previewBaseline: string;
}): boolean {
if (options.currentInput === null) return false;
if (options.currentInput === options.queryInput) return true;
return options.previewActive && options.previewBaseline === options.queryInput;
}
/**
* Whether fetchSuggestions must refuse to query/render for an already-known
* sensitive line (host latch or auth-challenge prompt text).
*
* This is *not* a substitute for the empty-echo / `allowExternalProviders:
* false` wait in useTerminalAutocomplete: `read -s -p '$ '` still looks like
* a normal shell PS1 until echo validates, so that path stays fail-closed
* separately (#2814).
*/
export function shouldBlockAutocompleteForSensitivePrompt(options: {
sensitiveInputActive: boolean;
promptText: string;
}): boolean {
if (options.sensitiveInputActive) return true;
return isSensitiveTerminalChallenge(options.promptText);
}
/**
* Keystrokes that rewrite the remote command line to `candidate`.
*
* Must use the same echo-lag-aware baseline as suggestion matching: the remote
* shell already has the typed buffer, even when local echo still shows a short
* prefix. Using lagging `prompt.userInput` here would append a duplicate tail
* (e.g. typed `systemctl` + echo `s` + accept → send `ystemctl …`).
*/
export function computeAutocompleteAcceptWrite(options: {
prompt: PromptDetectionResult;
typedBuffer: string;
typedBufferReliable: boolean;
candidate: string;
os: string;
execute?: boolean;
allowLineReplacement?: boolean;
}): string | null {
const currentLine = resolveAutocompleteQueryInput(
options.prompt,
options.typedBuffer,
options.typedBufferReliable,
);
if (currentLine === null) return null;
const allowLineReplacement = options.allowLineReplacement !== false;
if (
!options.candidate.startsWith(currentLine) &&
!allowLineReplacement
) {
return null;
}
const body = computeLivePreviewWrite({
currentLine,
candidate: options.candidate,
os: options.os,
promptText: options.prompt.promptText,
});
if (!options.execute) return body;
return body ? `${body}\r` : "\r";
}
export function getCommandToRecordOnEnter(
livePrompt: PromptDetectionResult,
alignedTyped: string | null,
typedBuffer: string,
typedBufferReliable: boolean,
): string | null {
if (!livePrompt.isAtPrompt) return null;
const alignedCommand = alignedTyped?.trim();
if (alignedCommand) return alignedCommand;
const reliableTypedCommand = typedBufferReliable ? typedBuffer.trim() : "";
if (reliableTypedCommand) {
const reconciledPrompt = reconcilePromptWithExternalCommand(
livePrompt,
reliableTypedCommand,
);
if (reconciledPrompt) return reliableTypedCommand;
}
const liveCommand = livePrompt.userInput.trim();
if (!liveCommand && reliableTypedCommand) {
return isNonPromptLine(`${livePrompt.promptText}${reliableTypedCommand}`)
? null
: reliableTypedCommand;
}
if (!liveCommand) return null;
if (!typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
const liveInputMayIncludePromptDecoration =
typedBufferReliable &&
typedBuffer.trim().length > 0 &&
liveCommand !== typedBuffer.trim() &&
liveCommand.endsWith(typedBuffer.trim());
if (liveInputMayIncludePromptDecoration) return null;
const liveInputMayBeLagging =
typedBufferReliable &&
typedBuffer.trim().length > 0 &&
typedBuffer.length > livePrompt.userInput.length &&
typedBuffer.startsWith(livePrompt.userInput);
if (liveInputMayBeLagging) return null;
if (typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
return liveCommand;
}

View File

@@ -0,0 +1,56 @@
import type { AutocompleteSettings } from "./useTerminalAutocomplete";
import type { AutocompleteHistoryScope } from "../../../domain/models";
import { shouldWriteAutocompleteLivePreview } from "./livePreviewSequence";
type TerminalAutocompleteSettingFields = {
autocompleteEnabled?: boolean;
autocompleteGhostText?: boolean;
autocompletePopupMenu?: boolean;
autocompleteDebounceMs?: number;
autocompleteMinChars?: number;
autocompleteMaxSuggestions?: number;
autocompleteHistoryScope?: AutocompleteHistoryScope;
shiftEnterNewlineEnabled?: boolean;
};
export function resolveTerminalAutocompleteSettings(input: {
protocol?: string;
terminalSettings?: TerminalAutocompleteSettingFields;
/** Vendor CLI / network-device session: skip live-preview PTY rewrites (#1193). */
isNetworkDevice?: boolean;
systemUnknown?: boolean;
}): Partial<AutocompleteSettings> | undefined {
const { protocol, terminalSettings, isNetworkDevice, systemUnknown } = input;
if (protocol === "serial" || systemUnknown) {
return {
enabled: terminalSettings?.autocompleteEnabled ?? true,
showGhostText: terminalSettings?.autocompleteGhostText ?? true,
showPopupMenu: terminalSettings?.autocompletePopupMenu ?? true,
livePreview: false,
allowLineReplacement: false,
debounceMs: terminalSettings?.autocompleteDebounceMs ?? 100,
minChars: terminalSettings?.autocompleteMinChars ?? 1,
maxSuggestions: terminalSettings?.autocompleteMaxSuggestions ?? 50,
historyScope: terminalSettings?.autocompleteHistoryScope ?? "host",
shiftEnterNewlineEnabled: terminalSettings?.shiftEnterNewlineEnabled ?? true,
};
}
if (!terminalSettings) {
return isNetworkDevice ? { livePreview: false } : undefined;
}
return {
enabled: terminalSettings.autocompleteEnabled ?? true,
showGhostText: terminalSettings.autocompleteGhostText ?? true,
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
livePreview: shouldWriteAutocompleteLivePreview(true, isNetworkDevice),
allowLineReplacement: true,
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
minChars: terminalSettings.autocompleteMinChars ?? 1,
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 50,
historyScope: terminalSettings.autocompleteHistoryScope ?? "host",
shiftEnterNewlineEnabled: terminalSettings.shiftEnterNewlineEnabled ?? true,
};
}

View File

@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { PluginTerminalProviderRegistry } from '../../../application/state/pluginTerminalProviderRegistry.ts';
import { provideTerminalCompletions } from './terminalCompletionProviders.ts';
test('terminal completion adapter merges validated plugin results through the host Provider path', async () => {
const calls: unknown[] = [];
const registry = {
async request(request: unknown) {
calls.push(request);
return {
requestId: 'request-1',
stale: false,
results: [{
pluginId: 'com.example',
pluginVersion: '1.0.0',
providerId: 'com.example.completion',
kind: 'terminal.completion',
requestId: 'provider-1',
status: 'ok',
result: {
items: [
{ text: 'zzzzunlikely-command', displayText: 'Plugin command', score: 50_000 },
{ text: '', score: 100_000 },
],
},
}],
} as const;
},
} as unknown as PluginTerminalProviderRegistry;
const results = await provideTerminalCompletions(registry, {
input: 'zzzzunlikely',
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
hostOs: 'linux',
maximum: 8,
});
assert.equal(calls.length, 1);
assert.equal(results[0].text, 'zzzzunlikely-command');
assert.equal(results[0].source, 'plugin');
assert.equal(results[0].providerId, 'com.example.completion');
assert.equal(results.some((item) => item.text === ''), false);
});
test('terminal completion adapter ignores stale plugin responses', async () => {
const registry = {
async request() { return { requestId: 'request-1', stale: true, results: [] }; },
} as unknown as PluginTerminalProviderRegistry;
const results = await provideTerminalCompletions(registry, {
input: 'zzzzunlikely',
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
hostOs: 'linux',
maximum: 8,
});
assert.equal(results.some((item) => item.source === 'plugin'), false);
});
test('terminal completion adapter preserves built-in results when the plugin bridge fails', async () => {
const registry = {
async request() { throw new Error('bridge unavailable'); },
} as unknown as PluginTerminalProviderRegistry;
const results = await provideTerminalCompletions(registry, {
input: 'zzzzunlikely',
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
hostOs: 'linux',
maximum: 8,
});
assert.ok(Array.isArray(results));
});
test('terminal completion adapter bounds plugin activation and authorization before returning built-ins', async () => {
let signal: AbortSignal | undefined;
const registry = {
async request(_request: unknown, options?: { signal?: AbortSignal }) {
signal = options?.signal;
return new Promise(() => {});
},
} as unknown as PluginTerminalProviderRegistry;
const result = await Promise.race([
provideTerminalCompletions(registry, {
input: 'git ',
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
hostOs: 'linux',
maximum: 8,
pluginResponseTimeoutMs: 10,
}),
new Promise<'timed-out'>((resolve) => setTimeout(() => resolve('timed-out'), 250)),
]);
assert.notEqual(result, 'timed-out');
assert.ok(Array.isArray(result));
assert.equal(signal?.aborted, true);
});
test('terminal completion adapter aborts and discards plugin results when the host security gate closes', async () => {
let providerSignal: AbortSignal | undefined;
let resolveProvider: ((value: {
requestId: string;
stale: false;
results: readonly unknown[];
}) => void) | undefined;
const registry = {
request(_request: unknown, options?: { signal?: AbortSignal }) {
providerSignal = options?.signal;
return new Promise((resolve) => { resolveProvider = resolve as typeof resolveProvider; });
},
} as unknown as PluginTerminalProviderRegistry;
const securityController = new AbortController();
const pending = provideTerminalCompletions(registry, {
input: 'safe-command',
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
hostOs: 'linux',
maximum: 8,
signal: securityController.signal,
});
await new Promise((resolve) => setImmediate(resolve));
securityController.abort();
resolveProvider?.({
requestId: 'request-1',
stale: false,
results: [{
providerId: 'com.example.completion',
status: 'ok',
result: { items: [{ text: 'plugin-result', score: 50_000 }] },
}],
});
const results = await pending;
assert.equal(providerSignal?.aborted, true);
assert.equal(results.some((item) => item.source === 'plugin'), false);
});
test('terminal completion adapter preserves built-in snippet metadata on duplicate plugin text', async () => {
const registry = {
async request() {
return {
requestId: 'request-1',
stale: false,
results: [{
pluginId: 'com.example',
pluginVersion: '1.0.0',
providerId: 'com.example.completion',
kind: 'terminal.completion',
requestId: 'provider-1',
status: 'ok',
result: { items: [{ text: 'deploy', score: 50_000 }] },
}],
} as const;
},
} as unknown as PluginTerminalProviderRegistry;
const snippet = { id: 'deploy', label: 'deploy', command: 'kubectl apply -f .' };
const results = await provideTerminalCompletions(registry, {
input: 'dep',
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
hostOs: 'linux',
snippets: [snippet],
maximum: 8,
});
const duplicate = results.find((item) => item.text === 'deploy');
assert.equal(duplicate?.source, 'snippet');
assert.equal(duplicate?.snippet, snippet);
assert.equal(results.filter((item) => item.text === 'deploy').length, 1);
});

View File

@@ -0,0 +1,143 @@
import {
mergePluginCompletionItems,
normalizePluginCompletionResult,
} from '../../../domain/pluginTerminalProviders';
import type { PluginTerminalProviderRegistry } from '../../../application/state/pluginTerminalProviderRegistry';
import {
getCompletions,
type CompletionSuggestion,
} from './completionEngine';
import type { AutocompleteCwdSource } from './terminalAutocompleteLayout';
import type { AutocompleteHistoryScope, Snippet } from '../../../domain/models';
export interface TerminalCompletionProviderRequest {
input: string;
session: NetcattyTerminalSessionSnapshot;
hostGroup?: string;
hostOs: 'linux' | 'windows' | 'macos';
cwdSource?: AutocompleteCwdSource;
snippets?: Snippet[];
maximum: number;
/** Which history pool built-in suggestions draw from. */
historyScope?: AutocompleteHistoryScope;
/** Internal end-to-end wait bound; tests may lower it deterministically. */
pluginResponseTimeoutMs?: number;
/** Host security/session cancellation propagated to the plugin bridge. */
signal?: AbortSignal;
/**
* Forwarded to built-in getCompletions when a path listing finishes after the
* soft budget (cache-bypassed relative SSH cwd).
*/
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
}
const DEFAULT_PLUGIN_COMPLETION_RESPONSE_TIMEOUT_MS = 800;
type PluginCompletionResponse = Awaited<ReturnType<PluginTerminalProviderRegistry['request']>>;
function emptyPluginCompletionResponse(): PluginCompletionResponse {
return { requestId: '', stale: false, results: Object.freeze([]) };
}
async function waitForPluginCompletionResponse(
response: Promise<PluginCompletionResponse>,
timeoutMs: number,
onTimeout?: () => void,
): Promise<PluginCompletionResponse> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<PluginCompletionResponse>((resolve) => {
timer = setTimeout(() => {
onTimeout?.();
resolve(emptyPluginCompletionResponse());
}, timeoutMs);
});
try {
return await Promise.race([response, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
export async function provideTerminalCompletions(
registry: PluginTerminalProviderRegistry | null,
request: TerminalCompletionProviderRequest,
): Promise<CompletionSuggestion[]> {
const builtInPromise = getCompletions(request.input, {
hostId: request.session.hostId,
hostGroup: request.hostGroup,
os: request.hostOs,
maxResults: request.maximum,
sessionId: request.session.sessionId,
protocol: request.session.protocol,
cwd: request.session.cwd,
cwdSource: request.cwdSource,
snippets: request.snippets,
historyScope: request.historyScope,
onLatePathSuggestions: request.onLatePathSuggestions,
});
const pluginRequestController = new AbortController();
const abortPluginRequest = () => pluginRequestController.abort();
request.signal?.addEventListener('abort', abortPluginRequest, { once: true });
if (request.signal?.aborted) pluginRequestController.abort();
const pluginPromise = registry?.request({
kind: 'terminal.completion',
operation: 'provideCompletions',
session: request.session,
payload: {
input: request.input,
cursor: request.input.length,
hostOs: request.hostOs,
cwdSource: request.cwdSource ?? null,
maximum: request.maximum,
},
deadlineMs: 750,
}, { signal: pluginRequestController.signal }).catch(() => emptyPluginCompletionResponse())
?? Promise.resolve(emptyPluginCompletionResponse());
const pluginResponseTimeoutMs = Number.isFinite(request.pluginResponseTimeoutMs)
? Math.max(1, Math.min(5_000, Math.trunc(request.pluginResponseTimeoutMs ?? 0)))
: DEFAULT_PLUGIN_COMPLETION_RESPONSE_TIMEOUT_MS;
let builtIn: CompletionSuggestion[];
let pluginResponse: PluginCompletionResponse;
try {
[builtIn, pluginResponse] = await Promise.all([
builtInPromise,
waitForPluginCompletionResponse(
pluginPromise,
pluginResponseTimeoutMs,
() => pluginRequestController.abort(),
),
]);
} finally {
request.signal?.removeEventListener('abort', abortPluginRequest);
}
if (request.signal?.aborted || pluginRequestController.signal.aborted || pluginResponse.stale) {
return builtIn;
}
const pluginGroups = pluginResponse.results.map((result) => result.status === 'ok'
? normalizePluginCompletionResult(result.providerId, result.result)
: Object.freeze([]));
const pluginItems = mergePluginCompletionItems(pluginGroups, request.maximum);
const combined: CompletionSuggestion[] = [
...builtIn,
...pluginItems.map((item) => ({
text: item.text,
displayText: item.displayText,
...(item.description === undefined ? {} : { description: item.description }),
source: 'plugin' as const,
score: item.score,
providerId: item.providerId,
})),
];
const deduplicated = new Map<string, CompletionSuggestion>();
for (const item of combined) {
const existing = deduplicated.get(item.text);
if (!existing
|| (existing.source === 'plugin' && item.source !== 'plugin')
|| (existing.source === item.source && item.score > existing.score)) {
deduplicated.set(item.text, item);
}
}
return [...deduplicated.values()]
.sort((left, right) => right.score - left.score || left.text.localeCompare(right.text))
.slice(0, request.maximum);
}

View File

@@ -0,0 +1,163 @@
/**
* Terminal cell-column width for autocomplete / ghost positioning.
*
* When an xterm instance is available, prefer its active Unicode provider
* (`15-graphemes` via UnicodeGraphemesAddon) so emoji / VS-16 clusters match
* the cursor advance. Fall back to a small East-Asian-Width-style classifier
* for unit fakes that lack `_core.unicodeService`.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
type UnicodeServiceLike = {
getStringCellWidth?: (s: string) => number;
};
type TermWithUnicodeService = {
_core?: {
unicodeService?: UnicodeServiceLike;
};
};
const unicodeMarkPattern = /\p{Mark}/u;
function codePointCellWidth(cp: number): number {
// Zero-width joiners / format / variation selectors / marks — xterm
// folds these into the surrounding grapheme (wcwidth 0 or shouldJoin).
if (
cp === 0x00ad ||
cp === 0x200d || // ZWJ
(cp >= 0x200b && cp <= 0x200f) || // ZWSP..RLM
(cp >= 0x202a && cp <= 0x202e) || // bidi overrides
(cp >= 0x2060 && cp <= 0x206f) || // word joiner, invisible ops
(cp >= 0xfe00 && cp <= 0xfe0f) || // Variation Selectors
cp === 0xfeff ||
(cp >= 0x1f3fb && cp <= 0x1f3ff) || // Emoji skin-tone modifiers
(cp >= 0xe0100 && cp <= 0xe01ef) || // Variation Selectors Supplement
unicodeMarkPattern.test(String.fromCodePoint(cp))
) {
return 0;
}
if (
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals, Kangxi
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK Compat
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat Ideographs
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
(cp >= 0x1f300 && cp <= 0x1faff) || // Emoji blocks
(cp >= 0x20000 && cp <= 0x3fffd) // CJK Extension B-F, G
) {
return 2;
}
return 1;
}
function graphemeCellWidth(grapheme: string): number {
let max = 0;
for (const ch of grapheme) {
const w = codePointCellWidth(ch.codePointAt(0) ?? 0);
if (w > max) max = w;
}
return max;
}
const graphemeSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
function fallbackStringCellWidth(s: string): number {
if (graphemeSegmenter) {
let w = 0;
for (const { segment } of graphemeSegmenter.segment(s)) {
w += graphemeCellWidth(segment);
}
return w;
}
// Fallback without Segmenter: sum code-point widths (ZWJ/marks already 0).
let w = 0;
for (const ch of s) {
w += codePointCellWidth(ch.codePointAt(0) ?? 0);
}
return w;
}
/** Terminal cell columns occupied by `s` (wide glyphs / grapheme clusters). */
export function stringCellWidth(
s: string,
term?: XTerm | TermWithUnicodeService | null,
): number {
if (!s) return 0;
const unicodeService = (term as TermWithUnicodeService | null | undefined)
?._core?.unicodeService;
const getWidth = unicodeService?.getStringCellWidth;
if (typeof getWidth === "function") {
return getWidth.call(unicodeService, s);
}
return fallbackStringCellWidth(s);
}
/**
* Slice a terminal line string by cell columns (xterm `cursorX` units).
*
* `translateToString()` returns characters, but `buffer.cursorX` is a cell
* column. Mixing them with `String#substring(cursorX)` pulls padding spaces
* into user input whenever the prompt contains wide glyphs (CJK paths in
* Windows CMD / PowerShell), which breaks autocomplete matching (#2813).
*/
export function sliceStringByCellColumns(
text: string,
startCell: number,
endCell?: number,
term?: XTerm | TermWithUnicodeService | null,
): string {
if (!text) return "";
const start = Math.max(0, startCell);
const end = endCell === undefined ? Number.POSITIVE_INFINITY : Math.max(start, endCell);
if (end === 0) return "";
let cell = 0;
let startIndex = 0;
let endIndex = text.length;
let sawStart = false;
const advance = (segment: string, index: number, segmentLength: number): boolean => {
const width = stringCellWidth(segment, term);
const nextCell = cell + width;
if (!sawStart && nextCell > start) {
startIndex = index;
sawStart = true;
}
if (nextCell >= end) {
endIndex = nextCell === end ? index + segmentLength : index;
if (!sawStart) {
startIndex = index;
sawStart = true;
}
return true;
}
cell = nextCell;
return false;
};
if (graphemeSegmenter) {
for (const { segment, index } of graphemeSegmenter.segment(text)) {
if (advance(segment, index, segment.length)) break;
}
} else {
let index = 0;
for (const ch of text) {
if (advance(ch, index, ch.length)) break;
index += ch.length;
}
}
if (!sawStart) return "";
return text.slice(startIndex, endIndex);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
/**
* Utility functions for xterm.js cell dimension access.
* Centralizes access to xterm's internal renderer API to reduce upgrade risk.
* Falls back to DOM measurement if the internal API is unavailable.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
export interface CellDimensions {
width: number;
height: number;
}
// Cache to avoid repeated DOM measurements (invalidated on resize)
let cachedDims: CellDimensions | null = null;
let cachedTermId: number = 0;
let termIdCounter = 0;
const termIdMap = new WeakMap<XTerm, number>();
function getTermId(term: XTerm): number {
let id = termIdMap.get(term);
if (id === undefined) {
id = ++termIdCounter;
termIdMap.set(term, id);
}
return id;
}
/**
* Get cell dimensions (width/height in CSS pixels) from an xterm instance.
* Tries the internal renderer API first (fast path), falls back to DOM measurement.
*/
export function getXTermCellDimensions(term: XTerm): CellDimensions {
// Try xterm core renderer API (fast path)
const coreAccess = term as XTerm & {
_core?: { _renderService?: { dimensions?: { css?: { cell?: CellDimensions } } } };
};
const coreDims = coreAccess._core?._renderService?.dimensions?.css?.cell;
if (coreDims && coreDims.width > 0 && coreDims.height > 0) {
// Update cache while we have a good value
const id = getTermId(term);
cachedDims = { width: coreDims.width, height: coreDims.height };
cachedTermId = id;
return cachedDims;
}
// Check cache (same terminal instance)
const id = getTermId(term);
if (cachedDims && cachedTermId === id) {
return cachedDims;
}
// Fallback: measure from DOM (triggers single reflow)
const dims = measureCellFromDOM(term);
cachedDims = dims;
cachedTermId = id;
return dims;
}
/**
* Measure cell dimensions by inserting a temporary span into the terminal element.
* Triggers a single reflow (reading offsetWidth + offsetHeight).
*/
function measureCellFromDOM(term: XTerm): CellDimensions {
const element = term.element;
if (!element) return { width: 8, height: 16 };
const span = document.createElement("span");
span.textContent = "W";
Object.assign(span.style, {
position: "absolute",
visibility: "hidden",
fontFamily: term.options.fontFamily || "monospace",
fontSize: `${term.options.fontSize}px`,
lineHeight: "normal",
});
element.appendChild(span);
const width = span.offsetWidth || 8;
const height = span.offsetHeight || 16;
span.remove();
return { width, height };
}
/**
* Invalidate the cached cell dimensions (call on terminal resize).
*/
export function invalidateCellDimensionCache(): void {
cachedDims = null;
}