974 lines
35 KiB
TypeScript
974 lines
35 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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 };
|
|||
|
|
}
|