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; 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; promptLineBreakStateRef?: RefObject; }; /** 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; };