mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:06:25 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f773dbe09 | ||
|
|
bd0be9fcd2 | ||
|
|
168e8f7232 | ||
|
|
df049dfa2b | ||
|
|
9817238662 | ||
|
|
71f27b4fd8 | ||
|
|
211a4c327c | ||
|
|
1254d55ff9 | ||
|
|
bd6ec004c5 | ||
|
|
5505d3f63c |
No files matched your search
+26
-5
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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); };
|
||||
};
|
||||
@@ -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\}/);
|
||||
});
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user