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