[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:
893
components/terminal/runtime/terminalCommandExecution.ts
Normal file
893
components/terminal/runtime/terminalCommandExecution.ts
Normal file
@@ -0,0 +1,893 @@
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user