Files
NetMesh/components/terminal/runtime/terminalCommandExecution.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

894 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import type { Host } from "../../../types";
import {
isConfirmedTerminalShellPrompt,
isSensitiveTerminalChallenge,
} from "../../../domain/terminalPromptSecurity";
import {
markPromptLineBreakCommandPending,
type PromptLineBreakState,
} from "./promptLineBreak";
import {
getAlignedPrompt,
isNonPromptLine,
reconcilePromptWithExternalCommand,
reconcilePromptWithTypedInput,
type PromptDetectionResult,
} from "../autocomplete/promptDetector";
import { getCommandToRecordOnEnter } from "../autocomplete/terminalAutocompletePrompt";
import { shouldArmSudoPasswordAutofill } from "./terminalSudoAutofill";
type TerminalCommandExecutionContext = {
host: Pick<Host, "id" | "label">;
sessionId: string;
onCommandExecuted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
onCommandSubmitted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
onTrustedCommandSubmitted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
commandBufferRef: RefObject<string>;
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
};
/** Bare omz/p10k glyph alone — detector often leaves cwd/git chrome in userInput. */
const isBareThemedTerminator = (promptText: string): boolean => {
const trimmed = promptText.trim();
if (trimmed.length !== 1) return false;
const code = trimmed.charCodeAt(0);
return /[❯❮→➜➤⟩»›]/.test(trimmed) || (code >= 0xE000 && code <= 0xF8FF);
};
type TerminalCellStyle = {
dim: number;
fgMode: number;
fg: number;
};
const readTerminalCellStyle = (
cell: {
isDim?: () => number;
getFgColorMode?: () => number;
getFgColor?: () => number;
} | null | undefined,
): TerminalCellStyle | null => {
if (
!cell
|| typeof cell.isDim !== "function"
|| typeof cell.getFgColorMode !== "function"
|| typeof cell.getFgColor !== "function"
) {
return null;
}
return {
dim: cell.isDim(),
fgMode: cell.getFgColorMode(),
fg: cell.getFgColor(),
};
};
const terminalCellStylesEqual = (
left: TerminalCellStyle,
right: TerminalCellStyle,
): boolean => (
left.dim === right.dim
&& left.fgMode === right.fgMode
&& left.fg === right.fg
);
const terminalCellStyleKey = (style: TerminalCellStyle): string => (
`${style.dim}:${style.fgMode}:${style.fg}`
);
type StyledCellSpan = {
offset: number;
style: TerminalCellStyle;
};
/**
* Walk logical line cells from startOffset to endOffset (buffer coordinates
* that include the prompt prefix). Skips cells with unreadable styles.
*/
const collectStyledCellsInRange = (
term: XTerm,
promptRow: number,
startOffset: number,
endOffset: number,
): StyledCellSpan[] | null => {
if (endOffset <= startOffset) return [];
const buffer = term.buffer.active;
const cells: StyledCellSpan[] = [];
let offset = 0;
for (let row = promptRow; ; row += 1) {
const rowLine = buffer.getLine(row);
if (!rowLine || typeof rowLine.getCell !== "function") return null;
const text = rowLine.translateToString(false);
for (let x = 0; x < text.length; x += 1) {
if (offset >= endOffset) return cells;
if (offset >= startOffset) {
const style = readTerminalCellStyle(rowLine.getCell(x));
if (!style) return null;
cells.push({ offset, style });
}
offset += 1;
}
const next = buffer.getLine(row + 1);
if (!next?.isWrapped) break;
}
return cells;
};
/**
* zsh-autosuggest ghosts are a uniform dim/foreign run past the cursor.
* Per-token syntax highlighting also changes fg past the cursor for accepted
* suffixes — only strip tails that look like suggestion paint, not ordinary
* highlight boundaries (`systemctl start`|`firewalld`).
*/
const truncateDivergentStyleTail = (
term: XTerm,
promptRow: number,
promptText: string,
input: string,
): string => {
try {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const cursorX = buffer.cursorX;
let combinedOffset = 0;
for (let row = promptRow; row < cursorY; row += 1) {
const rowLine = buffer.getLine(row);
if (!rowLine) return input;
combinedOffset += rowLine.translateToString(false).length;
}
combinedOffset += cursorX;
const inputCursor = combinedOffset - promptText.length;
if (inputCursor <= 0 || inputCursor >= input.length) return input;
const refLine = buffer.getLine(
inputCursor > 0 && cursorX === 0 && cursorY > promptRow
? cursorY - 1
: cursorY,
);
if (!refLine || typeof refLine.getCell !== "function") return input;
const refX = cursorX > 0
? cursorX - 1
: Math.max(0, refLine.translateToString(false).length - 1);
const refStyle = readTerminalCellStyle(refLine.getCell(refX));
if (!refStyle) return input;
const promptEnd = promptText.length;
const lineEnd = promptEnd + input.length;
const acceptedCells = collectStyledCellsInRange(
term,
promptRow,
promptEnd,
combinedOffset,
);
const postCells = collectStyledCellsInRange(
term,
promptRow,
combinedOffset,
lineEnd,
);
if (!acceptedCells || !postCells || postCells.length === 0) return input;
const acceptedStyleKeys = new Set(
acceptedCells.map((cell) => terminalCellStyleKey(cell.style)),
);
let cutOffset: number | null = null;
for (let i = 0; i < postCells.length; i += 1) {
const cell = postCells[i];
if (terminalCellStylesEqual(refStyle, cell.style)) continue;
const tail = postCells.slice(i);
const ghostStyle = tail[0]?.style;
if (!ghostStyle) return input;
// Mixed styles after the first break are syntax-highlighted tokens.
if (tail.some((entry) => !terminalCellStylesEqual(ghostStyle, entry.style))) {
return input;
}
const ghostKey = terminalCellStyleKey(ghostStyle);
const dimGhost = ghostStyle.dim !== 0 && refStyle.dim === 0;
// fg-only ghosts (common zsh-autosuggest `fg=8`) on mono-styled input.
const foreignMonoGhost = !acceptedStyleKeys.has(ghostKey)
&& acceptedStyleKeys.size <= 1;
if (!dimGhost && !foreignMonoGhost) return input;
cutOffset = cell.offset;
break;
}
if (cutOffset == null) return input;
const cut = cutOffset - promptText.length;
return input.slice(0, Math.max(0, cut)).replace(/\s+$/g, "");
} catch {
return input;
}
};
/**
* Read the full logical input after the prompt, including wrapped continuation
* rows and text past the cursor (Enter submits the whole line, not the prefix).
* Style-divergent tails past the cursor (zsh autosuggest ghosts) are dropped.
*/
const readFullLineAfterPrompt = (
term: XTerm,
promptText: string,
): string | null => {
if (!promptText) return null;
try {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
let promptRow = cursorY;
let line = buffer.getLine(promptRow);
if (!line) return null;
// Walk up through wrapped continuation rows to the prompt row.
while (line.isWrapped && promptRow > 0) {
promptRow -= 1;
const prev = buffer.getLine(promptRow);
if (!prev) return null;
line = prev;
}
let combined = "";
for (let row = promptRow; ; row += 1) {
const rowLine = buffer.getLine(row);
if (!rowLine) break;
combined += rowLine.translateToString(false);
const next = buffer.getLine(row + 1);
if (!next?.isWrapped) break;
}
if (!combined.startsWith(promptText)) return null;
const rawInput = combined.slice(promptText.length).replace(/\s+$/g, "");
return truncateDivergentStyleTail(term, promptRow, promptText, rawInput);
} catch {
return null;
}
};
const readCurrentLogicalTerminalLine = (term?: XTerm | null): string => {
if (!term) return "";
try {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
let firstRow = cursorY;
while (firstRow > 0 && buffer.getLine(firstRow)?.isWrapped) firstRow -= 1;
let line = "";
for (let row = firstRow; row <= cursorY; row += 1) {
const bufferLine = buffer.getLine(row);
if (!bufferLine) break;
line += bufferLine.translateToString(false);
}
return line.slice(-8_192);
} catch {
return "";
}
};
/**
* detectPrompt truncates userInput at the cursor.
*
* Never absorb painted tails into the command when the keystroke buffer is
* non-empty: zsh same-token autosuggest (`g` + paint `git status`) must stay
* as `g`. Incomplete remote echo (keystrokes ahead of the line) may promote
* the buffer into userInput. History that rewrote the line is handled later
* via live-line comparison (#2191 review).
*/
const expandPromptUserInputToFullLine = (
term: XTerm,
prompt: PromptDetectionResult,
typedBuffer: string,
): PromptDetectionResult => {
if (!prompt.isAtPrompt || !prompt.promptText) return prompt;
const buffered = typedBuffer.trim();
if (!buffered) return prompt;
// Incomplete echo: keystrokes ahead of what the line shows.
// - visible "su", buffer "sudo" (same single word still typing)
// - visible "su", buffer "su -" (more argv)
// Not: visible "su", buffer "sudo whoami" (history may have shortened).
if (
prompt.userInput.length > 0
&& buffered.startsWith(prompt.userInput)
&& buffered.length > prompt.userInput.length
) {
const next = buffered[prompt.userInput.length] ?? "";
const singleWordEchoLag =
!buffered.includes(" ")
&& /[\w@./:-]/.test(next);
const moreArgsEchoLag = next === " " || next === "\t";
if (singleWordEchoLag || moreArgsEchoLag) {
return {
...prompt,
userInput: buffered,
cursorOffset: buffered.length,
};
}
}
return prompt;
};
/** Status / cwd chrome that must not be recorded as a submitted command. */
const isDecorationOnlyCommand = (command: string): boolean => {
const t = command.trim();
if (!t) return true;
if (t === "~" || t.startsWith("~/")) return true;
if (/^[✗✔+*!]$/.test(t)) return true;
if (/^git:\([^)]*\)/.test(t)) return true;
// "git:(main) ✗" leftovers after a partial cache strip
if (/git:\([^)]*\)/.test(t) || /[✗✔]/.test(t)) {
const stripped = t
.replace(/git:\([^)]*\)/g, " ")
.replace(/[✗✔+*!]/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!stripped) return true;
if (/^(?:su|sudo|doas)(?:\s|$)/i.test(stripped)) return false;
if (!/\s/.test(stripped) && !/^(?:su|sudo|doas)$/i.test(stripped)) return true;
}
return false;
};
const hasThemedPromptMarker = (promptText: string): boolean => {
if (isBareThemedTerminator(promptText)) return true;
if (/[❯❮→➜➤⟩»›]/.test(promptText)) return true;
for (const ch of promptText) {
const code = ch.charCodeAt(0);
if (code >= 0xE000 && code <= 0xF8FF) return true;
}
return false;
};
/**
* When the prompt has no trailing space (`user@host:~$su -`), the detector
* may not find a boundary. Fall back to the last known prompt prefix.
*/
const resolveFromCachedPromptPrefix = (
term: XTerm,
lastPromptText: string | undefined,
): string => {
const cached = lastPromptText ?? "";
if (!cached) return "";
const fullInput = readFullLineAfterPrompt(term, cached)?.trim() ?? "";
// Reject partial-cache leftovers like "git:(main) ✗" (#2191 review).
if (!fullInput || isDecorationOnlyCommand(fullInput)) return "";
return fullInput;
};
export const shouldRecordShellHistory = (
command: string,
term?: XTerm | null,
): boolean => {
if (!term) return true;
const trimmed = command.trim();
const alignedResult = getAlignedPrompt(term, command, true);
const prompt = expandPromptUserInputToFullLine(term, alignedResult.prompt, command);
if (!prompt.isAtPrompt) return false;
if (alignedResult.alignedTyped?.trim() === trimmed) return true;
if (reconcilePromptWithExternalCommand(prompt, command)) return true;
// History recall on themed prompts: live userInput still includes cwd/git
// chrome, but reconcile can attribute it back to the prompt (#2191).
if (trimmed) {
const reconciled = reconcilePromptWithTypedInput(prompt, trimmed);
if (reconciled !== prompt && reconciled.userInput.trim() === trimmed) {
return true;
}
}
const liveCommand = prompt.userInput.trim();
if (liveCommand.length === 0) {
return !isNonPromptLine(`${prompt.promptText}${trimmed}`);
}
if (liveCommand === trimmed) return true;
// Themed multi-word / unicode dirs: resolver peels to "su -" but the raw
// userInput is still " My Project su -". Accept trailing resolved commands
// so password assist still arms (#2191 review).
if (
liveCommand === trimmed
|| liveCommand.endsWith(` ${trimmed}`)
|| liveCommand.endsWith(trimmed)
) {
return true;
}
return false;
};
/** Common shell verbs that are commands, not themed directory names. */
const LOOKS_LIKE_SHELL_COMMAND_PREFIX =
/^(?:echo|printf|ls|cd|pwd|cat|grep|find|sed|awk|vim|nvim|nano|git|npm|yarn|pnpm|node|python|pip|docker|make|curl|wget|ssh|scp|rsync|tar|zip|unzip|chmod|chown|cp|mv|rm|mkdir|touch|tail|head|less|more|man|which|type|alias|export|source|bash|zsh|fish|sh|env|ps|top|htop|kill|df|du|free|uname|whoami|id|date|clear|history|exit|logout|true|false|test|expr|seq|sleep|yes|nohup|time|env|sudo|su|doas)\b/i;
const CWD_NAME_COMMAND_COLLISION =
/^(?:git|node|go|npm|yarn|pnpm|docker|src|app|bin|lib|test|tmp|home|user|root|www|html|dist|build|target|main|dev|prod|staging)$/i;
/** Path / git-status chrome that may sit between a glyph prompt and the command. */
const isPlausiblePathDecoration = (text: string): boolean => {
const s = text.trim();
if (!s) return true;
if (s === "~" || s.startsWith("~/") || s.startsWith("/")) return true;
// Privilege verbs in the prefix are never directory chrome.
if (/\b(?:su|sudo|doas)\b/i.test(s)) return false;
const words = s.split(/\s+/).filter(Boolean);
// Any ordinary shell verb in the prefix means this is command text, not cwd
// chrome — including after git-status markers (`git:(main) ✗ echo …`).
for (const word of words) {
const token = word.replace(/^git:\([^)]*\)$/i, "").replace(/[✗✔+*!]/g, "");
if (!token) continue;
if (
LOOKS_LIKE_SHELL_COMMAND_PREFIX.test(token)
&& !CWD_NAME_COMMAND_COLLISION.test(token)
&& !/^[./~]/.test(token)
) {
return false;
}
}
// Pure git-status / status glyph chrome.
if (/^git:\([^)]*\)/.test(s) || /^[✗✔+*!]+$/.test(s)) return true;
if (words.every((w) => /^git:\([^)]*\)$/i.test(w) || /^[✗✔+*!]+$/.test(w))) {
return true;
}
// Allow unicode letters and common path punctuation in directory names.
return /^(?:[^\s\\]|[./~_()-])+(?:\s+(?:[^\s\\]|[./~_()-])+)*$/u.test(s);
};
/**
* Recover a privilege command from a line with no space after the prompt marker
* (`user@host:~$su -`) when prompt detection and lastPromptText both fail.
*/
const resolveNoSpacePromptPrivilegeCommand = (term: XTerm): string => {
try {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const line = buffer.getLine(cursorY);
if (!line) return "";
const raw = line.translateToString(false).replace(/\s+$/g, "");
const match = raw.match(/^(.*?[$#%>])((?:sudo|su|doas)(?:\s.*)?)$/i);
if (!match) return "";
const command = match[2].trim();
return shouldArmSudoPasswordAutofill(command) ? command : "";
} catch {
return "";
}
};
/**
* Peel themed cwd/git chrome from userInput.
*
* Prefer a trailing privilege command (su/sudo/doas) when the prefix looks like
* path decoration — longest-prompt peel alone turns ` su -` into `-` and
* `➜ My Project su -` into `Project su -` (#2191 review).
*/
const peelThemedCommandFromPrompt = (
prompt: PromptDetectionResult,
): string => {
const live = prompt.userInput;
const trimmedStart = live.trimStart();
if (!trimmedStart) return "";
const privilegeMatch = trimmedStart.match(
/(?:^|\s)((?:sudo|su|doas)(?:\s+.*)?)$/i,
);
if (privilegeMatch) {
const command = privilegeMatch[1].trim();
const before = trimmedStart
.slice(0, trimmedStart.length - privilegeMatch[1].length)
.trim();
if (isPlausiblePathDecoration(before)) {
return command;
}
}
// Leading whitespace only: try path-prefix + trailing command before taking
// the whole line (avoids ` My Project ls` → recording the directory too).
const trimmed = live.trim();
if (
trimmed
&& live.endsWith(trimmed)
&& /^\s+$/.test(live.slice(0, live.length - trimmed.length))
) {
const parts = trimmed.split(/\s+/).filter(Boolean);
if (parts.length === 1 || shouldArmSudoPasswordAutofill(trimmed)) {
return trimmed;
}
for (let i = 1; i < parts.length; i += 1) {
const before = parts.slice(0, i).join(" ");
const command = parts.slice(i).join(" ");
if (!command || !isPlausiblePathDecoration(before)) continue;
// Privilege after any path chrome, or ordinary commands only after a
// multi-word / path-sigil directory (not `git status` → `status`).
if (
shouldArmSudoPasswordAutofill(command)
|| before.includes(" ")
|| before === "~"
|| before.startsWith("~/")
|| before.startsWith("/")
) {
return command;
}
}
return trimmed;
}
// Reconcile peel: prefer the longest command (avoid over-peeling to "-").
let best: { command: string; length: number } | null = null;
for (let start = 0; start < live.length; start += 1) {
if (start > 0 && live[start - 1] !== " ") continue;
const candidate = live.slice(start);
if (!candidate.trim()) continue;
const extra = live.slice(0, start);
// Never treat privilege words as path chrome in the stripped prefix.
if (/\b(?:su|sudo|doas)\b/i.test(extra)) continue;
const reconciled = reconcilePromptWithTypedInput(prompt, candidate);
if (reconciled === prompt || reconciled.userInput !== candidate) continue;
const command = candidate.trim();
if (!command) continue;
if (!best || command.length > best.length) {
best = { command, length: command.length };
}
}
return best?.command ?? "";
};
/**
* Read the command currently shown on the prompt line, stripping themed
* prompt chrome (➜ ~ / git status decorations) when needed.
*
* lastPromptText is only trusted when the remainder reconciles against the
* original detector split (avoids partial-cache pollution and over-peeling
* a clean remainder down to "-"). Complete Powerline prompts keep the
* detector's multiword userInput (#2191).
*/
export const resolveLiveSubmittedCommand = (
prompt: PromptDetectionResult,
lastPromptText?: string,
): string => {
if (!prompt.isAtPrompt) return "";
// Clean standard prompts (user@host:~$ su -).
const direct = getCommandToRecordOnEnter(prompt, null, "", true);
if (direct) return direct;
// Cached full prompt first: handles space-containing dirs ("➜ My Project ")
// before peel can mis-split on the path (#2191 review).
const cachedPrompt = lastPromptText ?? "";
if (cachedPrompt) {
const fullLine = `${prompt.promptText}${prompt.userInput}`;
if (fullLine.startsWith(cachedPrompt)) {
const remainder = fullLine.slice(cachedPrompt.length).trim();
if (remainder && !isDecorationOnlyCommand(remainder)) {
if (prompt.userInput.endsWith(remainder)) {
const reconciled = reconcilePromptWithTypedInput(prompt, remainder);
if (reconciled !== prompt && reconciled.userInput.trim() === remainder) {
return remainder;
}
}
// Exact cache prefix on the rendered line (no-space / multi-word dirs).
return remainder;
}
}
}
// Incomplete bare-glyph split (➜ + cwd/git in userInput): peel chrome.
if (isBareThemedTerminator(prompt.promptText)) {
const peeled = peelThemedCommandFromPrompt(prompt);
if (peeled) return peeled;
}
// Themed prompts (including prefixed terminators like "⚡ ➜ "): peel cwd/path
// chrome before accepting userInput (⚡ ➜ ~ su - → su -).
if (hasThemedPromptMarker(prompt.promptText)) {
const peeled = peelThemedCommandFromPrompt(prompt);
if (peeled) return peeled;
}
// Complete Powerline / multi-glyph prompts may already isolate multiword
// commands (sudo whoami) when peel has nothing left to strip.
if (!isBareThemedTerminator(prompt.promptText)) {
const liveTrimmed = prompt.userInput.trim();
if (
liveTrimmed
&& prompt.promptText.trim().length > 0
&& !isDecorationOnlyCommand(liveTrimmed)
) {
const rawTokens = liveTrimmed.split(/\s+/).filter(Boolean);
if (
rawTokens.length <= 1
&& hasThemedPromptMarker(prompt.promptText)
&& !/^(?:su|sudo|doas)$/i.test(liveTrimmed)
) {
return "";
}
return liveTrimmed;
}
}
return peelThemedCommandFromPrompt(prompt);
};
/**
* True when a live "command" is really empty-prompt chrome (cwd / git status)
* left in userInput by the detector — not a history-recalled command.
*/
const isEmptyPromptDecoration = (
live: string,
prompt: PromptDetectionResult,
): boolean => {
const command = live.trim();
if (!command) return true;
if (isDecorationOnlyCommand(command)) return true;
// Bare glyph or multi-glyph themed prompts can leave a single cwd token.
if (!hasThemedPromptMarker(prompt.promptText)) return false;
const rawTokens = prompt.userInput.trim().split(/\s+/).filter(Boolean);
if (rawTokens.length <= 1) {
// Cwd chrome often keeps a trailing space after the directory token
// (" git "). A real one-word history command usually has no trailing pad.
if (/\s$/.test(prompt.userInput)) return true;
// One-word history of su/sudo/doas ( su) with no trailing pad.
if (/^(?:su|sudo|doas)$/i.test(command)) return false;
return true;
}
return false;
};
/**
* Resolve the command that Enter is submitting.
*
* The keystroke buffer alone is incomplete for shell history recall (↑/↓ /
* Ctrl+R): those keys redraw the line remotely and never rewrite
* commandBuffer. Prefer an aligned buffer when reliable; otherwise prefer
* the live line when it disagrees with a stale prefix (#2191).
*/
export const resolveSubmittedShellCommand = (
commandBuffer: string,
term?: XTerm | null,
lastPromptText?: string,
): string => {
const buffered = commandBuffer.trim();
if (!term) return buffered;
const alignedResult = getAlignedPrompt(term, commandBuffer, true);
// Expand only for incomplete echo (never same-token autosuggest paint).
const prompt = expandPromptUserInputToFullLine(
term,
alignedResult.prompt,
commandBuffer,
);
const liveFromCursor = prompt.isAtPrompt
? resolveLiveSubmittedCommand(prompt, lastPromptText)
: "";
// Full painted line (for history that rewrote past a stale typed prefix).
// Only adopt it over the buffer when it is a privilege command the buffer
// is not — autosuggest `g`→`git status` stays on the buffer.
let liveFromFull = liveFromCursor;
if (prompt.isAtPrompt && prompt.promptText) {
const fullInput = readFullLineAfterPrompt(term, prompt.promptText);
if (fullInput && fullInput !== prompt.userInput) {
liveFromFull = resolveLiveSubmittedCommand(
{
...prompt,
userInput: fullInput,
cursorOffset: fullInput.length,
},
lastPromptText,
);
}
}
const preferFullOverBuffer = (
buffer: string,
fullLive: string,
): boolean => {
if (!fullLive || fullLive === buffer) return false;
if (!fullLive.startsWith(buffer) || fullLive.length <= buffer.length) {
return false;
}
// History to privilege command from a non-privilege typed prefix ("s"→"su -").
return (
shouldArmSudoPasswordAutofill(fullLive)
&& !shouldArmSudoPasswordAutofill(buffer)
);
};
const aligned = alignedResult.alignedTyped?.trim() ?? "";
// Enter submits the whole zle line. detectPrompt truncates at the cursor, so
// after ↑ recall + mid-line edit the keystroke buffer may only hold the
// replacement token ("start") while the painted line is still
// "systemctl start firewalld". Prefer that full paint only when:
// - the buffer is already a whole token there (not a prefix of "status"), and
// - cell styles are available so zsh autosuggest ghosts were stripped from
// liveFromFull (cross-token " upgrade" must not be recorded).
const paintedLineContinuesPastCursor =
Boolean(liveFromFull)
&& Boolean(liveFromCursor)
&& liveFromFull !== liveFromCursor
&& liveFromFull.startsWith(liveFromCursor);
const bufferIsWholeTokenInPaintedLine = Boolean(
buffered
&& liveFromFull
&& liveFromFull.split(/\s+/).includes(buffered),
);
const cursorLine = term.buffer.active.getLine(
term.buffer.active.cursorY + term.buffer.active.baseY,
);
const canTrustPostCursorPaint = typeof cursorLine?.getCell === "function";
const preferFullPaintedLine =
paintedLineContinuesPastCursor
&& canTrustPostCursorPaint
&& bufferIsWholeTokenInPaintedLine
&& buffered !== liveFromCursor
&& buffered !== liveFromFull
&& aligned !== liveFromCursor;
// Aligned buffer can match a stale mid-line prefix after history recall
// (typed "s", recalled "su -", cursor after "s"), or only a suffix when
// history prepended text (typed "whoami", recalled "sudo whoami").
if (aligned) {
if (preferFullOverBuffer(aligned, liveFromFull) || preferFullPaintedLine) {
return liveFromFull;
}
if (
liveFromCursor
&& liveFromCursor.length > aligned.length
&& (
liveFromCursor.startsWith(aligned)
|| liveFromCursor.endsWith(aligned)
|| liveFromCursor.endsWith(` ${aligned}`)
)
) {
return liveFromCursor;
}
return aligned;
}
if (!prompt.isAtPrompt) {
// No-space prompts (`user@host:~$su -`) often fail boundary detection;
// recover via the last fully-detected prompt prefix, then a direct
// privilege-command scan for the first history recall before any cache.
if (!buffered) {
return (
resolveFromCachedPromptPrefix(term, lastPromptText)
|| resolveNoSpacePromptPrivilegeCommand(term)
);
}
return buffered;
}
const live = liveFromCursor;
if (!buffered) {
// Empty buffer: submitted text is the painted command (history at EOL or
// mid-line). Keystroke autosuggest always leaves a non-empty buffer.
const emptyLive = liveFromFull || live;
if (!emptyLive || isEmptyPromptDecoration(emptyLive, prompt)) {
return resolveFromCachedPromptPrefix(term, lastPromptText);
}
return emptyLive;
}
if (preferFullOverBuffer(buffered, liveFromFull) || preferFullPaintedLine) {
return liveFromFull;
}
if (!live || live === buffered) return buffered || live;
// Direct send / incomplete echo: keystroke buffer is the real command even
// when the themed line still only shows decoration (➜ netcatty + "ls").
if (reconcilePromptWithExternalCommand(prompt, buffered)) {
return buffered;
}
// History / reverse-search replaced a typed prefix (buffer "s", live "su -").
if (live.startsWith(buffered) && live.length > buffered.length) {
return live;
}
if (preferFullOverBuffer(buffered, liveFromFull)) {
return liveFromFull;
}
// Echo lag: live is a visible prefix of what the user typed.
// - "su" + buffer "su -" → same command, more argv → buffer
// - "su" + buffer "sudo" → incomplete echo of the same word → buffer
// - "su" + buffer "sudo whoami" → history shortened the line → live
if (buffered.startsWith(live) && buffered.length > live.length) {
const next = buffered[live.length] ?? "";
if (next === " " || next === "" || live.length === 0) {
return buffered;
}
const liveFirst = live.split(/\s+/)[0] ?? "";
const bufFirst = buffered.split(/\s+/)[0] ?? "";
// Single-word buffer still extending the echoed prefix: trust keystrokes.
if (
!buffered.includes(" ")
&& bufFirst.startsWith(liveFirst)
&& bufFirst !== liveFirst
) {
return buffered;
}
// Multi-word typed buffer vs shorter live command: history replaced it.
return live;
}
// Live ends with the typed buffer: history grew leftward ("sudo whoami" after
// typing "whoami", or "git" + typed "st"), or path chrome + typed command.
// Prefer live when the buffer is a trailing whole token (space-delimited) or
// a privilege wrapper; otherwise keep the keystroke buffer.
if (live.endsWith(buffered) || live.endsWith(` ${buffered}`)) {
if (live === buffered) return live;
if (
/^(?:sudo|su|doas|command|builtin)\s/i.test(live)
|| live.endsWith(` ${buffered}`)
) {
return live;
}
return buffered;
}
// Completely different commands: trust the live line (history replaced it).
return live;
};
export const recordTerminalCommandExecution = (
command: string,
ctx: TerminalCommandExecutionContext,
term?: XTerm | null,
options?: { sensitive?: boolean; allowHostStyleGreaterThanPrompt?: boolean },
): string | null => {
if (options?.sensitive || isSensitiveTerminalChallenge(readCurrentLogicalTerminalLine(term))) {
ctx.commandBufferRef.current = "";
return null;
}
const lastPromptText = ctx.promptLineBreakStateRef?.current?.lastPromptText;
const cmd = resolveSubmittedShellCommand(command, term, lastPromptText);
if (cmd) {
ctx.onCommandSubmitted?.(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
}
const alignedPrompt = term ? getAlignedPrompt(term, command, true).prompt : null;
const trustedPrompt = Boolean(
term && alignedPrompt?.isAtPrompt
&& isConfirmedTerminalShellPrompt(alignedPrompt.promptText, {
allowHostStyleGreaterThan: options?.allowHostStyleGreaterThanPrompt,
}),
);
if (cmd && shouldRecordShellHistory(cmd, term)) {
if (trustedPrompt) {
ctx.onTrustedCommandSubmitted?.(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
}
ctx.onCommandExecuted?.(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
ctx.commandBufferRef.current = "";
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, cmd);
return cmd;
}
ctx.commandBufferRef.current = "";
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, cmd || command);
return null;
};