Compare commits

...
16 changed files with 716 additions and 27 deletions

No files matched your search

+26 -5
View File
@@ -174,6 +174,7 @@ import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
import {
createPromptLineBreakState,
markTerminalCommandCompletionPending,
markTerminalCwdCompletionPending,
type PromptLineBreakState,
} from "./terminal/runtime/promptLineBreak";
import {
@@ -345,6 +346,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onTogglePaneMagnification,
onCommandExecuted,
onCommandSubmitted,
onCommandCompleted,
onSplitHorizontal,
onSplitVertical,
onOpenSftp,
@@ -2406,6 +2408,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
...args: Parameters<NonNullable<typeof onCommandSubmitted>>
) => {
if (!shouldPreserveTerminalCwdAcrossCommand(hostRestrictsExtraSshChannels(host))) {
// Arm completion tracking independently of the plugin trust callback:
// submissions from unconfirmed prompts (sftp> etc.) still invalidate the
// live cwd and still owe a completion publication. A new submission also
// closes any outstanding fallback re-probe window.
markTerminalCwdCompletionPending(promptLineBreakStateRef);
promptLineBreakStateRef.current.cwdRepublishPending = false;
invalidateTerminalCwdAfterCommand(
terminalCwdTracker,
sessionId,
@@ -2416,10 +2424,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [command, hostId, hostLabel, submittedSessionId] = args;
onCommandSubmitted?.(command, hostId, hostLabel, submittedSessionId);
}, [host, onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
const pluginAwareOnCommandCompleted = useCallback(() => {
pluginTerminalLifecycle.onCommandCompleted();
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
}, [pluginTerminalLifecycle]);
const pluginAwareOnCommandCompleted = useCallback((
cwdCompletionConfirmed?: boolean,
commandCompleted?: boolean,
) => {
// Only a consumed command completion marker mirrors a plugin commandSubmitted;
// cwd-only re-probe publications must not rerun completion-driven plugin work.
if (commandCompleted !== false) {
pluginTerminalLifecycle.onCommandCompleted();
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
}
// The runtime only marks a completion confirmed when it finished the last
// outstanding cwd-invalidating command (OSC 133 D, the armed one-shot
// re-probe, or a single-prompt fallback).
if (cwdCompletionConfirmed) {
onCommandCompleted?.(sessionId);
}
}, [onCommandCompleted, pluginTerminalLifecycle, sessionId]);
const pluginAwareOnTerminalCwdChange = useCallback((
changedSessionId: string,
cwd: string | null,
@@ -4625,7 +4646,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime,
});
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandCompleted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return (
<>
+37 -8
View File
@@ -342,6 +342,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const terminalOsc7SignalBySessionRef = useRef<Map<string, number>>(new Map());
const cwdProbeCancelersRef = useRef<Map<string, () => void>>(new Map());
const cwdProbeGenerationRef = useRef<Map<string, number>>(new Map());
const cwdProbeCommandSignalRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
const liveSessionIds = new Set(sessions.map((session) => session.id));
@@ -351,6 +352,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalOsc7SignalBySessionRef,
cwdProbeGenerationRef,
cwdProbeCancelersRef,
cwdProbeCommandSignalRef,
}, liveSessionIds);
terminalCwdStore.prune(liveSessionIds);
}, [sessions]);
@@ -398,7 +400,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalRendererCwdBySessionRef.current.delete(sessionId);
terminalRendererCwdSourceBySessionRef.current.delete(sessionId);
}
onUpdateSessionRestoreCwd?.(sessionId, nextCwd);
// Command submission invalidates live trust, not the last known restore
// location. Keep it until the shell confirms a cwd after completion.
if (nextCwd) onUpdateSessionRestoreCwd?.(sessionId, nextCwd);
// External store: side-panel live snapshot subscribers update without
// re-rendering TerminalLayerInner.
terminalCwdStore.setCwd(sessionId, nextCwd, nextCwd ? nextCwdSource : undefined);
@@ -444,6 +448,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalOsc7SignalBySessionRef,
cwdProbeGenerationRef,
cwdProbeCancelersRef,
cwdProbeCommandSignalRef,
}, sessionId);
codingCliSignalController.forgetSession(sessionId);
sessionCapabilitiesStore.delete(sessionId);
@@ -1330,6 +1335,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleCommandSubmitted = useCallback((command: string, _hostId: string, _hostLabel: string, sessionId: string) => {
codingCliSignalController.handleCommandSubmitted(sessionId, command);
const generation = (cwdProbeGenerationRef.current.get(sessionId) ?? 0) + 1;
cwdProbeGenerationRef.current.set(sessionId, generation);
cwdProbeCancelersRef.current.get(sessionId)?.();
cwdProbeCommandSignalRef.current.delete(sessionId);
const tabId = activeTabIdRef.current;
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
@@ -1346,10 +1355,22 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
restrictExtraSshChannels: hostRestrictsExtraSshChannels(sessionHost),
})) return;
const osc7SignalAtCommand = terminalOsc7SignalBySessionRef.current.get(sessionId) ?? 0;
const probeGeneration = (cwdProbeGenerationRef.current.get(sessionId) ?? 0) + 1;
cwdProbeGenerationRef.current.set(sessionId, probeGeneration);
cwdProbeCommandSignalRef.current.set(
sessionId, terminalOsc7SignalBySessionRef.current.get(sessionId) ?? 0,
);
}, [codingCliSignalController, restoreTerminalCwd, sidePanelLayoutsRef]);
const handleCommandCompleted = useCallback((sessionId: string) => {
const osc7SignalAtCommand = cwdProbeCommandSignalRef.current.get(sessionId);
if (osc7SignalAtCommand === undefined) return;
// Keep the baseline signal: a confirmed fallback publication also arms a
// one-shot re-probe so a real prompt after prompt-shaped command output
// can correct the published cwd. The next submission resets the baseline.
// A replaced re-probe must not stay eligible under the same generation:
// cancel it before storing the replacement so only the newest backend
// read can publish under this command's generation.
cwdProbeCancelersRef.current.get(sessionId)?.();
const probeGeneration = cwdProbeGenerationRef.current.get(sessionId);
const cancelProbe = scheduleBackendCwdProbeAfterCommand({
sessionId,
osc7SignalAtCommand,
@@ -1371,13 +1392,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
},
onProbedCwd: (cwd) => {
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return;
const existing = terminalRendererCwdBySessionRef.current.get(sessionId);
if (existing === cwd) return;
handleTerminalCwdChange(sessionId, cwd, { source: 'backend-strict' });
// Prompt-completion evidence (plain prompt, re-probe trigger, even an
// observed OSC 133;D marker — a child process can print all of them)
// can be forged by command output, so the probe result publishes
// untrusted. The SFTP follow re-verifies an untrusted stored cwd with
// its own fresh backend read, so a correct value reaches SFTP without
// an extra prompt.
handleTerminalCwdChange(sessionId, cwd, {
source: 'backend',
});
},
});
cwdProbeCancelersRef.current.set(sessionId, cancelProbe);
}, [codingCliSignalController, handleTerminalCwdChange, restoreTerminalCwd, sidePanelLayoutsRef, terminalBackend]);
}, [handleTerminalCwdChange, terminalBackend]);
const handleCommandExecuted = useCallback((command: string, hostId: string, hostLabel: string, sessionId: string) => {
onCommandExecuted?.(command, hostId, hostLabel, sessionId);
@@ -1390,6 +1417,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalOsc7SignalBySessionRef,
cwdProbeGenerationRef,
cwdProbeCancelersRef,
cwdProbeCommandSignalRef,
}, new Set());
}, []);
const sessionSudoAutofillPasswordsMap = useMemo(() => {
@@ -2528,6 +2556,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleCommandExecuted,
handleHistoryDelete: onDeleteShellHistoryEntry,
handleCommandSubmitted,
handleCommandCompleted,
handleComposeSend,
handleHistoryPaste,
handleHistoryRun,
@@ -262,7 +262,10 @@ export type TerminalSessionStartersContext = {
hostLabel: string,
sessionId: string,
) => void;
onCommandCompleted?: () => void;
/** True only when this completion confirms the last outstanding cwd-invalidating command.
* `commandCompleted` defaults to true; the runtime passes false only for a
* cwd-only publication that did not consume a command completion marker. */
onCommandCompleted?: (cwdCompletionConfirmed?: boolean, commandCompleted?: boolean) => void;
};
export type TerminalSessionDataMeta = {
@@ -200,6 +200,8 @@ import { pasteTextWithMultilineConfirm } from "../terminalClipboardPaste";
import { requestMultilinePasteConfirm } from "../../../application/state/multilinePasteConfirmStore";
import {
consumeOsc133CommandCompletion,
consumeOsc133CwdCompletion,
markOsc133CompletionProtocol,
type PromptLineBreakState,
} from "./promptLineBreak";
import { isSensitiveTerminalCommandInput, recordTerminalCommandExecution } from "./terminalCommandExecution";
@@ -366,7 +368,10 @@ export type CreateXTermRuntimeContext = {
hostLabel: string,
sessionId: string,
) => void;
onCommandCompleted?: () => void;
/** True only when this completion confirms the last outstanding cwd-invalidating command.
* `commandCompleted` defaults to true; the runtime passes false only for a
* cwd-only publication that did not consume a command completion marker. */
onCommandCompleted?: (cwdCompletionConfirmed?: boolean, commandCompleted?: boolean) => void;
requestPluginTerminalProviders?: RequestPluginTerminalProviders;
pluginProviderVisible?: boolean;
isPluginTerminalProviderAvailable?: (kind: NetcattyTerminalProviderKind) => boolean;
@@ -3239,8 +3244,34 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
const osc133Disposable = term.parser.registerOscHandler(133, (data) => {
if (consumeOsc133CommandCompletion(data, ctx.promptLineBreakStateRef?.current)) {
ctx.onCommandCompleted?.();
// An observed OSC 133;D completion marker proves the shell has completion
// integration, so the plain prompt fallback must stop publishing cwd once
// D is seen (non-D payloads can be forged by prompt-shaped command output
// on a non-integrated shell).
const commandCompleted = consumeOsc133CommandCompletion(data, ctx.promptLineBreakStateRef?.current);
const cwdCompleted = consumeOsc133CwdCompletion(data, ctx.promptLineBreakStateRef?.current);
markOsc133CompletionProtocol(data, ctx.promptLineBreakStateRef?.current);
// The D marker itself also flows through ordinary command output — a child
// can print it verbatim (`printf '\033]133;D\a'; sleep 2; cd /tmp`), which
// must not publish the pre-command cwd as trusted and retire the pending
// correction before the shell's genuine D arrives. So the D-confirmed
// backend read publishes untrusted, and its next prompt-shaped event
// re-probes to correct a stale value (the SFTP follow re-verifies
// untrusted values with fresh backend reads anyway).
if (cwdCompleted) {
// Re-arm a fresh re-probe chain so the shell's genuine prompt redraw
// re-verifies the published value.
const promptLineBreakState = ctx.promptLineBreakStateRef?.current;
if (promptLineBreakState) {
promptLineBreakState.cwdRepublishDepth = 1;
promptLineBreakState.cwdRepublishPending = true;
}
// A pending cwd completion without a pending command (an unconfirmed
// prompt submission) is a cwd-only publication: plugins never saw a
// commandSubmitted, so they must not receive a commandCompleted here.
ctx.onCommandCompleted?.(true, commandCompleted);
} else if (commandCompleted) {
ctx.onCommandCompleted?.(false, true);
}
return true;
});
@@ -3,12 +3,17 @@ import assert from "node:assert/strict";
import {
consumeOsc133CommandCompletion,
consumeOsc133CwdCompletion,
consumeTerminalCwdRepublish,
createPromptLineBreakState,
detectTerminalCommandCompletions,
drainTerminalCwdCompletions,
findTerminalPromptSourceChunkVisibleStarts,
insertPromptLineBreakBeforePrompt,
markOsc133CompletionProtocol,
markPromptLineBreakCommandPending,
markTerminalCommandCompletionPending,
markTerminalCwdCompletionPending,
prepareTerminalDataForPromptLineBreak,
syncPromptLineBreakState,
} from "./promptLineBreak";
@@ -1232,3 +1237,73 @@ test("finds prompt starts on the display string after identity transforms", () =
);
assert.deepEqual(starts, ["file tail".length]);
});
test("cwd completion arms on every invalidated submission and publishes only for a single outstanding command", () => {
const state = createPromptLineBreakState();
const stateRef = { current: state };
markTerminalCwdCompletionPending(stateRef);
markTerminalCwdCompletionPending(stateRef);
// Prompt-shaped command output must not confirm a queued batch...
assert.equal(drainTerminalCwdCompletions(createFakeTerm("$ ") as never, state), false);
assert.equal(state.pendingCwdCompletions, 0, "ambiguous batch drain drops markers without confirming");
// ...and it never consumes markers that belong to OSC 133 confirmation.
markTerminalCwdCompletionPending(stateRef);
state.shellCompletionProtocolSeen = true;
assert.equal(drainTerminalCwdCompletions(createFakeTerm("$ echo pending") as never, state), false, "a prompt with user input is not evidence the protocol fell silent");
assert.equal(state.pendingCwdCompletions, 1, "OSC 133 shells keep the marker for the authoritative D");
// A bare prompt for a single outstanding command without its D marker means
// the shell stopped reporting completions (nested non-integrated shell), so
// the heuristic fallback degrades the protocol flag and confirms.
assert.equal(drainTerminalCwdCompletions(createFakeTerm("$ ") as never, state), true);
assert.equal(state.pendingCwdCompletions, 0);
assert.equal(state.shellCompletionProtocolSeen, false, "a bare prompt with an outstanding completion degrades the protocol flag");
// ...but the protocol stays intact while no bare prompt arrives.
markTerminalCwdCompletionPending(stateRef);
state.shellCompletionProtocolSeen = true;
assert.equal(drainTerminalCwdCompletions(createFakeTerm("$ echo pending") as never, state), false);
assert.equal(state.pendingCwdCompletions, 1);
assert.equal(state.shellCompletionProtocolSeen, true, "non-prompt output must not degrade the protocol flag");
state.shellCompletionProtocolSeen = false;
assert.equal(drainTerminalCwdCompletions(createFakeTerm("$ ") as never, state), true);
// A single outstanding command still confirms at an empty prompt.
markTerminalCwdCompletionPending(stateRef);
assert.equal(drainTerminalCwdCompletions(createFakeTerm("$ echo pending") as never, state), false);
assert.equal(state.pendingCwdCompletions, 1);
assert.equal(drainTerminalCwdCompletions(createFakeTerm("$ ") as never, state), true);
assert.equal(state.pendingCwdCompletions, 0);
});
test("OSC 133 cwd completion publishes only when the last outstanding command finishes", () => {
const state = createPromptLineBreakState();
const stateRef = { current: state };
markTerminalCwdCompletionPending(stateRef);
markTerminalCwdCompletionPending(stateRef);
assert.equal(consumeOsc133CwdCompletion("C", state), false);
assert.equal(consumeOsc133CwdCompletion("D;0", state), false, "queued commands keep the marker pending");
assert.equal(state.pendingCwdCompletions, 1);
assert.equal(consumeOsc133CwdCompletion("D;1", state), true);
assert.equal(state.pendingCwdCompletions, 0);
assert.equal(consumeOsc133CwdCompletion("D;0", state), false);
assert.equal(markOsc133CompletionProtocol("A", state), undefined);
assert.equal(state.shellCompletionProtocolSeen, false, "a stray OSC 133;A does not count as the completion protocol");
assert.equal(markOsc133CompletionProtocol("D;0", state), undefined);
assert.equal(state.shellCompletionProtocolSeen, true);
});
test("the fallback publication arms a one-shot re-probe that survives non-prompt output", () => {
const state = createPromptLineBreakState();
const stateRef = { current: state };
markTerminalCwdCompletionPending(stateRef);
assert.equal(drainTerminalCwdCompletions(createFakeTerm("$ ") as never, state), true);
state.cwdRepublishPending = true;
assert.equal(consumeTerminalCwdRepublish(createFakeTerm("output$ wait") as never, state), false);
assert.equal(state.cwdRepublishPending, true, "non-prompt chunks keep the re-probe armed");
assert.equal(consumeTerminalCwdRepublish(createFakeTerm("$ ") as never, state), true);
assert.equal(state.cwdRepublishPending, false);
assert.equal(consumeTerminalCwdRepublish(createFakeTerm("$ ") as never, state), false);
});
@@ -12,6 +12,38 @@ export type PromptLineBreakState = {
pendingCommand: boolean;
suppressNextPromptCache: boolean;
pendingCommandCompletions: number;
/**
* Submissions that invalidated the live cwd and still owe a confirmed shell
* completion. Armed on every cwd-invalidating submission (not only trusted
* shell prompts), so unconfirmed prompts such as `sftp>` also publish cwd.
*/
pendingCwdCompletions: number;
/**
* One-shot re-probe armed after a prompt-shaped publication: the
* publication (plain-prompt fallback, armed re-probe, or OSC 133;D
* confirmation) can be forged by command output, so the next empty prompt
* re-probes to correct a stale published value.
*/
cwdRepublishPending: boolean;
/**
* How many re-probe generations the current chain consumed. A fresh
* fallback or OSC 133;D publication restarts the chain; consuming an armed
* re-probe advances it. The cap keeps a command that endlessly prints
* prompt-shaped chunks from requesting an unlimited number of backend reads.
*/
cwdRepublishDepth: number;
/**
* Set while the shell keeps sending OSC 133;D completion markers. While it
* holds, the plain prompt fallback never publishes cwd — the per-command D
* marker is the only event that can correlate the finishes of queued
* commands one by one (a prompt cannot, and other OSC 133 payloads can be
* forged by arbitrary command output — so can a D printed by a child, which
* is why its confirmed publication stays untrusted). The flag degrades back
* to heuristic fallback when a bare prompt arrives for an outstanding
* command without its D marker (e.g. after stepping into a nested
* non-integrated shell), and re-arms on the next D marker.
*/
shellCompletionProtocolSeen: boolean;
};
type VisibleTextMap = {
@@ -462,6 +494,10 @@ export function createPromptLineBreakState(): PromptLineBreakState {
pendingCommand: false,
suppressNextPromptCache: false,
pendingCommandCompletions: 0,
pendingCwdCompletions: 0,
cwdRepublishPending: false,
cwdRepublishDepth: 0,
shellCompletionProtocolSeen: false,
};
}
@@ -490,6 +526,93 @@ export function consumeOsc133CommandCompletion(
return data.split(";", 1)[0] === "D" && consumeTerminalCommandCompletion(state);
}
export function markOsc133CompletionProtocol(
data: string,
state: PromptLineBreakState | undefined,
): void {
// Only the D (command completed) marker proves the shell's completion
// protocol. Other OSC 133 payloads (A/B/C) can be forged by arbitrary
// command output on a non-integrated shell, and a stray A that never gets
// its D would permanently disable the plain-prompt fallback while cwd
// completions stay pending.
if (data.split(";", 1)[0] !== "D") return;
if (state) state.shellCompletionProtocolSeen = true;
}
/** Arm cwd publication after a submission invalidated the live cwd. */
export function markTerminalCwdCompletionPending(
stateRef?: RefObject<PromptLineBreakState>,
): void {
if (!stateRef?.current) return;
stateRef.current.pendingCwdCompletions = Math.min(
64,
stateRef.current.pendingCwdCompletions + 1,
);
}
/**
* Consume one cwd completion for an OSC 133;D. Returns true only when this
* completion finished the last outstanding cwd-invalidating command, i.e. the
* moment a cwd publication is safe.
*/
export function consumeOsc133CwdCompletion(
data: string,
state: PromptLineBreakState | undefined,
): boolean {
if (data.split(";", 1)[0] !== "D") return false;
if (!state || state.pendingCwdCompletions < 1) return false;
state.pendingCwdCompletions -= 1;
return state.pendingCwdCompletions === 0;
}
/**
* Plain-prompt fallback for cwd completion. Publishes only for a single
* outstanding command and only while the shell shows no OSC 133 completion
* protocol (where the per-command D marker correlates queued completions one
* by one, which a prompt cannot). Prompt-shaped command output cannot be told
* apart from a real prompt here, so the confirmation stays
* heuristic and callers keep a re-probe armed to self-correct.
*
* The protocol flag also degrades when a bare prompt arrives for a single
* outstanding command without its D marker: the shell may have stopped
* reporting completions after the operator stepped into a nested
* non-integrated shell (sh, su, sudo -s), where the pending completion would
* otherwise never drain. A later D marker re-arms the protocol.
*/
export function drainTerminalCwdCompletions(
term: XTerm,
state: PromptLineBreakState | undefined,
): boolean {
if (!state || state.pendingCwdCompletions < 1) return false;
if (state.shellCompletionProtocolSeen) {
if (state.pendingCwdCompletions !== 1 || !isAtEmptyPromptForCompletion(term)) {
return false;
}
state.shellCompletionProtocolSeen = false;
} else if (!isAtEmptyPromptForCompletion(term)) {
return false;
}
const outstanding = state.pendingCwdCompletions;
state.pendingCwdCompletions = 0;
return outstanding === 1;
}
const isAtEmptyPromptForCompletion = (term: XTerm): boolean => {
const prompt = detectPrompt(term);
return prompt.isAtPrompt && prompt.userInput.length === 0;
};
/** Consume the one-shot re-probe armed after a fallback cwd publication. */
export function consumeTerminalCwdRepublish(
term: XTerm,
state: PromptLineBreakState | undefined,
): boolean {
if (!state || !state.cwdRepublishPending) return false;
if (!isAtEmptyPromptForCompletion(term)) return false;
state.cwdRepublishPending = false;
return true;
}
export function detectTerminalCommandCompletions(
term: XTerm,
state: PromptLineBreakState | undefined,
@@ -11,7 +11,9 @@ import {
prepareTerminalDataForUserPasteDisplay,
} from "./terminalUserPaste";
import {
consumeTerminalCwdRepublish,
detectTerminalCommandCompletions,
drainTerminalCwdCompletions,
findTerminalPromptSourceChunkVisibleStarts,
prepareTerminalDataForPromptLineBreak,
syncPromptLineBreakState,
@@ -160,6 +162,14 @@ type TerminalSessionWriteOptions = CoalescedTerminalWriteOptions & {
};
const BACKGROUND_OUTPUT_FLUSH_MAX_PASSES = 64;
/**
* Bounds the re-probe chain armed after a cwd publication: a fresh publication
* restarts the chain at depth 1, and each consumed re-probe advances it until
* the cap, so prompt-shaped command output cannot request endless backend
* reads while a stale value still stays correctable by the next prompts.
*/
const MAX_CWD_REPUBLISH_DEPTH = 2;
// With microtask coalescing, idle drain is only a safety net for rAF TUI path
// and any leftover queue work. Keep xterm on its public async write path here:
// its private flushSync removes a chunk before parsing and can strand the
@@ -636,12 +646,47 @@ const writeSessionDataImmediate = (
}
};
const publishCommandCompletion = () => {
const promptLineBreakState = ctx.promptLineBreakStateRef?.current;
const completed = detectTerminalCommandCompletions(
term,
ctx.promptLineBreakStateRef?.current,
promptLineBreakState,
);
// A single prompt does not confirm all queued commands completed. Keep
// plugin accounting per command, but only a single outstanding command
// may confirm a cwd publication (a plain prompt cannot correlate a
// batch, and prompt-shaped command output can forge it).
const cwdConfirmed = drainTerminalCwdCompletions(term, promptLineBreakState) && completed <= 1;
const cwdRepublish = consumeTerminalCwdRepublish(term, promptLineBreakState);
// Every prompt-shaped completion event (the plain-prompt fallback, the
// armed one-shot re-probe) can be forged by command output, e.g.
// `printf '$ '; sleep 1; printf '$ '; sleep 1; cd /tmp`: its first fake
// prompt confirms the fallback, and a second one must never publish the
// pre-command cwd as trusted while the real completion is still owed.
// So all prompt-shaped publications stay untrusted (the SFTP follow
// re-verifies the value with a fresh backend read) and each publication
// re-arms the re-probe, keeping the shell's next genuine prompt able to
// correct a stale value without needing a new submission.
const publishCwd = (cwdConfirmed || cwdRepublish) && completed <= 1;
for (let index = 0; index < completed; index += 1) {
ctx.onCommandCompleted?.();
// Each looped completion consumed a pending command marker.
ctx.onCommandCompleted?.(index === 0 && publishCwd, true);
}
if (completed === 0 && publishCwd) {
// A cwd-only re-probe/fallback publication: no command marker was
// consumed, so plugins must not see a commandCompleted here.
ctx.onCommandCompleted?.(true, false);
}
if ((cwdConfirmed || cwdRepublish) && promptLineBreakState) {
// The published backend read is heuristic: keep the one-shot re-probe
// armed so a later prompt-shaped event can still correct a forged or
// stale value, with the chain depth capped.
const depth = cwdRepublish ? promptLineBreakState.cwdRepublishDepth : 0;
if (cwdRepublish && depth >= MAX_CWD_REPUBLISH_DEPTH) {
promptLineBreakState.cwdRepublishDepth = 0;
} else {
promptLineBreakState.cwdRepublishDepth = depth + 1;
promptLineBreakState.cwdRepublishPending = true;
}
}
};
const finishQueueItem = () => {
+34
View File
@@ -7,6 +7,7 @@ import {
shouldPreserveTerminalCwdAcrossCommand,
probeBackendSessionCwdAfterCommand,
resolvePreferredTerminalCwd,
scheduleBackendCwdProbeAfterCommand,
} from "./sftpCwd";
test("resolvePreferredTerminalCwd prefers fresh backend pwd when requested", async () => {
@@ -310,3 +311,36 @@ test("single-channel command submission keeps the cwd SFTP follow already has",
assert.equal(shouldPreserveTerminalCwdAcrossCommand(true), true);
assert.equal(shouldPreserveTerminalCwdAcrossCommand(false), false);
});
test("a cancelled completion probe cannot publish its late backend result", async () => {
let finish!: (value: { success: boolean; cwd: string }) => void;
let started!: () => void;
const began = new Promise<void>((resolve) => { started = resolve; });
const result = new Promise<{ success: boolean; cwd: string }>((resolve) => { finish = resolve; });
const published: string[] = [];
const cancel = scheduleBackendCwdProbeAfterCommand({
sessionId: "session", osc7SignalAtCommand: 0, getOsc7Signal: () => 0,
getSessionPwd: async () => { started(); return result; },
onProbedCwd: (cwd) => published.push(cwd),
});
await began;
cancel();
finish({ success: true, cwd: "/old-command" });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(published, []);
});
test("an OSC 7 arriving in the prompt chunk supersedes the scheduled completion probe", async () => {
let signal = 0;
let reads = 0;
const cancel = scheduleBackendCwdProbeAfterCommand({
sessionId: "session", osc7SignalAtCommand: 0, getOsc7Signal: () => signal,
getSessionPwd: async () => { reads += 1; return { success: true, cwd: "/obsolete" }; },
onProbedCwd: () => assert.fail("the OSC 7 report owns cwd"),
});
signal += 1;
await new Promise((resolve) => setTimeout(resolve, 10));
cancel();
assert.equal(reads, 0);
});
+7 -4
View File
@@ -131,7 +131,9 @@ export const resolvePreferredTerminalCwd = async ({
}
};
export const PROBE_SESSION_CWD_AFTER_COMMAND_MS = 150;
// Schedule after xterm finishes the completion/prompt chunk so an OSC 7 in
// that same chunk can supersede the fallback without an extra SSH request.
export const PROBE_SESSION_CWD_AFTER_COMMAND_MS = 0;
export type ProbeBackendSessionCwdAfterCommandOptions = {
sessionId: string;
@@ -141,7 +143,7 @@ export type ProbeBackendSessionCwdAfterCommandOptions = {
canProbe?: () => boolean | Promise<boolean>;
};
/** Probe backend pwd when OSC 7 did not report after a command. */
/** One guarded backend read after command completion when OSC 7 did not report. */
export const probeBackendSessionCwdAfterCommand = async ({
sessionId,
osc7SignalAtCommand,
@@ -175,10 +177,11 @@ export const scheduleBackendCwdProbeAfterCommand = (
},
): (() => void) => {
const delayMs = options.delayMs ?? PROBE_SESSION_CWD_AFTER_COMMAND_MS;
let cancelled = false;
const timeoutId = setTimeout(() => {
void probeBackendSessionCwdAfterCommand(options).then((cwd) => {
if (cwd) options.onProbedCwd(cwd);
if (!cancelled && cwd) options.onProbedCwd(cwd);
});
}, delayMs);
return () => clearTimeout(timeoutId);
return () => { cancelled = true; clearTimeout(timeoutId); };
};
+1
View File
@@ -217,6 +217,7 @@ export interface TerminalProps {
hostLabel: string,
sessionId: string,
) => void;
onCommandCompleted?: (sessionId: string) => void;
onSplitHorizontal?: () => void;
onSplitVertical?: () => void;
onOpenSftp?: (
@@ -238,3 +238,31 @@ test('normal boot and hibernate wake share terminal link error feedback', () =>
/createXTermRuntime\(\{[\s\S]*?onOpenExternalError,/,
);
});
test('normal and reactivated terminals forward completion only when it confirms outstanding cwd commands', () => {
for (const source of [terminalSource, effectsSource]) {
assert.match(source, /if \(cwdCompletionConfirmed\) \{\s*onCommandCompleted\?\.\(sessionId\)/);
}
// Cwd-only publications (armed re-probe, single-prompt fallback) never
// consume a command marker, so plugins must not receive commandCompleted.
for (const source of [terminalSource, effectsSource]) {
assert.match(
source,
/if \(commandCompleted !== false\) \{\s*(?:publishPluginTerminalRuntimeLifecycleEvent|pluginTerminalLifecycle\.onCommandCompleted)/,
);
}
// Every cwd-invalidating submission arms completion tracking, independent of
// the plugin trust callback, so unconfirmed prompts (sftp> etc.) also publish.
assert.match(terminalSource, /markTerminalCwdCompletionPending\(promptLineBreakStateRef\);/);
const layer = readFileSync(new URL('../TerminalLayer.tsx', import.meta.url), 'utf8');
const submitted = layer.slice(layer.indexOf('const handleCommandSubmitted ='), layer.indexOf('const handleCommandCompleted ='));
const completed = layer.slice(layer.indexOf('const handleCommandCompleted ='), layer.indexOf('const handleCommandExecuted ='));
assert.doesNotMatch(submitted, /scheduleBackendCwdProbeAfterCommand/);
assert.match(completed, /scheduleBackendCwdProbeAfterCommand/);
assert.match(layer, /if \(nextCwd\) onUpdateSessionRestoreCwd\?\.\(sessionId, nextCwd\)/);
const panes = readFileSync(new URL('../terminalLayer/TerminalLayerSupport.tsx', import.meta.url), 'utf8');
const workspaces = readFileSync(new URL('../terminalLayer/TerminalLayerWorkspaceSection.tsx', import.meta.url), 'utf8');
assert.match(panes, /onCommandCompleted=\{onCommandCompleted\}/);
assert.match(workspaces, /onCommandCompleted=\{handleCommandCompleted\}/);
});
+14 -4
View File
@@ -213,7 +213,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
}
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onCommandCompleted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
terminalSettings,
@@ -229,9 +229,19 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandSubmitted');
void xtermRuntimeRef.current?.pluginProviderHost?.commandSubmitted(args[0]);
};
const pluginAwareOnCommandCompleted = () => {
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandCompleted');
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
const pluginAwareOnCommandCompleted = (cwdCompletionConfirmed?: boolean, commandCompleted?: boolean) => {
// Only a consumed command completion marker mirrors a plugin commandSubmitted;
// cwd-only re-probe publications must not rerun completion-driven plugin work.
if (commandCompleted !== false) {
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandCompleted');
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
}
// The runtime only marks a completion confirmed when it finished the last
// outstanding cwd-invalidating command (OSC 133 D, the armed one-shot
// re-probe, or a single-prompt fallback).
if (cwdCompletionConfirmed) {
onCommandCompleted?.(sessionId);
}
};
const isRendererActive = isVisible || !hibernateHiddenTabs;
const isRendererActiveRef = useRef(isRendererActive);
@@ -818,6 +818,7 @@ interface TerminalPaneProps {
onAddKnownHost?: (knownHost: KnownHost) => void;
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
onCommandSubmitted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
onCommandCompleted?: (sessionId: string) => void;
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
isBroadcastEnabled?: (workspaceId: string) => boolean;
@@ -946,6 +947,7 @@ const terminalPanePropsAreEqual = (
prev.onAddKnownHost === next.onAddKnownHost &&
prev.onCommandExecuted === next.onCommandExecuted &&
prev.onCommandSubmitted === next.onCommandSubmitted &&
prev.onCommandCompleted === next.onCommandCompleted &&
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onSplitSession === next.onSplitSession &&
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
@@ -1247,6 +1249,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
onAddKnownHost,
onCommandExecuted,
onCommandSubmitted,
onCommandCompleted,
onSetWorkspaceFocusedSession,
onSplitSession,
isBroadcastEnabled,
@@ -1622,6 +1625,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
onAddKnownHost={onAddKnownHost}
onCommandExecuted={onCommandExecuted}
onCommandSubmitted={onCommandSubmitted}
onCommandCompleted={onCommandCompleted}
onExpandToFocus={inActiveWorkspace && !isFocusMode ? handleExpandToFocus : undefined}
onTogglePaneMagnification={inActiveWorkspace && (!isFocusMode || isMagnified) ? handleTogglePaneMagnification : undefined}
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
@@ -1720,6 +1724,7 @@ interface TerminalPanesHostProps {
onAddKnownHost?: (knownHost: KnownHost) => void;
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
onCommandSubmitted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
onCommandCompleted?: (sessionId: string) => void;
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
isBroadcastEnabled?: (workspaceId: string) => boolean;
@@ -1810,6 +1815,7 @@ const terminalPanesHostPropsAreEqual = (
if (prev.onAddKnownHost !== next.onAddKnownHost) return false;
if (prev.onCommandExecuted !== next.onCommandExecuted) return false;
if (prev.onCommandSubmitted !== next.onCommandSubmitted) return false;
if (prev.onCommandCompleted !== next.onCommandCompleted) return false;
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
if (prev.onSplitSession !== next.onSplitSession) return false;
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
@@ -98,6 +98,7 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
handleAddKnownHost,
handleCommandExecuted,
handleCommandSubmitted,
handleCommandCompleted,
onSetWorkspaceFocusedSession,
onSplitSession,
isBroadcastEnabled,
@@ -244,6 +245,7 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={handleCommandExecuted}
onCommandSubmitted={handleCommandSubmitted}
onCommandCompleted={handleCommandCompleted}
onSetWorkspaceFocusedSession={onSetWorkspaceFocusedSession}
onSplitSession={onSplitSession}
isBroadcastEnabled={isBroadcastEnabled}
@@ -0,0 +1,275 @@
import assert from "node:assert/strict";
import { spawn, execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, readlinkSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { transformSync } from "esbuild";
import { shouldProbeCommandCwd } from "./commandCwdProbe";
import { scheduleBackendCwdProbeAfterCommand } from "../terminal/sftpCwd";
import { buildOsc7SetupCommand } from "../terminal/osc7Setup";
import {
consumeOsc133CommandCompletion,
consumeOsc133CwdCompletion,
consumeTerminalCwdRepublish,
createPromptLineBreakState,
detectTerminalCommandCompletions,
drainTerminalCwdCompletions,
markOsc133CompletionProtocol,
markTerminalCommandCompletionPending,
markTerminalCwdCompletionPending,
} from "../terminal/runtime/promptLineBreak";
// Execute the actual parent callbacks, without mounting the rest of the app.
const source = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
const callbacks = source.slice(source.indexOf(" const handleCommandSubmitted ="), source.indexOf(" const handleCommandExecuted ="));
const makeCallbacks = new Function("ctx", transformSync(`
const { useCallback, codingCliSignalController, cwdProbeGenerationRef,
cwdProbeCancelersRef, cwdProbeCommandSignalRef, activeTabIdRef, sessionsRef,
canReuseTerminalConnection, sessionHostsMapRef, sidePanelLayoutHasTool,
sidePanelLayoutsRef, sftpHostForTabRef, shouldProbeCommandCwd, restoreTerminalCwd,
sftpFollowTerminalCwdRef, hostRestrictsExtraSshChannels, terminalOsc7SignalBySessionRef,
scheduleBackendCwdProbeAfterCommand, terminalBackend, classifyDistroId,
shouldProbeSessionCwd, handleTerminalCwdChange } = ctx;
${callbacks}
return { submitted: handleCommandSubmitted, completed: handleCommandCompleted };
`, { loader: "ts", target: "es2022" }).code);
const waitUntil = async (predicate: () => boolean) => {
const deadline = Date.now() + 5000;
while (!predicate()) {
assert.ok(Date.now() < deadline, "fixture shell did not reach the expected prompt/cwd");
await new Promise((resolve) => setTimeout(resolve, 5));
}
};
for (const mode of ["osc133", "osc7", "prompt"]) {
const integration = mode === "osc7";
test(`Bash follows completed cwd changes, no-op and failed cd using ${mode}`, {
skip: process.platform !== "linux",
}, async () => {
const home = mkdtempSync(join(tmpdir(), "netcatty-cwd-completion-"));
const first = join(home, "one");
const second = join(home, "two");
mkdirSync(first); mkdirSync(second);
const rc = join(home, ".bashrc");
const promptMarker = "__NETCATTY_FIXTURE_PROMPT__$ ";
const firstGate = join(home, "release-first");
const finalGate = join(home, "release-final");
const env = { ...process.env, HOME: home, SHELL: "/bin/bash", PS1: "$ ", HISTFILE: "/dev/null" };
if (integration) execFileSync("/bin/sh", ["-c", buildOsc7SetupCommand()], { env, stdio: "pipe" });
const configured = (integration ? readFileSync(rc, "utf8") : "") + `\nPS1='${promptMarker}'\n`;
// OSC 133 is the existing runtime completion protocol, independent of cwd.
writeFileSync(rc, mode === "prompt" ? configured : `${configured}\nPROMPT_COMMAND="\${PROMPT_COMMAND:+\${PROMPT_COMMAND}; }printf '\\033]133;D\\a'"\n`);
const shell = spawn("/bin/bash", ["--noprofile", "--rcfile", rc, "-i"], { cwd: home, env, stdio: "pipe" });
let ready = false;
let received = "";
let prompts = "";
let cwd: string | null = null;
let cwdSource: string | undefined;
let probes = 0;
let completions = 0;
const signals = { current: new Map<string, number>() };
const cancelers = { current: new Map<string, () => void>() };
const pending = { current: createPromptLineBreakState() };
const { submitted, completed } = makeCallbacks({
useCallback: (callback: unknown) => callback,
codingCliSignalController: { handleCommandSubmitted() {} },
cwdProbeGenerationRef: { current: new Map() }, cwdProbeCancelersRef: cancelers,
cwdProbeCommandSignalRef: { current: new Map() },
activeTabIdRef: { current: "session" }, sessionsRef: { current: [{ id: "session" }] },
canReuseTerminalConnection: () => true,
sessionHostsMapRef: { current: new Map([["session", { sftpFollowTerminalCwd: true }]]) },
sidePanelLayoutHasTool: () => true, sidePanelLayoutsRef: { current: new Map() },
sftpHostForTabRef: { current: new Map([["session", { sftpFollowTerminalCwd: true }]]) },
shouldProbeCommandCwd, restoreTerminalCwd: true, sftpFollowTerminalCwdRef: { current: true },
hostRestrictsExtraSshChannels: () => false, terminalOsc7SignalBySessionRef: signals,
scheduleBackendCwdProbeAfterCommand,
terminalBackend: {
getSessionRemoteInfo: async () => ({ remoteSshVersion: "OpenSSH_fixture" }),
getSessionPwd: async () => {
probes += 1;
return { success: true, cwd: readlinkSync(`/proc/${shell.pid}/cwd`) };
},
},
classifyDistroId: () => "linux", shouldProbeSessionCwd: () => true,
handleTerminalCwdChange: (_session: string, path: string, meta?: { source?: string }) => {
cwd = path;
cwdSource = meta?.source;
},
});
shell.stderr.on("data", (chunk: Buffer) => {
if (mode !== "prompt") return;
prompts += chunk.toString();
// Pipes may split or coalesce real Bash prompts with command echo. Do
// not lose a prompt just because it is not at the end of a data chunk.
for (;;) {
const end = prompts.indexOf(promptMarker);
if (end < 0) break;
prompts = prompts.slice(end + promptMarker.length);
ready = true;
completions += 1;
const prompt = { buffer: { active: {
cursorX: 2, cursorY: 0, baseY: 0,
getLine: (line: number) => line === 0 ? { isWrapped: false, translateToString: () => "$ " } : undefined,
} } };
// Mirror the runtime fallback publication rules: only a single
// outstanding command (or the armed re-check) confirms a cwd, and a
// publication arms a one-shot re-check afterward. Prompt-shaped
// evidence can be forged by command output, so every publication
// stays untrusted.
const count = detectTerminalCommandCompletions(prompt as never, pending.current);
const cwdConfirmed = drainTerminalCwdCompletions(prompt as never, pending.current) && count <= 1;
const republish = consumeTerminalCwdRepublish(prompt as never, pending.current);
const publishCwd = (cwdConfirmed || republish) && count <= 1;
if (publishCwd) {
completed("session");
if (cwdConfirmed || republish) {
// Mirror the runtime arming rules: a fresh publication restarts
// the re-check chain, a consumed re-check advances it, and the
// chain is capped.
const depth = republish ? pending.current.cwdRepublishDepth : 0;
if (republish && depth >= 2) {
pending.current.cwdRepublishDepth = 0;
} else {
pending.current.cwdRepublishDepth = depth + 1;
pending.current.cwdRepublishPending = true;
}
}
}
}
});
shell.stdout.on("data", (chunk: Buffer) => {
received += chunk.toString();
for (;;) {
const end = received.indexOf("\x07");
if (end < 0) break;
const sequence = received.slice(0, end);
received = received.slice(end + 1);
const osc7 = sequence.includes("\x1b]7;") ? sequence.match(/file:\/\/[^/]*(.*)$/) : null;
if (osc7) {
signals.current.set("session", (signals.current.get("session") ?? 0) + 1);
cwd = decodeURIComponent(osc7[1]);
}
if (sequence.endsWith("\x1b]133;D")) {
ready = true;
completions += 1;
// Mirror the runtime: the OSC 133;D marker proves shell completion
// integration and confirms the last outstanding cwd-invalidating
// command. A child can print the marker verbatim, so the confirmed
// publication stays untrusted and re-arms the re-check.
markOsc133CompletionProtocol("D", pending.current);
const cwdDone = consumeOsc133CwdCompletion("D", pending.current);
consumeOsc133CommandCompletion("D", pending.current);
if (cwdDone) {
pending.current.cwdRepublishDepth = 1;
pending.current.cwdRepublishPending = true;
completed("session");
}
}
}
});
const submit = (command: string) => {
cwd = null; // The shared live cwd is invalidated at submission.
markTerminalCommandCompletionPending(pending);
markTerminalCwdCompletionPending(pending);
pending.current.cwdRepublishPending = false;
submitted(command, "host", "fixture", "session");
shell.stdin.write(`${command}\n`);
};
try {
await waitUntil(() => ready);
submit(`while [ ! -f '${firstGate}' ]; do sleep 0.01; done; cd '${first}'`);
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(probes, 0, "no fixed post-submit polling while the command is still running");
assert.equal(cwd, null);
writeFileSync(firstGate, "release");
await waitUntil(() => cwd === first);
if (mode === "osc133") {
assert.equal(cwdSource, "backend", "OSC 133;D-confirmed publications stay untrusted: a child can print the marker verbatim");
} else if (mode === "prompt") {
assert.equal(cwdSource, "backend", "prompt-shaped fallback publications stay untrusted");
}
for (const command of ["cd .", `cd '${home}/does-not-exist'`]) {
submit(command);
await waitUntil(() => cwd === first);
}
const before = probes;
const completedBefore = completions;
submit(`sleep 0.1 && cd '${home}'`);
submit(`while [ ! -f '${finalGate}' ]; do sleep 0.01; done; cd '${second}'`);
await waitUntil(() => completions === completedBefore + 1);
if (!integration) assert.equal(probes, before, "queued commands must finish before the fallback read");
writeFileSync(finalGate, "release");
if (mode === "prompt") {
await waitUntil(() => completions >= completedBefore + 2);
assert.equal(probes, before, "ambiguous prompt batches must not guess a confirmed cwd");
assert.equal(cwd, null, "wait for an explicit cwd report or Locate instead of publishing an intermediate path");
} else {
await waitUntil(() => cwd === second);
assert.equal(probes - before, integration ? 0 : 1);
assert.equal(probes, integration ? 0 : 4);
}
// A submission from an unconfirmed prompt (sftp> etc.) never reaches the
// trusted plugin callback, yet its cwd invalidation must still publish
// once the shell reports completion (Codex: arm cwd completion for every
// invalidated submission).
cwd = null;
markTerminalCwdCompletionPending(pending);
pending.current.cwdRepublishPending = false;
submitted(`cd '${home}'`, "host", "fixture", "session");
shell.stdin.write(`cd '${home}'\n`);
await waitUntil(() => cwd === home);
} finally {
writeFileSync(firstGate, "release");
writeFileSync(finalGate, "release");
for (const cancel of cancelers.current.values()) cancel();
shell.stdin.end("exit\n");
await new Promise<void>((resolve) => shell.once("exit", () => resolve()));
rmSync(home, { recursive: true, force: true });
}
});
}
test("completion re-probe replacement cancels the previous pending probe", () => {
const scheduled: unknown[] = [];
const cancelled = new Set<number>();
const { submitted, completed } = makeCallbacks({
useCallback: (callback: unknown) => callback,
codingCliSignalController: { handleCommandSubmitted() {} },
cwdProbeGenerationRef: { current: new Map<string, number>() },
cwdProbeCancelersRef: { current: new Map<string, () => void>() },
cwdProbeCommandSignalRef: { current: new Map<string, number>() },
activeTabIdRef: { current: "session" },
sessionsRef: { current: [{ id: "session" }] } as never,
canReuseTerminalConnection: () => true,
sessionHostsMapRef: { current: new Map([["session", { sftpFollowTerminalCwd: true }]]) } as never,
sidePanelLayoutHasTool: () => true,
sidePanelLayoutsRef: { current: new Map() } as never,
sftpHostForTabRef: { current: new Map([["session", { sftpFollowTerminalCwd: true }]]) } as never,
shouldProbeCommandCwd, restoreTerminalCwd: true, sftpFollowTerminalCwdRef: { current: true },
hostRestrictsExtraSshChannels: () => false,
terminalOsc7SignalBySessionRef: { current: new Map<string, number>() },
scheduleBackendCwdProbeAfterCommand: (options: unknown) => {
scheduled.push(options);
return () => { cancelled.add(scheduled.indexOf(options)); };
},
terminalBackend: {
getSessionRemoteInfo: async () => ({ remoteSshVersion: "OpenSSH_fixture" }),
getSessionPwd: async () => ({ success: true, cwd: null }),
},
classifyDistroId: () => "linux", shouldProbeSessionCwd: () => true,
handleTerminalCwdChange: () => {},
});
submitted("cd /tmp", "host", "fixture", "session");
// A forged prompt confirms a fallback publication; the later real prompt
// arms the re-probe. The replacement must cancel the still-pending probe so
// only the newest backend read stays eligible under the same generation.
completed("session");
completed("session");
completed("session");
assert.equal(scheduled.length, 3, "each confirmed completion schedules a probe");
assert.ok(cancelled.has(scheduled.indexOf(scheduled[0])) && cancelled.has(scheduled.indexOf(scheduled[1])), "every replaced probe is cancelled");
assert.ok(!cancelled.has(scheduled.indexOf(scheduled[2])), "the newest probe stays eligible");
});
@@ -38,6 +38,7 @@ export type TerminalSessionRuntimeState = {
terminalRendererCwdSourceBySessionRef?: RuntimeStateRef<RendererCwdSource>;
terminalOsc7SignalBySessionRef: RuntimeStateRef<number>;
cwdProbeGenerationRef: RuntimeStateRef<number>;
cwdProbeCommandSignalRef?: RuntimeStateRef<number>;
cwdProbeCancelersRef: RuntimeStateRef<() => void>;
};
@@ -49,6 +50,7 @@ export function clearTerminalSessionRuntimeState(
state.cwdProbeCancelersRef.current.delete(sessionId);
state.cwdProbeGenerationRef.current.delete(sessionId);
state.cwdProbeCommandSignalRef?.current.delete(sessionId);
state.terminalOsc7SignalBySessionRef.current.delete(sessionId);
state.terminalRendererCwdBySessionRef.current.delete(sessionId);
state.terminalRendererCwdSourceBySessionRef?.current.delete(sessionId);
@@ -74,6 +76,7 @@ export function pruneTerminalSessionRuntimeState(
...(state.terminalRendererCwdSourceBySessionRef?.current.keys() ?? []),
...state.terminalOsc7SignalBySessionRef.current.keys(),
...state.cwdProbeGenerationRef.current.keys(),
...(state.cwdProbeCommandSignalRef?.current.keys() ?? []),
...state.cwdProbeCancelersRef.current.keys(),
]);