212 lines
7.1 KiB
TypeScript
212 lines
7.1 KiB
TypeScript
|
|
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;
|
||
|
|
}
|
||
|
|
|