import { resolveHostOs } from '../domain/host'; import { Terminal as XTerm } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { SerializeAddon } from "@xterm/addon-serialize"; import { SearchAddon } from "@xterm/addon-search"; import "@xterm/xterm/css/xterm.css"; import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug } from "lucide-react"; import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useI18n } from "../application/i18n/I18nProvider"; import { detectLocalOs } from "../lib/localShell"; import { logger } from "../lib/logger"; import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils"; import { Host, Snippet, TerminalSession, } from "../types"; import { resolveSnippetCommand } from "./SnippetExecutionProvider"; import { scrollTerminalToBottomAfterInputIfEnabled, shouldEnableNativeUserInputAutoScroll, } from "../domain/terminalScroll"; import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId, type TerminalHostUpdate, } from "../domain/terminalAppearance"; import { createTerminalEncodingStorageKey, isTerminalEncodingPreference, resolveInitialTerminalEncoding, shouldSyncTerminalEncodingOnAttach, terminalEncodingPreferenceToCharset, type TerminalEncodingPreference, type TerminalEncodingAttachConnection, } from "../domain/terminalEncodingPreference"; import { resolveRestoreCwdIntent, resolveInheritedCwdIntent } from "../domain/sessionRestore"; import { buildTerminalContextReadResult, buildTerminalContextSnapshotText, normalizeTerminalContextRange, resolveTerminalContextLineWindow, type TerminalContextReader, } from "../domain/terminalContextRead"; import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host"; import { shouldCollectServerStats } from "../domain/systemManager/systemTarget"; import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts"; import { CONNECTION_PROGRESS_START } from "./terminal/connectionProgress"; import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop"; import { resolveHostAuth, resolveHostAutofillPassword } from "../domain/sshAuth"; import { resolveEffectiveTerminalProtocol } from "../domain/terminalProtocol"; import { isPluginHostProtocol } from "../domain/pluginConnection"; import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch"; import { appendTerminalPromptSecurityTail, isConfirmedTerminalShellPrompt, isUntrustedTerminalInputPrompt, } from "../domain/terminalPromptSecurity"; import { listPasswordPromptFillCandidates } from "../domain/passwordPromptAssist"; import { useTerminalBackend } from "../application/state/useTerminalBackend"; import { TERMINAL_AUTO_RECONNECT_DELAY_MS, canAttemptTerminalAutoReconnect, shouldAutoReconnectAfterExit, shouldContinueAutoReconnectAfterFailure, } from "../application/state/terminalAutoReconnect"; import { useStoredBoolean } from "../application/state/useStoredBoolean"; import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString"; import { useSessionLogBackend } from "../application/state/useSessionLogBackend"; import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore"; import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore"; import { shouldPublishPluginTerminalSessionMountLifecycle, usePluginTerminalSessionLifecycle, } from "../application/state/usePluginTerminalSessionLifecycle"; import { usePluginTerminalProviders } from "../application/state/usePluginTerminalProviders"; import type { PluginTerminalDecorationRule } from "../domain/pluginTerminalProviders"; import { terminalReconnectRegistry } from "../application/state/terminalReconnectRegistry"; import { resolveSftpReuseSourceSessionId } from "../application/state/terminalConnectionReuse"; // SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer import { Button } from "./ui/button"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { toast } from "./ui/toast"; import { useAvailableFonts } from "../application/state/fontStore"; import { composeFontFamilyStack, type SupportedPlatform } from "../infrastructure/config/cjkFonts"; import { resolveTerminalFontFamilyId } from "../infrastructure/config/fonts"; import { getBuiltinTerminalThemeById } from "../infrastructure/config/terminalThemes"; import { STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN, STORAGE_KEY_TERMINAL_ENCODING_BY_HOST_PREFIX, } from "../infrastructure/config/storageKeys"; import { useCustomThemes } from "../application/state/customThemeStore"; import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog"; import { HostKeyInfo } from "./terminal/TerminalHostKeyVerification"; import { createKnownHostFromHostKeyInfo, toHostKeyInfo } from "./terminal/hostKeyVerification"; import { TerminalToolbar } from "./terminal/TerminalToolbar"; import { ScriptRecordingIndicator } from "./terminal/ScriptRecordingIndicator"; import { ScriptSaveRecordingDialog } from "./scripts/ScriptSaveRecordingDialog"; import { registerScreenSnapshotProvider } from "@/infrastructure/scripts/screenSnapshotRegistry.ts"; import { SCRIPT_RECORDING_LIMIT_EVENT, useScriptRecorder, } from "@/application/state/useScriptRecorder.ts"; import { getScriptRecordingSnapshot, setScriptRecordingState } from "@/application/state/scriptRecordingStore.ts"; import { runAutomationScript, runConnectScriptsSequential, selectScriptOverlayRun, subscribeScriptRuns, pauseScriptRun, resumeScriptRun, stopScriptRun, } from "@/application/state/scriptAutomationCoordinator.ts"; import { hasUnresolvedConnectScriptBindings, resolveConnectScriptsForHost, shouldMarkConnectAutomationConsumed, shouldUseFreshSshConnectionForAutomation, } from "@/domain/hostConnectScripts.ts"; import { isVaultInitialized } from "@/application/state/vaultInitStore.ts"; import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts"; import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts"; import { handleTerminalOscNotification } from "@/application/state/oscDesktopNotifications.ts"; import { OscNotificationStreamScanner } from "@/domain/terminalOscNotifications.ts"; import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay"; import { isScriptSnippet } from "@/domain/snippetScript.ts"; import { snippetCanRunInTerminal } from "@/domain/snippetTargets.ts"; import { useOutputTriggers } from "@/application/state/useOutputTriggers.ts"; import { TerminalComposeBar } from "./terminal/TerminalComposeBar"; import { TerminalContextMenu } from "./terminal/TerminalContextMenu"; import { TerminalSearchBar } from "./terminal/TerminalSearchBar"; import { ZmodemOverwriteDialog } from "./terminal/ZmodemOverwriteDialog"; import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator"; import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTerminalLog"; import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer"; import { createProgrammaticCommandLogRewriter, type ProgrammaticCommandLogRewrite } from "./terminal/programmaticCommandLog"; import { getSessionLogInitialLine } from "./terminal/sessionLogInitialLine"; import { getTerminalSelectionForClipboard } from "./terminal/normalizeTerminalSelection"; import { getHistoryPreviewSelectionFromRoot } from "./terminal/runtime/terminalHistoryScrollOverride"; import { createTerminalOutputHistoryPreview } from "./terminal/runtime/terminalOutputHistory"; import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer"; import { createTerminalSessionStarters, type PendingAuth, type TerminalSessionDataMeta, } from "./terminal/runtime/createTerminalSessionStarters"; import { createXTermRuntime, resetKittyKeyboardModeStateForSession, type XTermRuntime, } from "./terminal/runtime/createXTermRuntime"; import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast"; import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry"; import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry"; import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady"; import { detectPrompt } from "./terminal/autocomplete/promptDetector"; import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference"; import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions"; import { createPromptLineBreakState, markTerminalCommandCompletionPending, type PromptLineBreakState, } from "./terminal/runtime/promptLineBreak"; import { prepareSudoAutofillInput, type PasswordPromptPickerState, type SudoPasswordAutofill, } from "./terminal/runtime/terminalSudoAutofill"; import { recordTerminalCommandExecution, shouldRecordShellHistory, } from "./terminal/runtime/terminalCommandExecution"; import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus"; import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport"; import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance"; import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch"; import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions"; import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState"; import { useTerminalDragDrop } from "./terminal/hooks/useTerminalDragDrop"; import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste"; import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "../application/state/ymodemFileMemory"; import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete"; import { resolveTerminalAutocompleteSettings } from "./terminal/autocomplete/terminalAutocompleteSettings"; import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction } from "./terminal/osc7Setup"; import { getRemoteClipboardImageUploadErrorMessageKey, type RemoteClipboardImageUploadResult, } from "./terminal/clipboardImagePaste"; import { createTerminalCwdTracker, invalidateTerminalCwdAfterCommand, resolvePreferredTerminalCwd, type TerminalCwdChangeMeta, } from "./terminal/sftpCwd"; import { useTerminalEffects } from "./terminal/useTerminalEffects"; import { useTerminalHibernateEffect } from "./terminal/useTerminalHibernateEffect"; import { readActiveTerminalBufferTextRange } from "./terminal/terminalContextBuffer"; import { applyAuthoritativeHibernateSnapshot, appendHibernatePendingBuffer, isTerminalAlternateScreenActive, readTerminalHibernateContext, resolveTerminalSnapshotCapture, serializeTerminalForHibernate, } from "./terminal/terminalHibernateRuntime"; import { ackTerminalSessionFlow, clearTerminalSessionFlowAck, flushTerminalSessionFlowAck, } from "./terminal/runtime/terminalFlowAckBuffer"; import { releaseTerminalFlowBeforeHibernate, } from "./terminal/runtime/terminalSessionAttachment"; import { resetTerminalLineTimestamps } from "./terminal/runtime/terminalLineTimestamps"; import { flushPendingTerminalWritesBeforeHibernate, hasPendingTerminalWrites, runWithTerminalOutputPausedAfterWritesSettle, writeLocalTerminalDataInOrder, } from "./terminal/runtime/terminalUnfocusedRepaint"; import { canHibernateTerminalRuntimeSession, canHibernateTerminalRuntimeStatus, isTerminalFileTransferActive, resolveHibernateKeepRendererCount, resolveHibernatePreferWasmSerialize, resolveHibernateSkipAltScreen, resolveTerminalHibernateDelayMs, resolveTerminalHibernateEnabledForProtocol, resolveTerminalHibernateReplayChunkBytes, type TerminalHibernateWakePayload, } from "../domain/terminalHibernate"; import { terminalHiddenRendererStore } from "../application/state/terminalHiddenRendererStore"; import { wakeTerminalFromHibernate, type TerminalRuntimeRefs, } from "./terminal/terminalRuntimeMount"; import type { CreateXTermRuntimeContext } from "./terminal/runtime/createXTermRuntime"; import { TerminalView } from "./terminal/TerminalView"; import { cancelConnectAutomationBatch, createConnectAutomationBatch, trackConnectAutomationStop, type ConnectAutomationBatch, } from "./terminal/connectAutomationBatch"; import { getInitialTerminalStatus, resolveTerminalVaultInitialized, shouldResetConnectAutomationOnReconnect, shouldSuppressHostStartupCommandOnReconnect, shouldStartTerminalBackend, } from "./terminal/restoredSessionGate"; import { alignTerminalViewportScroll, createSynchronizedOutputFitScheduler, AUTO_RUN_SNIPPET_LINE_DELAY_MS, forceSyncRenderAfterResize, MAX_CONNECTION_LOG_DATA_CHARS, shouldDelayAutoRunSnippetInput, shouldHideConnectingDialogForConnectionReuse, shouldShowTerminalDisconnectedNotice, shouldShowTerminalConnectionDialog, type TerminalProps, } from "./terminal/terminalHelpers"; import { terminalPropsAreEqual } from "./terminal/terminalMemo"; const HIBERNATE_RETRY_AFTER_DRAIN_MS = 250; const EMPTY_CHAIN_HOSTS: Host[] = []; const TerminalComponent: React.FC = ({ host, keys, identities, snippets, snippetPackages = [], compactToolbar = false, onDeleteSnippets, lineTimestampsAvailable = true, chainHosts = EMPTY_CHAIN_HOSTS, appearanceTheme, knownHosts = [], isVisible, paneLayoutKey, inWorkspace, isResizing, isFocusMode, isPaneMagnified, isFocused, isFocusedPane, fontFamilyId, fontSize, terminalTheme, followAppTerminalTheme = false, terminalSettings, sessionId, workspaceId, restoreState, vaultInitializedOverride, pendingInitialCwd, shellType, lastCwd, restoreTerminalCwd = false, startupCommand, noAutoRun, multiLineRunMode, pendingScriptId, pendingScript, reuseConnectionFromSessionId, requireFreshConnection = false, attachExistingSession = false, attachAuthorization, onAttachClosePreparationChange, serialConfig, hotkeyScheme = "disabled", disableTerminalFontZoom = false, keyBindings = [], onHotkeyAction, onTerminalFontSizeChange, onStatusChange, onSessionExit, onTerminalDataCapture, onOsDetected, onCloseSession, onUpdateHost, onAddKnownHost, onExpandToFocus, onTogglePaneMagnification, onCommandExecuted, onCommandSubmitted, onSplitHorizontal, onSplitVertical, onOpenSftp, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalOutput, onTerminalContextReaderChange, onOpenScripts, onOpenHistory, onOpenTheme, onOpenSystem, isBroadcastEnabled, onToggleBroadcast, onToggleComposeBar, isWorkspaceComposeBarOpen, onBroadcastInput, onBroadcastInterruptPriorityChange, onSnippetExecutorChange, onProgrammaticCommandLogRewriteChange, sessionLog, sshDebugLogEnabled, sudoAutofillPassword, sudoAutofillCandidates, showSelectionAIAction = true, onAddSelectionToAI, sessionDisplayName, onRename, onDetach, onStartSessionDrag, onEndSessionDrag, onDetachPointerDown, onDetachDragStart, onDetachDragEnd, }) => { const layoutSuppressActive = useTerminalLayoutSuppressActive(); const deferTerminalResize = isResizing || layoutSuppressActive; const deferTerminalResizeRef = useRef(deferTerminalResize); deferTerminalResizeRef.current = deferTerminalResize; // Initial TCP dial timeout. Authentication prompts use their own backend timeout. const hostConnectionTimeouts = resolveHostSshConnectionTimeouts(host); const CONNECTION_TIMEOUT = hostConnectionTimeouts.tcpConnectTimeoutSeconds * 1000; const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host); const isPluginConnection = isPluginHostProtocol(effectiveTerminalProtocol); const { t } = useI18n(); const openExternalErrorTextRef = useRef({ body: t("settings.application.openExternal.failedBody"), title: t("settings.application.openExternal.failedTitle"), }); openExternalErrorTextRef.current = { body: t("settings.application.openExternal.failedBody"), title: t("settings.application.openExternal.failedTitle"), }; const onOpenExternalError = useCallback(() => { const message = openExternalErrorTextRef.current; toast.error(message.body, message.title); }, []); // Reactive vault-ready flag so restored panes re-arm boot after hydration // (module isVaultInitialized() alone is not a React dependency). const sharedVaultInitialized = useVaultSnapshotField("isVaultInitialized"); const vaultInitialized = resolveTerminalVaultInitialized( sharedVaultInitialized, vaultInitializedOverride, ); const connectScriptsConsumedRef = useRef(false); const connectScriptsCompletedIdsRef = useRef(new Set()); const connectScriptsInFlightRef = useRef(false); const connectScriptsBatchRef = useRef(null); const pendingScriptRunIdRef = useRef(null); const pendingScriptHandledRef = useRef(null); const pendingScriptRef = useRef(pendingScript); const pendingScriptIdRef = useRef(pendingScriptId); pendingScriptRef.current = pendingScript; pendingScriptIdRef.current = pendingScriptId; const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => { if (snippet.id) { return pendingScriptRunIdRef.current === snippet.id; } return pendingScriptHandledRef.current === snippet; }, []); // Mosh marks status=connected during the SSH handshake so interactive // prompts remain reachable. Connect/pending scripts must wait until // mosh-client is ready (#2199). closeSession clears preload ready // listeners for this session id — resubscribe synchronously before each // startMosh (not only in a useEffect, which can lose a race with reconnect). const [moshShellReady, setMoshShellReady] = useState(() => effectiveTerminalProtocol !== 'mosh'); const disposeMoshReadyRef = useRef<(() => void) | null>(null); const [saveRecordingOpen, setSaveRecordingOpen] = useState(false); const [recordedCode, setRecordedCode] = useState(''); const recorder = useScriptRecorder(sessionId); const recorderRef = useRef(recorder); recorderRef.current = recorder; const passwordPromptActiveRef = useRef(false); const terminalTitleRef = useRef(undefined); const pluginTerminalLifecycleRef = useRef | null>(null); useEffect(() => registerTerminalSensitiveInputReader( sessionId, () => passwordPromptActiveRef.current, ), [sessionId]); const sensitivePromptOutputTailRef = useRef(""); const [activeScriptRun, setActiveScriptRun] = useState(undefined); const dismissedScriptRunIdsRef = useRef(new Set()); useEffect(() => { return subscribeScriptRuns((runs) => { setActiveScriptRun(selectScriptOverlayRun(runs, sessionId, dismissedScriptRunIdsRef.current)); }); }, [sessionId]); const dismissScriptOverlay = useCallback(() => { if (activeScriptRun) { dismissedScriptRunIdsRef.current.add(activeScriptRun.runId); } setActiveScriptRun(undefined); }, [activeScriptRun]); const scriptSessionName = sessionDisplayName || host.label; const outputTriggers = useOutputTriggers({ sessionId, host, snippets, onRunScript: (snippet, sid) => runAutomationScript({ snippet, sessionId: sid, sessionMeta: { connected: true, name: scriptSessionName, hostname: host.hostname, username: host.username, }, }).then(() => undefined).catch((err) => { const message = err instanceof Error ? err.message : String(err); toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); throw err; }), }); const appendOutputTriggerOutputRef = useRef(outputTriggers.appendOutput); appendOutputTriggerOutputRef.current = outputTriggers.appendOutput; const noteOutputTriggerUserInputRef = useRef(outputTriggers.noteUserInput); noteOutputTriggerUserInputRef.current = outputTriggers.noteUserInput; const availableFonts = useAvailableFonts(); const containerRef = useRef(null); const termRef = useRef(null); const fitAddonRef = useRef(null); const serializeAddonRef = useRef(null); const searchAddonRef = useRef(null); const xtermRuntimeRef = useRef(null); const terminalCwdTracker = useMemo(() => createTerminalCwdTracker(), []); const knownCwdRef = useRef(undefined); const disposeDataRef = useRef<(() => void) | null>(null); const disposeExitRef = useRef<(() => void) | null>(null); const disposeTelnetEchoModeRef = useRef<(() => void) | null>(null); const hibernatedRef = useRef(false); const softHiddenRef = useRef(false); const hasRuntimeRef = useRef(false); const hibernateSnapshotRef = useRef(""); const hibernateViewportSnapshotRef = useRef(""); const hibernateScrollbackSnapshotRef = useRef(""); const hibernateContextSnapshotRef = useRef(""); const hibernateContextViewportSnapshotRef = useRef(""); const hibernateContextScrollbackSnapshotRef = useRef(""); const hibernatePendingBufferRef = useRef(""); const hibernatePendingCapDisabledRef = useRef(false); const hibernateAlternateScreenRef = useRef(false); const hibernateRetryTimerRef = useRef | null>(null); const fullHibernateRuntimeRef = useRef<(() => Promise) | null>(null); const wakeSoftHiddenRuntimeRef = useRef<(() => void) | null>(null); const resumeRendererAfterCancelledHibernateUpgradeRef = useRef<(() => void) | null>(null); const wakeInProgressRef = useRef(false); const wakePromiseRef = useRef | null>(null); const sessionRef = useRef(null); const sessionCleanupPromiseRef = useRef | null>(null); /** Epoch owned by an in-flight disconnect/teardown close (before bump). */ const pendingCloseBootEpochRef = useRef(undefined); const trackSessionCleanup = (promise: Promise) => { const settled = Promise.resolve(promise).then( () => undefined, () => undefined, ); const previous = sessionCleanupPromiseRef.current; const next = previous ? Promise.all([previous, settled]).then(() => undefined) : settled; sessionCleanupPromiseRef.current = next; void next.finally(() => { if (sessionCleanupPromiseRef.current === next) { sessionCleanupPromiseRef.current = null; } }); }; const isBootActiveRef = useRef(false); const bootEpochRef = useRef(0); const publishBootEpoch = () => { setTerminalBootEpoch(sessionId, bootEpochRef.current); }; const bumpBootEpoch = () => { bootEpochRef.current += 1; publishBootEpoch(); }; /** Invalidate the current boot and remember its epoch for the matching closeSession. */ const invalidateBootEpochForClose = () => { const closingEpoch = bootEpochRef.current; bumpBootEpoch(); pendingCloseBootEpochRef.current = closingEpoch; return closingEpoch; }; const resolveCloseBootEpoch = () => { if (pendingCloseBootEpochRef.current !== undefined) { const epoch = pendingCloseBootEpochRef.current; pendingCloseBootEpochRef.current = undefined; return epoch; } return bootEpochRef.current; }; const hasConnectedRef = useRef(false); const hasRunStartupCommandRef = useRef(false); const restoreCwdIntentRef = useRef<{ cwd: string; command: string } | null>(null); const suppressHostStartupCommandRef = useRef(false); // Token for an in-flight retry chain. handleRetry sets this to a fresh // symbol; any cancel/close/teardown/subsequent-retry invalidates it. The // chained xterm.write callbacks verify the token before proceeding so a // cancelled retry can't fire a startNewSession after the fact. const retryTokenRef = useRef(null); const reconnectPreparationTokenRef = useRef(null); const autoReconnectTimerRef = useRef | null>(null); const autoReconnectLoopActiveRef = useRef(false); const [manualReconnectActive, setManualReconnectActive] = useState(false); const autoReconnectAttemptRef = useRef(0); const startReconnectRef = useRef<((mode: "manual" | "auto") => void) | null>(null); const wakeHibernatedRuntimeForReconnectRef = useRef<(() => Promise) | null>(null); /** Connected wake for multi-tab snippet fan-out (reattaches session listeners). */ const wakeHibernatedRuntimeForConnectedRef = useRef<(() => Promise) | null>(null); const reconnectWakeInFlightRef = useRef(false); // When a hibernated reconnect wake is invalidated: 'dispose' for unmount/ // session teardown (orphan runtime must go), 'keep' for Disconnect so the // pane retains an xterm instance and can reconnect without being remounted. const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose"); const reconnectWakeTokenRef = useRef(null); const manualReconnectRequestRef = useRef<() => void>(() => {}); // Once a pane reconnects, every later SSH attempt in it must dial a fresh // connection: reusing a live/idle pooled transport multiplexes onto the old // login, so remote supplementary-group changes (e.g. `usermod -aG`) are not // picked up until the app fully quits (#3293). See createTerminalSessionStarters. const requireFreshConnectionOnReconnectRef = useRef(false); const terminalDataCapturedRef = useRef(false); const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS)); const terminalOutputHistoryRef = useRef(createTerminalOutputHistoryPreview()); const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer()); const commandLogRewriterRef = useRef(createProgrammaticCommandLogRewriter()); const onTerminalDataCaptureRef = useRef(onTerminalDataCapture); const onSessionExitRef = useRef(onSessionExit); const commandBufferRef = useRef(""); useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => { const term = termRef.current; if (!term) return false; const prompt = detectPrompt(term); return isIdleShellReadyForCommandInjection({ sensitiveInputActive: passwordPromptActiveRef.current, hasLiveTerminal: true, alternateScreenActive: isTerminalAlternateScreenActive(term), isAtPrompt: prompt.isAtPrompt, userInputLength: prompt.userInput.length, pendingTypedInputLength: commandBufferRef.current.length, }); }), [sessionId]); const promptLineBreakStateRef = useRef(createPromptLineBreakState()); const [hasMouseTracking, setHasMouseTracking] = useState(false); const mouseTrackingRef = useRef(false); const serialLineBufferRef = useRef(""); const telnetLocalEchoRef = useRef(false); const pluginTerminalSessionExitRef = useRef<(exitCode?: number) => void>(() => {}); useLayoutEffect(() => { onSessionExitRef.current = onSessionExit; }, [onSessionExit]); useEffect(() => () => { reconnectWakeInvalidateModeRef.current = "dispose"; reconnectWakeTokenRef.current = null; }, [sessionId]); const terminalSettingsRef = useRef(terminalSettings); terminalSettingsRef.current = terminalSettings; const hostRef = useRef(host); hostRef.current = host; // The protocol capability is connection-scoped. A settings change applies // to newly mounted terminals so an already-negotiated remote never sees the // parser and encoder disagree or disappear during hibernation/reconnect. const kittyKeyboardProtocolEnabledForSessionRef = useRef( terminalSettings?.kittyKeyboardProtocolEnabled === true, ); const kittyKeyboardProtocolEnabledForSession = kittyKeyboardProtocolEnabledForSessionRef.current; const isSearchOpenRef = useRef(false); const hibernateFileTransferActiveRef = useRef(false); const handleUpdateHostFromTerminal = useCallback((hostUpdate: TerminalHostUpdate) => { onUpdateHost?.(hostUpdate as Host); }, [onUpdateHost]); onTerminalDataCaptureRef.current = onTerminalDataCapture; const isVisibleRef = useRef(isVisible); isVisibleRef.current = isVisible; const isFocusedRef = useRef(!!isFocused); isFocusedRef.current = !!isFocused; const oscNotificationScannerRef = useRef(new OscNotificationStreamScanner()); const hibernateEnabled = resolveTerminalHibernateEnabledForProtocol(terminalSettings, effectiveTerminalProtocol) && !kittyKeyboardProtocolEnabledForSession && !isBroadcastEnabled; const hibernateEnabledRef = useRef(hibernateEnabled); hibernateEnabledRef.current = hibernateEnabled; const isRendererActive = isVisible || !hibernateEnabled; const isRendererActiveRef = useRef(isRendererActive); isRendererActiveRef.current = isRendererActive; const pendingOutputScrollRef = useRef(false); const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null); const fontWeightFixupDoneRef = useRef(false); const captureTerminalLogData = useCallback((data: string) => { // Keep the history tracker's viewport size current so cursor-row and // cursor-column moves are clamped exactly like the terminal clamps them. const captureTerm = termRef.current; if (captureTerm) { terminalOutputHistoryRef.current.setViewportRows(captureTerm.rows); terminalOutputHistoryRef.current.setViewportCols(captureTerm.cols); // The tracker's wrap decisions must measure cell widths the way this // terminal's Unicode provider does, or the preview diverges from what // xterm actually rendered (e.g. `15-graphemes` emoji widths). terminalOutputHistoryRef.current.setWidthTerminal(captureTerm); } const readableCommandData = commandLogRewriterRef.current.append(data); // The alternate-screen preview reads the raw display stream (before the // replay sanitizer, which drops alternate-screen output on purpose) but // after the command rewriter, so masked commands stay masked (#2516). terminalOutputHistoryRef.current.append(readableCommandData); const replaySafeData = terminalLogSanitizerRef.current.append(readableCommandData); if (!replaySafeData) return; connectionLogBufferRef.current.append(replaySafeData); }, []); const finalizeTerminalLogData = useCallback(() => { const readableCommandData = commandLogRewriterRef.current.finish(); if (readableCommandData) { const replaySafeData = terminalLogSanitizerRef.current.append(readableCommandData); if (replaySafeData) { connectionLogBufferRef.current.append(replaySafeData); } } const replaySafeData = terminalLogSanitizerRef.current.finish(); if (replaySafeData) { connectionLogBufferRef.current.append(replaySafeData); } return connectionLogBufferRef.current.toString(); }, []); const readTerminalContext = useCallback(async (request) => { if (request.sessionId !== sessionId) { return { ok: false, error: `Terminal context reader is registered for "${sessionId}", not "${request.sessionId}".` }; } const range = normalizeTerminalContextRange(request.range); let term = termRef.current; let liveContextReady = !term; for (let attempt = 0; term && attempt < 2; attempt += 1) { const targetTerm = term; const flushed = await flushPendingTerminalWritesBeforeHibernate(targetTerm); term = termRef.current; if (term !== targetTerm) continue; if (!flushed) { return { ok: false, error: "Terminal output is still draining; retry the context read." }; } liveContextReady = true; break; } if (term && !liveContextReady) { return { ok: false, error: "Terminal changed while reading its context; retry the request." }; } if (term) { const alternateScreen = isTerminalAlternateScreenActive(term); const activeBuffer = term.buffer.active as typeof term.buffer.active & { viewportY?: number; }; const totalLines = Math.max(0, activeBuffer.length); const viewportStartLine = alternateScreen ? 0 : Math.max(0, activeBuffer.viewportY ?? Math.max(0, totalLines - term.rows)); const viewportEndLine = totalLines > 0 ? Math.min(totalLines - 1, viewportStartLine + Math.max(1, term.rows) - 1) : -1; const lineWindow = resolveTerminalContextLineWindow({ range, totalLines, viewportStartLine, viewportEndLine, startLine: request.startLine, maxLines: request.maxLines, }); let content = ''; if (lineWindow.endLine >= lineWindow.startLine) { content = readActiveTerminalBufferTextRange(term, { startLine: lineWindow.startLine, endLine: lineWindow.endLine, }); } return { ok: true, sessionId, label: sessionDisplayName ?? host.label, range, content, totalLines, startLine: lineWindow.startLine, endLine: lineWindow.endLine, returnedLines: lineWindow.endLine >= lineWindow.startLine ? lineWindow.endLine - lineWindow.startLine + 1 : 0, hasMoreBefore: lineWindow.startLine > 0, hasMoreAfter: lineWindow.endLine >= 0 && lineWindow.endLine < totalLines - 1, source: 'live', alternateScreen, }; } const snapshot = buildTerminalContextSnapshotText({ scrollbackText: hibernateContextScrollbackSnapshotRef.current, viewportText: hibernateContextViewportSnapshotRef.current, pendingText: hibernatePendingBufferRef.current, }); const retainedText = hibernateContextSnapshotRef.current; const fullText = retainedText ? [retainedText, hibernatePendingBufferRef.current].filter(Boolean).join("\n") : snapshot.fullText; const viewportEndLine = hibernatePendingBufferRef.current && fullText ? fullText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").length - 1 : snapshot.viewportEndLine; if (!fullText) { return { ok: false, error: `Terminal session "${sessionId}" has no readable terminal buffer yet.` }; } return buildTerminalContextReadResult({ sessionId, label: sessionDisplayName ?? host.label, fullText, range, startLine: request.startLine, maxLines: request.maxLines, source: 'snapshot', alternateScreen: hibernateAlternateScreenRef.current, viewportStartLine: snapshot.viewportStartLine, viewportEndLine, }); }, [host.label, sessionDisplayName, sessionId]); useEffect(() => { onTerminalContextReaderChange?.(sessionId, readTerminalContext); return () => onTerminalContextReaderChange?.(sessionId, null); }, [onTerminalContextReaderChange, readTerminalContext, sessionId]); useEffect(() => { commandLogRewriterRef.current = createProgrammaticCommandLogRewriter(); }, [sessionId]); const queueProgrammaticCommandLogRewrite = useCallback((rewrite: ProgrammaticCommandLogRewrite) => { commandLogRewriterRef.current.queueRewrite(rewrite); }, []); useEffect(() => { onProgrammaticCommandLogRewriteChange?.(sessionId, queueProgrammaticCommandLogRewrite); return () => onProgrammaticCommandLogRewriteChange?.(sessionId, null); }, [onProgrammaticCommandLogRewriteChange, queueProgrammaticCommandLogRewrite, sessionId]); const writeLocalTerminalData = useCallback((data: string) => { const term = termRef.current; if (!term) return; writeLocalTerminalDataInOrder(term, data, captureTerminalLogData); }, [captureTerminalLogData]); const hotkeySchemeRef = useRef(hotkeyScheme); const disableTerminalFontZoomRef = useRef(disableTerminalFontZoom); const keyBindingsRef = useRef(keyBindings); const onHotkeyActionRef = useRef(onHotkeyAction); hotkeySchemeRef.current = hotkeyScheme; disableTerminalFontZoomRef.current = disableTerminalFontZoom; keyBindingsRef.current = keyBindings; onHotkeyActionRef.current = onHotkeyAction; const isBroadcastEnabledRef = useRef(isBroadcastEnabled); const onBroadcastInputRef = useRef(onBroadcastInput); isBroadcastEnabledRef.current = isBroadcastEnabled; onBroadcastInputRef.current = onBroadcastInput; // Snippets ref for shortkey support in terminal const snippetsRef = useRef(snippets); snippetsRef.current = snippets; // Autocomplete handler refs — populated by so the // xterm runtime (and a few effects here) can drive the hook without making // Terminal re-render on every suggestion update. const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined); const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined); const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined); const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined); const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined); const terminalBackend = useTerminalBackend(); const { chooseManualSessionLogPath, startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus, } = useSessionLogBackend(); const { resizeSession, receiveSerialYmodem, selectDirectory, selectDirectoryAvailable, selectFile, selectFileAvailable, sendSerialYmodem, serialYmodemAvailable, serialYmodemReceiveAvailable, setSessionEncoding, } = terminalBackend; // isScriptsOpen state removed - scripts now handled by side panel const [status, setStatus] = useState(() => ( getInitialTerminalStatus() )); const hasEverConnectedRef = useRef(status === "connected"); const [error, setError] = useState(null); const lastToastedErrorRef = useRef(null); const [showLogs, setShowLogs] = useState(false); const [progressLogs, setProgressLogs] = useState([]); const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000); const [isCancelling, setIsCancelling] = useState(false); const [showSFTP, setShowSFTP] = useState(false); const [isSessionLogging, setIsSessionLogging] = useState(false); const [progressValue, setProgressValue] = useState(CONNECTION_PROGRESS_START); const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false); const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false); const [connectionReuseAttemptSourceId, setConnectionReuseAttemptSourceId] = useState( reuseConnectionFromSessionId, ); const statusRef = useRef(status); statusRef.current = status; const getSessionConnectedRef = useRef(() => statusRef.current === "connected" && Boolean(sessionRef.current)); getSessionConnectedRef.current = () => statusRef.current === "connected" && Boolean(sessionRef.current); const sudoAutofillRef = useRef(null); // Prefer parent-supplied candidates (TerminalLayer); otherwise derive from // host/keys/identities so standalone popups (TerminalPopupPage) still work. const resolvedSudoAutofillCandidates = useMemo( () => isPluginConnection ? [] : sudoAutofillCandidates ?? listPasswordPromptFillCandidates({ host, keys, identities }), [sudoAutofillCandidates, host, keys, identities, isPluginConnection], ); const resolvedSudoAutofillPassword = useMemo( () => isPluginConnection ? undefined : sudoAutofillPassword ?? resolveHostAutofillPassword({ host, keys, identities }), [sudoAutofillPassword, host, keys, identities, isPluginConnection], ); const sudoAutofillPasswordRef = useRef(resolvedSudoAutofillPassword); sudoAutofillPasswordRef.current = resolvedSudoAutofillPassword; const resolvedLoginUsername = useMemo( () => resolveHostAuth({ host, keys, identities }).username, [host, keys, identities], ); const sudoAutofillCandidatesRef = useRef(resolvedSudoAutofillCandidates); sudoAutofillCandidatesRef.current = resolvedSudoAutofillCandidates; const [passwordPickerState, setPasswordPickerState] = useState(null); const passwordPickerRef = useRef< ((active: boolean, state: PasswordPromptPickerState | null) => boolean) | undefined >(undefined); passwordPickerRef.current = (active, state) => { setPasswordPickerState(active && state ? state : null); return true; }; const [chainProgress, setChainProgress] = useState<{ currentHop: number; totalHops: number; currentHostLabel: string; connectionPhase: string; } | null>(null); const [isConnectionAwaitingUserInput, setIsConnectionAwaitingUserInput] = useState(false); const [isConnectionPastTcpDial, setIsConnectionPastTcpDial] = useState(false); const [reconnectNoticeMessage, setReconnectNoticeMessage] = useState(null); // pendingUploadEntries removed - drag-drop uploads now handled by SftpSidePanel const [isComposeBarOpen, setIsComposeBarOpen] = useStoredBoolean( STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN, false, ); const terminalEncodingStorageKey = createTerminalEncodingStorageKey( STORAGE_KEY_TERMINAL_ENCODING_BY_HOST_PREFIX, host, ); const initialRememberedTerminalEncoding = readOptionalStoredStringValue( terminalEncodingStorageKey, isTerminalEncodingPreference, ); const [, setRememberedTerminalEncoding] = useStoredString( terminalEncodingStorageKey, 'utf-8', isTerminalEncodingPreference, ); const [terminalEncoding, setTerminalEncoding] = useState(() => { return resolveInitialTerminalEncoding( host?.charset, initialRememberedTerminalEncoding, ); }); const terminalEncodingRef = useRef(terminalEncoding); terminalEncodingRef.current = terminalEncoding; const hasRememberedTerminalEncodingRef = useRef(initialRememberedTerminalEncoding !== null); // True only after the user actively picks an encoding from the toolbar. // onSessionAttached uses this to decide whether to override the backend's // initial charset for telnet/serial reconnects — on a first attach we // must not overwrite arbitrary host.charset values (latin1/shift_jis/...) // that the UI's two-value state can't represent. const userPickedEncodingRef = useRef(false); const terminalSearch = useTerminalSearch({ searchAddonRef, termRef }); const { isSearchOpen, searchMatchCount, searchFocusToken, requestSearchFocus, handleToggleSearch, handleSearch, handleFindNext, handleFindPrevious, handleCloseSearch, } = terminalSearch; isSearchOpenRef.current = isSearchOpen; const prepareProgrammaticSudoInput = useCallback((data: string): string => { if ( statusRef.current !== "connected" || (isBroadcastEnabledRef.current && onBroadcastInputRef.current) ) { return data; } const pastedCommand = data.match(/^([^\r\n]+)(\r\n|\r|\n)$/); if (!pastedCommand || !shouldRecordShellHistory(pastedCommand[1], termRef.current)) { return data; } prepareSudoAutofillInput(data, null, sudoAutofillRef.current); return data; }, []); // Terminal autocomplete — onAcceptText writes directly to session (no CustomEvent) const autocompleteAcceptTextRef = useRef<((text: string) => void) | undefined>(undefined); autocompleteAcceptTextRef.current = (text: string) => { const id = sessionRef.current; if (id && text) { const sensitive = passwordPromptActiveRef.current; let textToWrite = text; let handledSubmittedInput = false; if ( host.protocol !== "serial" && statusRef.current === "connected" && !(isBroadcastEnabledRef.current && onBroadcastInputRef.current) ) { const preparedText = prepareProgrammaticSudoInput(text); handledSubmittedInput = preparedText !== text; textToWrite = preparedText; } // Serial line mode: buffer text and handle local echo instead of direct send if (host.protocol === "serial" && serialConfig?.lineMode) { for (const ch of text) { if (ch === "\r") { const line = serialLineBufferRef.current + "\r"; terminalBackend.writeToSession(id, line, { sensitive }); serialLineBufferRef.current = ""; if (serialConfig?.localEcho) writeLocalTerminalData("\r\n"); } else if (ch === "\x15") { if (serialConfig?.localEcho && serialLineBufferRef.current.length > 0) { writeLocalTerminalData("\b \b".repeat(serialLineBufferRef.current.length)); } serialLineBufferRef.current = ""; } else if (ch === "\b" || ch === "\x7f") { if (serialLineBufferRef.current.length > 0) { serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1); if (serialConfig?.localEcho) writeLocalTerminalData("\b \b"); } } else if (ch.charCodeAt(0) >= 32) { serialLineBufferRef.current += ch; if (serialConfig?.localEcho) writeLocalTerminalData(ch); } } // Still update commandBuffer and broadcast for serial line mode // (fall through to shared bookkeeping below — don't return early) } else if (host.protocol === "serial" && serialConfig?.localEcho) { // Serial character mode with local echo: echo accepted text locally terminalBackend.writeToSession(id, textToWrite, { sensitive }); for (const ch of text) { if (ch === "\r") { writeLocalTerminalData("\r\n"); } else if (ch.charCodeAt(0) >= 32) { writeLocalTerminalData(ch); } } } else { terminalBackend.writeToSession(id, textToWrite, { sensitive }); } // Broadcast to other sessions if broadcast mode is enabled if (!sensitive && isBroadcastEnabledRef.current && onBroadcastInputRef.current) { onBroadcastInputRef.current(text, sessionId); } // ESC-prefixed writes (Esc+. yank-last-arg) are shell editor commands. // Walking printable bytes would append "." and leave history/completions // tracking a stale line. if (text.startsWith("\x1b")) { return; } // Update command buffer for onCommandExecuted tracking for (const ch of text) { if (handledSubmittedInput) { commandBufferRef.current = ""; break; } else if (ch === "\r" || ch === "\n") { const rawCommand = commandBufferRef.current; const sensitive = passwordPromptActiveRef.current; if (recorderRef.current.isRecording) { void recorderRef.current.recordEnter({ sensitive, }); } passwordPromptActiveRef.current = false; recordTerminalCommandExecution(rawCommand, { host, sessionId, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onTrustedCommandSubmitted: pluginAwareOnCommandSubmitted, commandBufferRef, promptLineBreakStateRef, }, termRef.current, { sensitive, allowHostStyleGreaterThanPrompt: isNetworkDevice }); } else if (ch === "\x15") { // Ctrl+U: clear line — reset command buffer (fuzzy match sends this) commandBufferRef.current = ""; recorderRef.current.recordClearLine(); } else if (ch === "\b" || ch === "\x7f") { // Backspace: remove last character (Windows fuzzy replacement uses \b) commandBufferRef.current = commandBufferRef.current.slice(0, -1); recorderRef.current.recordBackspace(); } else if (ch.charCodeAt(0) >= 32) { commandBufferRef.current += ch; recorderRef.current.recordInput(ch); } } } }; // Autocomplete config — the hook itself lives in so // its state updates don't re-render this component (see render below). // For local protocol the effective OS is the client OS: synthetic fallback // hosts (TerminalLayer) and saved-host defaults (HostDetailsPanel) both // stamp os: "linux", which mis-routes the autocomplete clear sequence to // Ctrl-U on Windows where cmd/PowerShell render it literally (#1112). const resolvedAutocompleteOs = host.protocol === "local" ? detectLocalOs(navigator.userAgent || navigator.platform) : resolveHostOs(host); const autocompleteHostOs = resolvedAutocompleteOs === "windows" || resolvedAutocompleteOs === "macos" ? resolvedAutocompleteOs : "linux"; const autocompleteSettings = resolveTerminalAutocompleteSettings({ protocol: effectiveTerminalProtocol, terminalSettings, systemUnknown: resolvedAutocompleteOs === 'unknown', isNetworkDevice: host.deviceType === 'network' || classifyDistroId(host.distro) === 'network-device', }); const resolveSftpInitialPath = useCallback(async (options?: { preferFreshBackend?: boolean; allowRendererFallback?: boolean; requireActiveShellCwd?: boolean; }): Promise => { const cwd = await resolvePreferredTerminalCwd({ rendererCwd: terminalCwdTracker.getRendererCwd(), rendererCwdSource: terminalCwdTracker.getRendererCwdSource(), sessionId: sessionRef.current, getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options), preferFreshBackend: options?.preferFreshBackend, allowRendererFallback: options?.allowRendererFallback, requireActiveShellCwd: options?.requireActiveShellCwd, }); return cwd ?? undefined; }, [terminalBackend, terminalCwdTracker]); const clearTerminalCwd = useCallback((options?: { persistRestoreMetadata?: boolean }) => { terminalCwdTracker.clearRendererCwd(); knownCwdRef.current = undefined; if (options?.persistRestoreMetadata === false) return; onTerminalCwdChange?.(sessionId, null); }, [onTerminalCwdChange, sessionId, terminalCwdTracker]); // Classify the host's device family from the *detected* distro and the // explicit deviceType only. This intentionally bypasses // getEffectiveHostDistro(): the manual distro override (`distroMode: // 'manual'` + `manualDistro`) is a purely cosmetic icon choice, and a // user who pinned e.g. an "ubuntu" icon on what is actually a Cisco / // Huawei host must not silently re-enable POSIX-shell probes against it. // Several features gate on this — the working-directory probe below, the // /etc/os-release probe, and the periodic server-stats poll (#674) — // because each opens an extra exec channel that strict network-device // CLIs reject or log as a new AAA session, and on Huawei VRP closes the // whole session (#1043). const detectedDeviceClass = classifyDistroId(host.distro); const isNetworkDevice = host.deviceType === 'network' || detectedDeviceClass === 'network-device'; const remoteDragDropUsesZmodem = supportsZmodemTerminalDragDrop(host, isNetworkDevice); // Check if this is a local or serial connection (doesn't need connection dialog during connecting) const isLocalConnection = host.protocol === "local"; const isSerialConnection = host.protocol === "serial"; const supportsRemoteImagePaste = !isLocalConnection && !isSerialConnection && !isPluginConnection && host.protocol !== "telnet" && host.protocol !== "mosh" && !host.moshEnabled && host.protocol !== "et" && !host.etEnabled; // Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for // network devices. See isNetworkDevice above for why the gating uses the // raw detected distro / explicit deviceType (not getEffectiveHostDistro); // #674 covers the AAA-log-flood motivation for stats specifically. const isSupportedOs = shouldCollectServerStats(host, undefined, null); const isSystemSidebarEligible = !!onOpenSystem && isSupportedOs && !isLocalConnection && !isSerialConnection && !isPluginConnection && host.protocol !== 'telnet'; // Server-stats polling now lives inside (rendered by // TerminalView) so its ~5s refresh only re-renders that widget, not the whole // terminal. We just forward `isSupportedOs` via ctx. const zmodem = useZmodemTransfer(sessionId); const [ymodemInProgress, setYmodemInProgress] = useState(false); const zmodemToastedRef = useRef(false); const pendingAuthRef = useRef(null); useEffect(() => { sudoAutofillRef.current?.updatePassword(resolvedSudoAutofillPassword); }, [resolvedSudoAutofillPassword]); useEffect(() => { sudoAutofillRef.current?.updateCandidates(resolvedSudoAutofillCandidates); }, [resolvedSudoAutofillCandidates]); useEffect(() => { const mode = terminalSettings?.passwordPromptAssist ?? "hint"; sudoAutofillRef.current?.updateMode(mode); }, [terminalSettings?.passwordPromptAssist]); // Drop a stale picker if the session disconnects/reconnects — exit teardown // nulls sudoAutofillRef without calling onPicker(false). useEffect(() => { if (status === "disconnected" || status === "connecting") { setPasswordPickerState(null); } }, [status]); const handlePasswordPickerSelect = useCallback((id: string) => { sudoAutofillRef.current?.confirmFill(id); }, []); const passwordPickerTitle = t("terminal.passwordPicker.title"); const passwordPickerEmptyText = t("terminal.passwordPicker.empty"); const sudoHintText = t("terminal.sudoHint.pressEnter"); const sessionStartersRef = useRef | null>(null); const reuseConnectionSourceRef = useRef(reuseConnectionFromSessionId); const reuseConnectionSourceAttemptedRef = useRef(false); const previousReuseConnectionSourcePropRef = useRef(reuseConnectionFromSessionId); if (previousReuseConnectionSourcePropRef.current !== reuseConnectionFromSessionId) { previousReuseConnectionSourcePropRef.current = reuseConnectionFromSessionId; reuseConnectionSourceRef.current = reuseConnectionFromSessionId; reuseConnectionSourceAttemptedRef.current = false; } const auth = useTerminalAuthState({ host, pendingAuthRef, termRef, onUpdateHost: handleUpdateHostFromTerminal, onStartSession: (term) => { const starters = sessionStartersRef.current; if (!starters) return; if (effectiveTerminalProtocol.startsWith('plugin:')) { starters.startPluginConnection(term); return; } if (effectiveTerminalProtocol === 'mosh') { starters.startMosh(term); return; } if (effectiveTerminalProtocol === 'et') { starters.startEt(term); return; } starters.startSSH(term); }, setStatus: (next) => setStatus(next), setProgressLogs, }); const [needsHostKeyVerification, setNeedsHostKeyVerification] = useState(false); const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState(null); const [pendingHostKeyRequestId, setPendingHostKeyRequestId] = useState(null); const pendingConnectionRef = useRef<(() => void) | null>(null); // OSC-52 clipboard read prompt const [osc52ReadPromptVisible, setOsc52ReadPromptVisible] = useState(false); const osc52ReadResolverRef = useRef<((allowed: boolean) => void) | null>(null); const [osc7SetupOpen, setOsc7SetupOpen] = useState(false); const [osc7SetupRunning, setOsc7SetupRunning] = useState(false); const handleOsc52ReadRequest = useCallback((): Promise => { // Reject if terminal is not visible (background tab) — user can't see the prompt if (!isVisibleRef.current) return Promise.resolve(false); // Reject if another prompt is already pending (avoid resolver overwrite) if (osc52ReadResolverRef.current) return Promise.resolve(false); return new Promise((resolve) => { osc52ReadResolverRef.current = resolve; setOsc52ReadPromptVisible(true); }); }, []); const handleOsc52ReadResponse = useCallback((allowed: boolean) => { setOsc52ReadPromptVisible(false); osc52ReadResolverRef.current?.(allowed); osc52ReadResolverRef.current = null; // Restore focus to terminal termRef.current?.focus(); }, []); const handleOsc7SetupOpenChange = useCallback((open: boolean) => { setOsc7SetupOpen(open); if (!open) { queueMicrotask(() => termRef.current?.focus()); } }, []); const handleOsc7SetupConfirm = useCallback(() => { if (status !== "connected") { handleOsc7SetupOpenChange(false); return; } if (osc7SetupRunning) return; const currentCwd = terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current; if (!currentCwd) { toast.error(t("terminal.osc7Setup.failed")); return; } setOsc7SetupRunning(true); void runOsc7SetupAction({ status, sessionId, setupCommand: buildOsc7SetupExecCommand(currentCwd), setupOsc7Tracking: terminalBackend.setupOsc7Tracking, writeToSession: terminalBackend.writeToSession, writeLocalTerminalData, }).then((result) => { handleOsc7SetupOpenChange(false); if (result.success) { toast.success(result.sentToTerminal ? t("terminal.osc7Setup.sent") : t("terminal.osc7Setup.configured")); return; } toast.error(result.error || t("terminal.osc7Setup.failed")); }).catch((error) => { toast.error(error instanceof Error ? error.message : t("terminal.osc7Setup.failed")); }).finally(() => { setOsc7SetupRunning(false); }); }, [ handleOsc7SetupOpenChange, osc7SetupRunning, sessionId, status, t, terminalCwdTracker, terminalBackend.setupOsc7Tracking, terminalBackend.writeToSession, writeLocalTerminalData, ]); const handleTopOverlayMouseDownCapture = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return; if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return; e.preventDefault(); }, []); // Subscribe to custom theme changes so editing triggers re-render const customThemes = useCustomThemes(); const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null); const hasFontFamilyOverride = host.fontFamilyOverride === true || (host.fontFamilyOverride === undefined && !!host.fontFamily); const hasFontWeightOverride = host.fontWeightOverride === true || (host.fontWeightOverride === undefined && host.fontWeight != null); const effectiveFontSize = useMemo( () => (hasFontSizeOverride && host.fontSize != null ? host.fontSize : fontSize), [fontSize, hasFontSizeOverride, host.fontSize], ); const effectiveFontWeight = useMemo( () => (hasFontWeightOverride && host.fontWeight != null ? host.fontWeight : (terminalSettings?.fontWeight ?? 400)), [terminalSettings?.fontWeight, hasFontWeightOverride, host.fontWeight], ); const resolvedFontFamily = useMemo(() => { const hostFontId = hasFontFamilyOverride && host.fontFamily ? host.fontFamily : fontFamilyId; const resolvedFontId = resolveTerminalFontFamilyId( hostFontId, typeof navigator !== "undefined" ? navigator.platform : "", ); const selectedFont = availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0]; const platform: SupportedPlatform = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform) ? "darwin" : typeof navigator !== "undefined" && /Win/i.test(navigator.platform) ? "win32" : "linux"; return composeFontFamilyStack({ primaryFamily: selectedFont.family, userFallback: terminalSettings?.fallbackFont ?? "", latinFontId: resolvedFontId, platform, }); }, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily, terminalSettings?.fallbackFont]); const { accentMode, customAccent } = useAppearanceChromeStore(); const baseEffectiveTheme = useMemo(() => { // Always re-apply appearanceChromeStore accent. appearanceTheme from the // layer may still carry a previously baked custom accent while TerminalLayer // memo ignores accent churn — re-resolve the catalog theme by id first. const resolveBase = (): typeof terminalTheme => { if (appearanceTheme) { const clean = getBuiltinTerminalThemeById(appearanceTheme.id) || customThemes.find((t) => t.id === appearanceTheme.id); if (clean) return clean; } if (followAppTerminalTheme) return terminalTheme; const themeId = resolveHostTerminalThemeId( { theme: host.theme, themeOverride: host.themeOverride } as Pick, terminalTheme.id, ); if (themeId) { const hostTheme = getBuiltinTerminalThemeById(themeId) || customThemes.find((t) => t.id === themeId); if (hostTheme) return hostTheme; } return terminalTheme; }; return applyCustomAccentToTerminalTheme(resolveBase(), accentMode, customAccent); }, [accentMode, appearanceTheme, customAccent, customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme]); const resolvedChainHosts = chainHosts; const clearAutoReconnect = useCallback((options?: { stopLoop?: boolean; clearNotice?: boolean }) => { if (autoReconnectTimerRef.current) { clearTimeout(autoReconnectTimerRef.current); autoReconnectTimerRef.current = null; } if (options?.stopLoop !== false) { autoReconnectLoopActiveRef.current = false; autoReconnectAttemptRef.current = 0; } if (options?.clearNotice !== false) setReconnectNoticeMessage(null); }, []); const updateStatus = useCallback((next: TerminalSession["status"]) => { statusRef.current = next; setStatus(next); hasConnectedRef.current = next === "connected"; if (next !== "connecting") { setManualReconnectActive(false); terminalReconnectRegistry.setActive(sessionId, false); } if (next === "connected") { hasEverConnectedRef.current = true; clearAutoReconnect(); } onStatusChange?.(sessionId, next); }, [clearAutoReconnect, onStatusChange, sessionId]); const updateStatusRef = useRef(updateStatus); updateStatusRef.current = updateStatus; const scheduleAutoReconnect = useCallback((trigger?: { evt?: Parameters[0]["evt"] }) => { // Observe popups display an existing backend; the hidden owner remains the // sole authority for reconnecting or replacing that session. if (attachExistingSession) return false; const shouldSchedule = trigger?.evt ? shouldAutoReconnectAfterExit({ evt: trigger.evt, host, terminalSettings, hasEverConnected: hasEverConnectedRef.current, }) : shouldContinueAutoReconnectAfterFailure({ host, terminalSettings, loopActive: autoReconnectLoopActiveRef.current, }); if (!shouldSchedule || !canAttemptTerminalAutoReconnect({ hasTerminalRuntime: Boolean(termRef.current), isHibernated: hibernatedRef.current, })) { return false; } autoReconnectLoopActiveRef.current = true; if (autoReconnectTimerRef.current) { return true; } autoReconnectAttemptRef.current += 1; const attempt = autoReconnectAttemptRef.current; const seconds = Math.round(TERMINAL_AUTO_RECONNECT_DELAY_MS / 1000); const scheduledMessage = t("terminal.progress.autoReconnectScheduled", { seconds, attempt }); setReconnectNoticeMessage(scheduledMessage); setError(null); setShowLogs(true); setIsDisconnectedDialogDismissed(false); setProgressLogs((prev) => [...prev, scheduledMessage]); updateStatus("connecting"); autoReconnectTimerRef.current = setTimeout(() => { autoReconnectTimerRef.current = null; startReconnectRef.current?.("auto"); }, TERMINAL_AUTO_RECONNECT_DELAY_MS); return true; }, [attachExistingSession, host, t, terminalSettings, updateStatus]); const prepareRestoredReconnect = useCallback(() => { suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect( restoreState === "restored-disconnected" ? "restored" : "manual", ); if (restoreState !== "restored-disconnected") { restoreCwdIntentRef.current = null; return; } restoreCwdIntentRef.current = resolveRestoreCwdIntent({ enabled: restoreTerminalCwd, session: { status: "disconnected", restoreState, protocol: host.protocol, shellType, lastCwd, requireFreshConnection, moshEnabled: host.moshEnabled, etEnabled: host.etEnabled, }, isNetworkDevice, }); }, [ host.etEnabled, host.moshEnabled, host.protocol, isNetworkDevice, lastCwd, requireFreshConnection, restoreState, restoreTerminalCwd, shellType, ]); // Set only once the inherited `cd` is actually written to the shell (from the // restore-cwd consumption callback), NOT when the intent is merely prepared — // otherwise a first connect that fails before consumption would block the // intent from being re-armed on retry, landing the clone in the login dir. const initialCwdConsumedRef = useRef(false); const prepareInitialCwdIntent = useCallback(() => { if (initialCwdConsumedRef.current) return; if (!pendingInitialCwd) return; const intent = resolveInheritedCwdIntent({ session: { protocol: host.protocol, shellType, moshEnabled: host.moshEnabled, etEnabled: host.etEnabled, cwd: pendingInitialCwd, }, isNetworkDevice, }); if (!intent) return; restoreCwdIntentRef.current = intent; }, [pendingInitialCwd, host.protocol, host.moshEnabled, host.etEnabled, shellType, isNetworkDevice]); const handleTerminalDataCaptureOnce = useCallback(( capturedSessionId: string, data: string, options?: { finalized?: boolean }, ) => { const captureHandler = onTerminalDataCaptureRef.current; if (!captureHandler || terminalDataCapturedRef.current) return; terminalDataCapturedRef.current = true; const capturedData = options?.finalized ? data : (finalizeTerminalLogData() || data); captureHandler(capturedSessionId, capturedData); }, [finalizeTerminalLogData]); const attachHomeWebContentsIdRef = useRef(undefined); useEffect(() => { const bridge = netcattyBridge.get(); if (!bridge?.onTerminalOutputDrainRequest || !bridge?.respondTerminalOutputDrain) return undefined; return bridge.onTerminalOutputDrainRequest(sessionId, async (payload) => { const term = termRef.current; if (term) { const flushed = await flushPendingTerminalWritesBeforeHibernate(term); if (!flushed) { logger.warn("Terminal output drain did not settle before the deadline", { sessionId }); return; } } flushTerminalSessionFlowAck(sessionId); bridge.respondTerminalOutputDrain?.(payload.requestId); }); }, [sessionId]); // Home renderer for AI observe popups: serialize scrollback on demand, and // accept reverse snapshots when the observe popup restores the route. useEffect(() => { if (attachExistingSession) return undefined; const bridge = netcattyBridge.get(); const unsubs: Array<() => void> = []; if (bridge?.onTerminalSessionSnapshotRequest && bridge?.respondTerminalSessionSnapshot) { unsubs.push(bridge.onTerminalSessionSnapshotRequest(async (payload) => { if (!payload || payload.sessionId !== sessionId) return; let snapshot = ""; try { if (serializeAddonRef.current) { if (termRef.current) { const flushed = await flushPendingTerminalWritesBeforeHibernate(termRef.current); if (!flushed) { logger.warn("Terminal snapshot drain did not settle before the deadline", { sessionId }); return; } } await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization(); snapshot = serializeAddonRef.current.serialize() || ""; } else if (hibernatedRef.current || softHiddenRef.current) { // Hibernate path: live xterm is torn down; use retained snapshot. snapshot = [ hibernateSnapshotRef.current || "", hibernatePendingBufferRef.current || "", ].join(""); } } catch (err) { logger.warn("Failed to serialize terminal snapshot for attach popup", err); } bridge.respondTerminalSessionSnapshot?.( payload.requestId, snapshot, xtermRuntimeRef.current?.getKittyKeyboardModeState(), xtermRuntimeRef.current?.getKittyKeyboardProtocolEnabled() ?? kittyKeyboardProtocolEnabledForSession, passwordPromptActiveRef.current, knownCwdRef.current ?? null, terminalTitleRef.current ?? null, ); })); } if (bridge?.onTerminalSessionApplySnapshot) { unsubs.push(bridge.onTerminalSessionApplySnapshot(async (payload) => { if ( !payload || payload.sessionId !== sessionId || typeof payload.snapshot !== "string" || typeof payload.contextSnapshot !== "string" || typeof payload.contextViewportSnapshot !== "string" || typeof payload.contextScrollbackSnapshot !== "string" || typeof payload.alternateScreen !== "boolean" ) return false; const term = termRef.current; if (term) { const flushed = await flushPendingTerminalWritesBeforeHibernate(term); if (!flushed || termRef.current !== term) { throw new Error("Terminal output did not settle before applying the snapshot"); } } if (typeof payload.kittyKeyboardProtocolEnabled === "boolean") { xtermRuntimeRef.current?.setKittyKeyboardProtocolEnabled( payload.kittyKeyboardProtocolEnabled, ); } if (typeof payload.passwordPromptActive === "boolean") { passwordPromptActiveRef.current = payload.passwordPromptActive; } if (payload.cwd !== undefined) { const cwd = terminalCwdTracker.setRendererCwd(payload.cwd, "snapshot"); knownCwdRef.current = cwd; pluginTerminalLifecycleRef.current?.onCwdChanged(cwd ?? null); onTerminalCwdChange?.(sessionId, cwd ?? null, { source: "snapshot" }); } if (payload.title !== undefined) { const title = payload.title || null; terminalTitleRef.current = title ?? undefined; pluginTerminalLifecycleRef.current?.onTitleChanged(title); onTerminalTitleChange?.(sessionId, title); } if (payload.kittyKeyboardModeState) { xtermRuntimeRef.current?.restoreKittyKeyboardModeState( payload.kittyKeyboardModeState, ); } if (term) { term.reset(); // The buffer was just wiped; drop ledger stamps anchored to the old // rows so the reconnect gutter cannot paint stale timestamps. resetTerminalLineTimestamps(term); if (payload.snapshot) { await new Promise((resolve) => term.write(payload.snapshot, resolve)); } xtermRuntimeRef.current?.cursorLineHighlighter.refresh({ force: true }); try { term.scrollToBottom?.(); } catch { /* ignore */ } } else if (hibernatedRef.current || softHiddenRef.current) { applyAuthoritativeHibernateSnapshot({ snapshot: hibernateSnapshotRef, viewportSnapshot: hibernateViewportSnapshotRef, scrollbackSnapshot: hibernateScrollbackSnapshotRef, contextSnapshot: hibernateContextSnapshotRef, contextViewportSnapshot: hibernateContextViewportSnapshotRef, contextScrollbackSnapshot: hibernateContextScrollbackSnapshotRef, pendingBuffer: hibernatePendingBufferRef, alternateScreen: hibernateAlternateScreenRef, }, payload.snapshot, { contextSnapshot: payload.contextSnapshot, contextViewportSnapshot: payload.contextViewportSnapshot, contextScrollbackSnapshot: payload.contextScrollbackSnapshot, alternateScreen: payload.alternateScreen, }); } return true; })); } return () => { for (const unsub of unsubs) unsub(); }; }, [ attachExistingSession, kittyKeyboardProtocolEnabledForSession, onTerminalCwdChange, onTerminalTitleChange, sessionId, terminalCwdTracker, ]); const cleanupSession = async (options?: { retainOwnership?: boolean }) => { const closingSessionId = sessionRef.current; xtermRuntimeRef.current?.flushKittyKeyboardReleases(); sessionRef.current = null; const disposeSessionListeners = () => { disposeDataRef.current?.(); disposeDataRef.current = null; disposeExitRef.current?.(); disposeExitRef.current = null; disposeTelnetEchoModeRef.current?.(); disposeTelnetEchoModeRef.current = null; telnetLocalEchoRef.current = false; }; if (!attachExistingSession) { disposeSessionListeners(); } const pendingCleanup = sessionCleanupPromiseRef.current; if (pendingCleanup) { await pendingCleanup; } if (!closingSessionId) { disposeSessionListeners(); // Still notify main so in-flight SSH passphrase prompts for this UI // sessionId are aborted even before a backend session was attached. try { await terminalBackend.closeSession(sessionId, { bootEpoch: resolveCloseBootEpoch(), ...(options?.retainOwnership === true ? { retainOwnership: true } : {}), }); } catch (err) { logger.warn("Failed to cancel pending session boot on disconnect", err); } const postDisposeCleanup = sessionCleanupPromiseRef.current; if (postDisposeCleanup) { await postDisposeCleanup; } return; } // Observe/attach popups must not kill the backend session — only release // the display route back to the home renderer. if (attachExistingSession) { const homeId = attachHomeWebContentsIdRef.current; attachHomeWebContentsIdRef.current = undefined; const outputPauseLease = await terminalBackend.acquireSessionFlowPauseLease(closingSessionId); try { // Stop the source before detaching the popup listener. This lets any // already-delivered writes settle into xterm before its final snapshot. const paused = await outputPauseLease.waitForPause(); if (!paused?.success && paused?.error === "Output drain unavailable") { await new Promise((resolve) => setTimeout(resolve, 40)); } else if (!paused?.success) { throw new Error(paused?.error || "Failed to drain terminal output"); } const snapshotTerm = termRef.current; if (snapshotTerm) { const flushed = await flushPendingTerminalWritesBeforeHibernate(snapshotTerm); if (!flushed) { throw new Error("Terminal output did not settle before closing the attached display"); } } // Push popup display state home first so reopen is not stale. let finalContext = { contextSnapshot: "", contextViewportSnapshot: "", contextScrollbackSnapshot: "", alternateScreen: snapshotTerm ? isTerminalAlternateScreenActive(snapshotTerm) : false, }; try { if (snapshotTerm) finalContext = readTerminalHibernateContext(snapshotTerm); } catch (err) { logger.warn("Failed to read terminal context for attach popup", err); } let serializedSnapshot: unknown; try { await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization(); serializedSnapshot = serializeAddonRef.current?.serialize?.(); } catch (err) { logger.warn("Failed to serialize terminal snapshot for attach popup", err); } const capture = resolveTerminalSnapshotCapture(serializedSnapshot, finalContext); const snap = capture.snapshot; finalContext = capture.context; const applied = await terminalBackend.applySessionSnapshot?.( closingSessionId, snap, { ...finalContext, kittyKeyboardModeState: xtermRuntimeRef.current?.getKittyKeyboardModeState(), kittyKeyboardProtocolEnabled: xtermRuntimeRef.current?.getKittyKeyboardProtocolEnabled(), passwordPromptActive: passwordPromptActiveRef.current, cwd: knownCwdRef.current ?? null, title: terminalTitleRef.current ?? null, }, attachAuthorization || "", ); if (applied && !applied.success) throw new Error(applied.error || "Failed to apply terminal snapshot"); // Hand the route home while output is still paused, then detach the // popup listener and resume so subsequent bytes land on the home route. const restored = await terminalBackend.restoreSessionOutput?.( closingSessionId, homeId ?? null, attachAuthorization || "", ); if (restored && !restored.success) throw new Error(restored.error || "Failed to restore terminal output"); disposeSessionListeners(); const activeTerm = termRef.current; if (activeTerm) { releaseTerminalFlowBeforeHibernate(terminalBackend, activeTerm, closingSessionId, { resumeBackend: true, }); } else { flushTerminalSessionFlowAck(closingSessionId); clearTerminalSessionFlowAck(closingSessionId); terminalBackend.setSessionFlowPaused?.(closingSessionId, false); } outputPauseLease.release(); } catch (err) { outputPauseLease.release({ keepPaused: true }); logger.warn("Failed to restore terminal output after attach popup close", err); disposeSessionListeners(); throw err; } return; } const cleanupPromise = (async () => { const activeTerm = termRef.current; if (activeTerm) { releaseTerminalFlowBeforeHibernate(terminalBackend, activeTerm, closingSessionId, { resumeBackend: false, }); } else { flushTerminalSessionFlowAck(closingSessionId); clearTerminalSessionFlowAck(closingSessionId); } try { await terminalBackend.closeSession(closingSessionId, { bootEpoch: resolveCloseBootEpoch(), ...(options?.retainOwnership === true ? { retainOwnership: true } : {}), }); } catch (err) { logger.warn("Failed to close SSH session", err); } })(); sessionCleanupPromiseRef.current = cleanupPromise; try { await cleanupPromise; } finally { if (sessionCleanupPromiseRef.current === cleanupPromise) { sessionCleanupPromiseRef.current = null; } } }; const cleanupSessionRef = useRef(cleanupSession); cleanupSessionRef.current = cleanupSession; useEffect(() => { if (!attachExistingSession || !onAttachClosePreparationChange) return undefined; const prepare = () => cleanupSessionRef.current(); onAttachClosePreparationChange(prepare); return () => onAttachClosePreparationChange(null); }, [attachExistingSession, onAttachClosePreparationChange]); const disposeRuntimeOnly = () => { xtermRuntimeRef.current?.dispose(); xtermRuntimeRef.current = null; termRef.current = null; fitAddonRef.current = null; serializeAddonRef.current = null; searchAddonRef.current = null; hasRuntimeRef.current = false; }; const clearHibernateRuntimeState = useCallback(() => { hibernatedRef.current = false; softHiddenRef.current = false; hibernateSnapshotRef.current = ""; hibernateViewportSnapshotRef.current = ""; hibernateScrollbackSnapshotRef.current = ""; hibernateContextSnapshotRef.current = ""; hibernateContextViewportSnapshotRef.current = ""; hibernateContextScrollbackSnapshotRef.current = ""; hibernatePendingBufferRef.current = ""; hibernateAlternateScreenRef.current = false; terminalHiddenRendererStore.clearSoftHidden(sessionId); }, [sessionId]); const forceCloseHibernatedSession = useCallback(() => { if (!terminalDataCapturedRef.current) { const hibernatedData = hibernateSnapshotRef.current + hibernatePendingBufferRef.current; if (hibernatedData) { handleTerminalDataCaptureOnce(sessionId, hibernatedData); } } disposeDataRef.current?.(); disposeDataRef.current = null; disposeExitRef.current?.(); disposeExitRef.current = null; disposeTelnetEchoModeRef.current?.(); disposeTelnetEchoModeRef.current = null; telnetLocalEchoRef.current = false; const closingSessionId = sessionRef.current; if (closingSessionId) { flushTerminalSessionFlowAck(closingSessionId); clearTerminalSessionFlowAck(closingSessionId); if (attachExistingSession) { const homeId = attachHomeWebContentsIdRef.current; attachHomeWebContentsIdRef.current = undefined; terminalBackend.setSessionFlowPaused?.(closingSessionId, true); void terminalBackend.restoreSessionOutput?.( closingSessionId, homeId ?? null, attachAuthorization || "", ).then((result) => { if (!result?.success) throw new Error(result?.error || "Failed to restore terminal output"); flushTerminalSessionFlowAck(closingSessionId); clearTerminalSessionFlowAck(closingSessionId); terminalBackend.setSessionFlowPaused?.(closingSessionId, false); }).catch((err) => { logger.warn("Failed to restore terminal output after attach popup hibernate close", err); }); } else { try { const closeResult = terminalBackend.closeSession(closingSessionId); void Promise.resolve(closeResult).catch((err) => { logger.warn("Failed to close hibernated session", err); }); } catch (err) { logger.warn("Failed to close hibernated session", err); } } } sessionRef.current = null; clearHibernateRuntimeState(); }, [attachAuthorization, attachExistingSession, clearHibernateRuntimeState, handleTerminalDataCaptureOnce, sessionId, terminalBackend]); const observeTerminalInputPrompt = useCallback(( chunk: string, meta?: TerminalSessionDataMeta, ) => { sensitivePromptOutputTailRef.current = appendTerminalPromptSecurityTail( sensitivePromptOutputTailRef.current, chunk, ); const promptSecurityOptions = { allowHostStyleGreaterThan: isNetworkDevice }; if (typeof meta?.pluginPipelineSensitiveInput === "boolean") { passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput; if (meta.pluginPipelineSensitiveInput) { autocompleteCloseRef.current?.(); } else { sensitivePromptOutputTailRef.current = ""; } return; } else if (isUntrustedTerminalInputPrompt( sensitivePromptOutputTailRef.current, promptSecurityOptions, )) { passwordPromptActiveRef.current = true; autocompleteCloseRef.current?.(); } else if (isConfirmedTerminalShellPrompt( sensitivePromptOutputTailRef.current, promptSecurityOptions, )) { passwordPromptActiveRef.current = false; } }, [isNetworkDevice]); const beginHibernatedSessionListeners = useCallback((backendId: string) => { disposeDataRef.current?.(); flushTerminalSessionFlowAck(backendId); terminalBackend.setSessionFlowPaused?.(backendId, false); hibernatePendingBufferRef.current = ""; oscNotificationScannerRef.current = new OscNotificationStreamScanner(); disposeDataRef.current = terminalBackend.onSessionData( backendId, (chunk, meta) => { observeTerminalInputPrompt(chunk, meta); const scanned = oscNotificationScannerRef.current.consume(chunk); for (const notification of scanned.notifications) { handleTerminalOscNotification({ notification, mode: terminalSettingsRef.current?.oscNotifications, sessionFocused: isFocusedRef.current, sessionId, fallbackTitle: host.label || host.hostname || "Netcatty", onSessionActivity: () => onTerminalBell?.(sessionId), }); } hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current ? hibernatePendingBufferRef.current + scanned.remainder : appendHibernatePendingBuffer( hibernatePendingBufferRef.current, scanned.remainder, ); const pluginPipelineIngressBytes = Number.isFinite(meta?.pluginPipelineIngressBytes) ? Math.max(0, Number(meta.pluginPipelineIngressBytes)) : chunk.length; ackTerminalSessionFlow(terminalBackend, backendId, pluginPipelineIngressBytes); }, { replayBacklog: true }, ); disposeExitRef.current?.(); disposeExitRef.current = terminalBackend.onSessionExit(backendId, (evt) => { disposeTelnetEchoModeRef.current?.(); disposeTelnetEchoModeRef.current = null; telnetLocalEchoRef.current = false; updateStatusRef.current("disconnected"); pluginTerminalSessionExitRef.current(evt.exitCode); if (evt.error) { setError(evt.error); } const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`; hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current ? hibernatePendingBufferRef.current + exitMessage : appendHibernatePendingBuffer( hibernatePendingBufferRef.current, exitMessage, ); onSessionExitRef.current?.(sessionId, evt); scheduleAutoReconnect({ evt }); }); }, [host.hostname, host.label, observeTerminalInputPrompt, onTerminalBell, scheduleAutoReconnect, sessionId, terminalBackend]); const clearHibernateRetry = useCallback(() => { if (hibernateRetryTimerRef.current === null) return; clearTimeout(hibernateRetryTimerRef.current); hibernateRetryTimerRef.current = null; }, []); const scheduleHibernateRetry = useCallback(() => { if (hibernateRetryTimerRef.current !== null) return; hibernateRetryTimerRef.current = setTimeout(() => { hibernateRetryTimerRef.current = null; if ( isVisibleRef.current || hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current || !canHibernateTerminalRuntimeStatus(statusRef.current) || isSearchOpenRef.current || hibernateFileTransferActiveRef.current || !hibernateEnabledRef.current ) { return; } void fullHibernateRuntimeRef.current?.(); }, HIBERNATE_RETRY_AFTER_DRAIN_MS); }, []); const applyHibernateSnapshot = useCallback(( snapshot: { snapshot: string; viewportSnapshot: string; scrollbackSnapshot: string; contextSnapshot?: string; contextViewportSnapshot?: string; contextScrollbackSnapshot?: string; alternateScreen: boolean; }, ) => { hibernateSnapshotRef.current = snapshot.snapshot; hibernateViewportSnapshotRef.current = snapshot.viewportSnapshot; hibernateScrollbackSnapshotRef.current = snapshot.scrollbackSnapshot; hibernateContextSnapshotRef.current = snapshot.contextSnapshot ?? ""; hibernateContextViewportSnapshotRef.current = snapshot.contextViewportSnapshot ?? ""; hibernateContextScrollbackSnapshotRef.current = snapshot.contextScrollbackSnapshot ?? ""; hibernateAlternateScreenRef.current = snapshot.alternateScreen; }, []); const shouldSkipHibernateForActiveAlternateScreen = useCallback((term: XTerm): boolean => { if (statusRef.current !== "connected") return false; if ( !isTerminalAlternateScreenActive(term) || !resolveHibernateSkipAltScreen(terminalSettings) ) { return false; } logger.info("[Terminal] Skipping hibernate: alternate screen active", { sessionId }); return true; }, [sessionId, terminalSettings]); // Hibernate rebuilds a tab from a serialized *text* snapshot. Inline images // (Kitty graphics / SIXEL / iTerm IIP) live in the ImageAddon's own storage and // are not part of that snapshot, so a session holding images degrades to // soft-hide (WebGL suspended, xterm and its image layers kept alive) instead of // a full renderer release. The check reads the live image cache, so it stops // blocking once the cache is emptied by a terminal reset or FIFO eviction. const runtimeHasInlineImages = useCallback( () => xtermRuntimeRef.current?.hasInlineImages() === true, [], ); const fullHibernateRuntime = useCallback(async (): Promise => { if ( hibernatedRef.current || (softHiddenRef.current && statusRef.current !== "disconnected") || !termRef.current || !serializeAddonRef.current ) return false; clearHibernateRetry(); if (reconnectPreparationTokenRef.current !== null) return false; const hibernateStatus = statusRef.current; const backendId = sessionRef.current; if (!canHibernateTerminalRuntimeSession(hibernateStatus, backendId)) return false; const term = termRef.current; const serializeAddon = serializeAddonRef.current; const canFinishHibernate = () => ( !isVisibleRef.current && !hibernatedRef.current && (!softHiddenRef.current || hibernateStatus === "disconnected") && hasRuntimeRef.current && canHibernateTerminalRuntimeSession(hibernateStatus, backendId) && !isSearchOpenRef.current && !hibernateFileTransferActiveRef.current && !runtimeHasInlineImages() && hibernateEnabledRef.current && termRef.current === term && statusRef.current === hibernateStatus && sessionRef.current === backendId && serializeAddonRef.current === serializeAddon && reconnectPreparationTokenRef.current === null ); if (!canFinishHibernate()) return false; terminalHiddenRendererStore.clearSoftHidden(sessionId); softHiddenRef.current = false; const flushedBeforeHibernate = await flushPendingTerminalWritesBeforeHibernate(term); if (!flushedBeforeHibernate) { logger.info("[Terminal] Skipping hibernate: terminal output is still draining", { sessionId }); scheduleHibernateRetry(); return false; } if (!canFinishHibernate()) return false; if (shouldSkipHibernateForActiveAlternateScreen(term)) { return false; } const snapshot = await serializeTerminalForHibernate( term, serializeAddon, { preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current), prepare: () => xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization() ?? Promise.resolve(), }, ); if (!canFinishHibernate()) return false; if (snapshot.alternateScreen && snapshot.snapshot.length === 0) { logger.info("[Terminal] Skipping hibernate: alternate screen snapshot unavailable", { sessionId }); return false; } applyHibernateSnapshot(snapshot); isBootActiveRef.current = false; const connectedBackendId = hibernateStatus === "connected" ? backendId : null; if (connectedBackendId) { releaseTerminalFlowBeforeHibernate(terminalBackend, term, connectedBackendId); } disposeDataRef.current?.(); disposeDataRef.current = null; disposeExitRef.current?.(); disposeExitRef.current = null; disposeRuntimeOnly(); if (connectedBackendId) { beginHibernatedSessionListeners(connectedBackendId); } hibernatedRef.current = true; // Hibernation rebuilds the autofill controller on wake; drop any open // picker so it cannot stay visible against a non-pending controller. setPasswordPickerState(null); logger.info("[Terminal] Hibernated runtime", { sessionId, snapshotChars: hibernateSnapshotRef.current.length, viewportChars: hibernateViewportSnapshotRef.current.length, scrollbackChars: hibernateScrollbackSnapshotRef.current.length, alternateScreen: snapshot.alternateScreen, }); return true; }, [ applyHibernateSnapshot, beginHibernatedSessionListeners, clearHibernateRetry, scheduleHibernateRetry, runtimeHasInlineImages, sessionId, shouldSkipHibernateForActiveAlternateScreen, terminalBackend, ]); fullHibernateRuntimeRef.current = fullHibernateRuntime; const upgradeSoftHiddenRuntimeToHibernate = useCallback(() => { if (!wakeSoftHiddenRuntimeRef.current) return; wakeSoftHiddenRuntimeRef.current?.(); void fullHibernateRuntime().then( (completed) => { if (!completed) resumeRendererAfterCancelledHibernateUpgradeRef.current?.(); }, (error) => { logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error }); resumeRendererAfterCancelledHibernateUpgradeRef.current?.(); }, ); }, [fullHibernateRuntime, sessionId]); const hideRuntimeOnly = useCallback(() => { if (hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current) return; xtermRuntimeRef.current?.suspendWebglRenderer(); terminalHiddenRendererStore.markSoftHidden(sessionId); softHiddenRef.current = true; logger.info("[Terminal] Soft-hidden runtime", { sessionId }); }, [sessionId]); const hibernateRuntime = useCallback(() => { if (hibernatedRef.current || !termRef.current) return; if (shouldSkipHibernateForActiveAlternateScreen(termRef.current)) { return; } // Images cannot be restored from the text snapshot: keep the renderer alive // and only suspend WebGL, and never evict another tab on this session's behalf. if (runtimeHasInlineImages()) { hideRuntimeOnly(); return; } if (softHiddenRef.current) { if (statusRef.current === "disconnected") { upgradeSoftHiddenRuntimeToHibernate(); } return; } const keepCount = statusRef.current === "connected" ? resolveHibernateKeepRendererCount(terminalSettings) : 0; if (keepCount > 0 && terminalHiddenRendererStore.getSoftHiddenCount() < keepCount) { hideRuntimeOnly(); return; } if (keepCount > 0) { const victim = terminalHiddenRendererStore.pickEvictionCandidate(keepCount); if (victim && victim !== sessionId) { terminalHiddenRendererStore.requestEviction(victim); } } void fullHibernateRuntime(); }, [ fullHibernateRuntime, hideRuntimeOnly, runtimeHasInlineImages, sessionId, shouldSkipHibernateForActiveAlternateScreen, terminalSettings, upgradeSoftHiddenRuntimeToHibernate, ]); const terminalRuntimeRefs = useMemo(() => ({ xtermRuntimeRef, termRef, fitAddonRef, serializeAddonRef, prepareKeywordHighlightSerialization: () => xtermRuntimeRef.current ?.keywordHighlighter.prepareForSerialization() ?? Promise.resolve(), searchAddonRef, hasRuntimeRef, }), []); const xTermRuntimeContextRef = useRef | null>(null); const pluginDecorationRulesRef = useRef(Object.freeze([])); const pluginDecorationRefreshRef = useRef<(reason: string) => void>(() => {}); useEffect(() => () => { clearKittyKeyboardBroadcastSession(sessionId); clearTerminalBootEpoch(sessionId); }, [sessionId]); const teardown = () => { // Capture the live epoch before invalidating so closeSession still matches // the registered backend session / pending passphrase boot. invalidateBootEpochForClose(); clearTerminalBootEpoch(sessionId); isBootActiveRef.current = false; retryTokenRef.current = null; reconnectPreparationTokenRef.current = null; restoreCwdIntentRef.current = null; suppressHostStartupCommandRef.current = false; clearHibernateRetry(); clearAutoReconnect(); void cleanupSession(); synchronizedFitSchedulerRef.current?.dispose(); disposeRuntimeOnly(); }; const pluginTerminalLifecycle = usePluginTerminalSessionLifecycle({ sessionId, hostId: host.id, workspaceId, protocol: effectiveTerminalProtocol, status, shellType, initialCwd: knownCwdRef.current ?? lastCwd, ownsBackendLifecycle: shouldPublishPluginTerminalSessionMountLifecycle(attachExistingSession), }); pluginTerminalLifecycleRef.current = pluginTerminalLifecycle; pluginTerminalSessionExitRef.current = pluginTerminalLifecycle.onSessionExited; const getPluginTerminalSnapshotState = useCallback((): Partial => { const term = termRef.current; return { status: statusRef.current, ...(knownCwdRef.current ? { cwd: knownCwdRef.current } : {}), ...(term ? { cols: term.cols, rows: term.rows } : {}), alternateScreen: term?.buffer.active.type === 'alternate', }; }, []); const pluginTerminalProviders = usePluginTerminalProviders({ sessionId, hostId: host.id, workspaceId, protocol: effectiveTerminalProtocol, status, shellType, baseTheme: baseEffectiveTheme, getSnapshotState: getPluginTerminalSnapshotState, }); const { decorationRules: pluginDecorationRules, hasProvider: isPluginTerminalProviderAvailable, providerRevision: pluginTerminalProviderRevision, refreshProviderOutputs, request: requestPluginTerminalProviders, resolvedTheme: effectiveTheme, } = pluginTerminalProviders; pluginDecorationRefreshRef.current = (reason: string) => { void refreshProviderOutputs(reason); }; const pluginAwareOnCommandSubmitted = useCallback(( command: string, ) => { markTerminalCommandCompletionPending(promptLineBreakStateRef); pluginTerminalLifecycle.onCommandSubmitted(); void xtermRuntimeRef.current?.pluginProviderHost?.commandSubmitted(command); }, [pluginTerminalLifecycle]); const cwdAwareOnCommandSubmitted = useCallback(( ...args: Parameters> ) => { invalidateTerminalCwdAfterCommand( terminalCwdTracker, sessionId, () => { knownCwdRef.current = undefined; }, onTerminalCwdChange, ); onCommandSubmitted?.(...args); }, [onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]); const pluginAwareOnCommandCompleted = useCallback(() => { pluginTerminalLifecycle.onCommandCompleted(); void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted(); }, [pluginTerminalLifecycle]); const pluginAwareOnTerminalCwdChange = useCallback(( changedSessionId: string, cwd: string | null, meta?: TerminalCwdChangeMeta, ) => { pluginTerminalLifecycle.onCwdChanged(cwd); onTerminalCwdChange?.(changedSessionId, cwd, meta); }, [onTerminalCwdChange, pluginTerminalLifecycle]); const pluginAwareOnRuntimeCwdChange = useCallback(( cwd: string, meta?: TerminalCwdChangeMeta, ) => { const normalizedCwd = terminalCwdTracker.setRendererCwd( cwd, meta?.source ?? "backend", ); knownCwdRef.current = normalizedCwd; pluginAwareOnTerminalCwdChange(sessionId, normalizedCwd ?? null, meta); void refreshProviderOutputs('cwd-changed'); }, [pluginAwareOnTerminalCwdChange, refreshProviderOutputs, sessionId, terminalCwdTracker]); const pluginAwareOnTerminalTitleChange = useCallback((changedSessionId: string, title: string | null) => { terminalTitleRef.current = title || undefined; pluginTerminalLifecycle.onTitleChanged(title); onTerminalTitleChange?.(changedSessionId, title); }, [onTerminalTitleChange, pluginTerminalLifecycle]); const sessionStarters = createTerminalSessionStarters({ host, hostRef, keys, identities, knownHosts, resolvedChainHosts, sessionId, reuseConnectionFromSessionIdRef: reuseConnectionSourceRef, requireFreshConnection, requireFreshConnectionOnReconnectRef, reuseConnectionSourceAttemptedRef, setConnectionReuseAttemptSourceId, shouldUseFreshSshConnection: () => { const currentPendingScript = pendingScriptRef.current; const currentPendingScriptId = pendingScriptIdRef.current; const hasUnhandledPendingScript = currentPendingScript && isScriptSnippet(currentPendingScript) ? !isPendingScriptAlreadyHandled(currentPendingScript) : Boolean( currentPendingScriptId && pendingScriptRunIdRef.current !== currentPendingScriptId, ); return shouldUseFreshSshConnectionForAutomation({ host: hostRef.current, snippets: snippetsRef.current, vaultInitialized: isVaultInitialized(), hasPendingScript: hasUnhandledPendingScript, connectAutomationConsumed: connectScriptsConsumedRef.current, }); }, onConnectAutomationSnapshotCommitted: () => { connectScriptsConsumedRef.current = true; }, isNetworkDevice, startupCommand, noAutoRun, multiLineRunMode, shellType, suppressHostStartupCommandRef, terminalSettings, terminalSettingsRef, terminalBackend, serialConfig, telnetLocalEchoRef, isPaneVisibleRef: isVisibleRef, isVisibleRef: isRendererActiveRef, isBootActiveRef, bootEpochRef, pendingOutputScrollRef, sessionRef, hasConnectedRef, hasRunStartupCommandRef, restoreCwdIntentRef, disposeDataRef, disposeExitRef, trackSessionCleanup, disposeTelnetEchoModeRef, fitAddonRef, serializeAddonRef, pendingAuthRef, promptLineBreakStateRef, sudoAutofillRef, onSudoHint: (active: boolean) => sudoHintRef.current?.(active) ?? false, onPasswordPromptPicker: (active, state) => passwordPickerRef.current?.(active, state) ?? false, sudoAutofillCandidates: resolvedSudoAutofillCandidates, sudoAutofillCandidatesRef, updateStatus, setStatus, setError, setNeedsAuth: auth.setNeedsAuth, setAuthRetryMessage: auth.setAuthRetryMessage, setAuthPassword: auth.setAuthPassword, setProgressLogs, setProgressValue, setChainProgress, setIsConnectionAwaitingUserInput, setIsConnectionPastTcpDial, t, onSessionAttached: (id: string) => { clearTerminalCwd({ persistRestoreMetadata: false }); // SSH: always sync. Its backend starts in utf-8 regardless of // host.charset, so the push is what keeps the UI state aligned // across reconnects — including localhost SSH targets, hence // hostname isn't in the gate. const isLocal = host.protocol === 'local' || host.id?.startsWith('local-'); const isSerial = host.protocol === 'serial' || host.id?.startsWith('serial-'); const isTelnet = host.protocol === 'telnet'; const isMosh = host.protocol === 'mosh' || host.moshEnabled; const isEt = host.protocol === 'et' || host.etEnabled; const isPlugin = isPluginHostProtocol(host.protocol); const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh && !isEt && !isPlugin; const encodingAttachConnection: TerminalEncodingAttachConnection = isSSH ? 'ssh' : isTelnet ? 'telnet' : isSerial ? 'serial' : 'other'; // Telnet / serial: the backend already applied host.charset unless // a remembered per-host choice exists. Remembered choices are explicit // user preferences and must win on reconnect, including saved serial hosts. // (including arbitrary iconv labels like latin1 / shift_jis that // the UI's two-value state can't represent) through start*Session // options, so don't clobber it on first attach without a stored choice. if (shouldSyncTerminalEncodingOnAttach({ connection: encodingAttachConnection, userPickedEncoding: userPickedEncodingRef.current, hasRememberedEncoding: hasRememberedTerminalEncodingRef.current, })) { setSessionEncoding(id, terminalEncodingRef.current); } }, onRestoreCwdIntentConsumed: (cwd: string) => { knownCwdRef.current = cwd; // The inherited `cd` was actually sent — now mark it consumed so retries // don't re-arm it (see prepareInitialCwdIntent). initialCwdConsumedRef.current = true; // Report the new cwd to app state so `pendingInitialCwd` is cleared even // for shells that never emit OSC 7 (where the post-connect fallback probe // is skipped because knownCwdRef is now set). Without this the transient // seed would survive and re-inject a stale dir on a later remount. if (pendingInitialCwd) { onTerminalCwdChange?.(sessionId, cwd); } }, onSessionExit: (closedSessionId, evt) => { clearTerminalCwd(); pluginTerminalLifecycle.onSessionExited(evt.exitCode); onSessionExitRef.current?.(closedSessionId, evt); scheduleAutoReconnect({ evt }); }, onTerminalDataCapture: handleTerminalDataCaptureOnce, onTerminalOutput: (chunk: string, meta?: TerminalSessionDataMeta) => { observeTerminalInputPrompt(chunk, meta); appendOutputTriggerOutputRef.current(chunk, meta); if (onTerminalOutput) { onTerminalOutput(sessionId, chunk); } }, onTerminalLogData: captureTerminalLogData, onProgrammaticCommandLogRewrite: queueProgrammaticCommandLogRewrite, onOsDetected, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandCompleted: pluginAwareOnCommandCompleted, sessionLog, sshDebugLogEnabled, sudoAutofillPassword: resolvedSudoAutofillPassword, sudoAutofillPasswordRef, }); sessionStartersRef.current = sessionStarters; useEffect(() => { if (status === 'disconnected' && !autoReconnectLoopActiveRef.current) { connectScriptsConsumedRef.current = false; connectScriptsCompletedIdsRef.current = new Set(); } if (status === 'disconnected' && effectiveTerminalProtocol === 'mosh') { setMoshShellReady(false); } }, [effectiveTerminalProtocol, status]); // Synchronously (re)register the mosh ready listener. Must run before // startMosh on retry: cleanupSession/closeSession wipes preload listeners, // and a useEffect-only resubscribe can race a fast passwordless handshake. const prepareMoshReadySubscription = useCallback(() => { disposeMoshReadyRef.current?.(); disposeMoshReadyRef.current = null; if (effectiveTerminalProtocol !== 'mosh') { setMoshShellReady(true); return; } if (!terminalBackend.onMoshSessionReady) { // Older bridges without the ready event must not block scripts forever. setMoshShellReady(true); return; } setMoshShellReady(false); disposeMoshReadyRef.current = terminalBackend.onMoshSessionReady(sessionId, (evt) => { const currentEpoch = bootEpochRef.current; if ( Number.isFinite(currentEpoch) && Number.isFinite(evt?.bootEpoch) && evt.bootEpoch !== currentEpoch ) { return; } setMoshShellReady(true); }) ?? null; }, [effectiveTerminalProtocol, sessionId, terminalBackend]); useEffect(() => { prepareMoshReadySubscription(); return () => { disposeMoshReadyRef.current?.(); disposeMoshReadyRef.current = null; }; }, [prepareMoshReadySubscription]); useEffect(() => { if (status !== "disconnected") return; scheduleAutoReconnect(); }, [scheduleAutoReconnect, status]); useEffect(() => { if (!autoReconnectLoopActiveRef.current) return; if (shouldContinueAutoReconnectAfterFailure({ host, terminalSettings, loopActive: true })) return; const hadPendingReconnect = autoReconnectTimerRef.current !== null; clearAutoReconnect(); if (hadPendingReconnect) { updateStatus("disconnected"); } }, [clearAutoReconnect, host, terminalSettings, updateStatus]); useEffect(() => { pendingScriptRunIdRef.current = null; pendingScriptHandledRef.current = null; }, [pendingScript?.id, pendingScriptId]); useEffect(() => { if (status !== 'connected') return; if (effectiveTerminalProtocol === 'mosh' && !moshShellReady) return; let pendingOne: Snippet | undefined; if (pendingScript && isScriptSnippet(pendingScript)) { if (!isPendingScriptAlreadyHandled(pendingScript)) { pendingOne = pendingScript; } } else if (pendingScriptId) { const script = snippets.find((item) => item.id === pendingScriptId && isScriptSnippet(item)); if (script && !isPendingScriptAlreadyHandled(script)) { pendingOne = script; } } const resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets); // An empty hydrated vault is a final decision for this connection. Lock it // before the output-settling delay so a sync arriving during that window // cannot inject a new connect script into an already-reused SSH session. if ( !connectScriptsConsumedRef.current && resolvedConnectScripts.length === 0 && shouldMarkConnectAutomationConsumed({ allConnectScriptsDone: true, vaultInitialized: isVaultInitialized(), hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets), }) ) { connectScriptsConsumedRef.current = true; } const shouldEvaluateConnect = !connectScriptsConsumedRef.current; const hasPendingWork = Boolean(pendingOne); if (!shouldEvaluateConnect && !hasPendingWork) return; if (connectScriptsInFlightRef.current) return; const batch = createConnectAutomationBatch(); connectScriptsBatchRef.current = batch; connectScriptsInFlightRef.current = true; const batchStillActive = () => ( !batch.controller.signal.aborted && connectScriptsBatchRef.current === batch ); // Defer until xterm has rendered login output and the main-process output tap // has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer). let timerFired = false; const timer = window.setTimeout(() => { timerFired = true; if (!batchStillActive()) return; const runPending = Boolean(pendingOne); const connectQueueNow = connectScriptsConsumedRef.current ? [] : resolvedConnectScripts.filter( (item) => item.id && !connectScriptsCompletedIdsRef.current.has(item.id), ); const scriptsToRun: Snippet[] = []; for (const item of connectQueueNow) { scriptsToRun.push(item); } if (runPending && pendingOne && !scriptsToRun.some((entry) => entry.id === pendingOne.id)) { scriptsToRun.push(pendingOne); } const allConnectScriptsDone = resolvedConnectScripts.length === 0 || resolvedConnectScripts.every( (item) => item.id && connectScriptsCompletedIdsRef.current.has(item.id), ); if (scriptsToRun.length === 0) { if (!connectScriptsConsumedRef.current && shouldMarkConnectAutomationConsumed({ allConnectScriptsDone, vaultInitialized: isVaultInitialized(), hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets), })) { connectScriptsConsumedRef.current = true; } if (connectScriptsBatchRef.current === batch) { connectScriptsBatchRef.current = null; connectScriptsInFlightRef.current = false; } return; } const pendingScriptToMark = runPending ? pendingOne : undefined; const connectIdsInBatch = new Set( connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)), ); void runConnectScriptsSequential({ scripts: scriptsToRun, sessionId, signal: batch.controller.signal, onCancelableRunChange: (stopCurrentRun) => ( trackConnectAutomationStop(batch, stopCurrentRun) ), sessionMeta: { connected: true, name: scriptSessionName, hostname: host.hostname, username: host.username, }, onScriptComplete: (snippet) => { if (!batchStillActive()) return; if (snippet.id && connectIdsInBatch.has(snippet.id)) { connectScriptsCompletedIdsRef.current.add(snippet.id); } if (pendingScriptToMark && snippet === pendingScriptToMark) { if (snippet.id) { pendingScriptRunIdRef.current = snippet.id; } else { pendingScriptHandledRef.current = snippet; } } }, }) .then(() => { if (!batchStillActive()) return; const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets); const doneAfterRun = resolvedAfterRun.length === 0 || resolvedAfterRun.every( (item) => item.id && connectScriptsCompletedIdsRef.current.has(item.id), ); if (doneAfterRun) { connectScriptsConsumedRef.current = true; } }) .catch(async (err) => { if (!batchStillActive()) return; const message = err instanceof Error ? err.message : String(err); toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); connectScriptsConsumedRef.current = true; const pendingStillNeeded = pendingScriptToMark && ( pendingScriptToMark.id ? pendingScriptRunIdRef.current !== pendingScriptToMark.id : pendingScriptHandledRef.current !== pendingScriptToMark ); if (!pendingStillNeeded) return; try { await runConnectScriptsSequential({ scripts: [pendingScriptToMark], sessionId, signal: batch.controller.signal, onCancelableRunChange: (stopCurrentRun) => ( trackConnectAutomationStop(batch, stopCurrentRun) ), sessionMeta: { connected: true, name: scriptSessionName, hostname: host.hostname, username: host.username, }, onScriptComplete: (snippet) => { if (!batchStillActive()) return; if (snippet.id) { pendingScriptRunIdRef.current = snippet.id; } else { pendingScriptHandledRef.current = snippet; } }, }); } catch (pendingErr) { if (!batchStillActive()) return; const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr); toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage); } }) .finally(() => { if ( !batch.controller.signal.aborted && connectScriptsBatchRef.current === batch ) { connectScriptsBatchRef.current = null; connectScriptsInFlightRef.current = false; } }); }, 400); return () => { window.clearTimeout(timer); if (!timerFired && connectScriptsBatchRef.current === batch) { batch.controller.abort(); connectScriptsBatchRef.current = null; connectScriptsInFlightRef.current = false; } else if (statusRef.current !== "connected") { void cancelConnectAutomationBatch(batch).catch(() => {}); } }; }, [effectiveTerminalProtocol, host, isPendingScriptAlreadyHandled, moshShellReady, pendingScript, pendingScriptId, scriptSessionName, sessionId, snippets, status, t]); useEffect(() => { return registerScreenSnapshotProvider(sessionId, () => { const term = termRef.current; if (!term?.buffer?.active) { // Hibernated terminals: prefer last captured viewport for script sync. const hibernatedViewport = hibernateViewportSnapshotRef.current || hibernateSnapshotRef.current || hibernatePendingBufferRef.current; if (hibernatedViewport) { const lines = hibernatedViewport.split("\n"); return { rows: Math.max(lines.length, 1), cols: 80, currentRow: Math.max(lines.length - 1, 0), lines, source: "hibernate-viewport", }; } return { rows: 24, cols: 80, currentRow: 0, lines: [] }; } const buffer = term.buffer.active; const lines: string[] = []; for (let row = 0; row < term.rows; row += 1) { lines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? ''); } return { rows: term.rows, cols: term.cols, currentRow: buffer.baseY + buffer.cursorY, lines, }; }); }, [sessionId]); useEffect(() => { const startHandler = (event: Event) => { const detail = (event as CustomEvent<{ sessionId?: string }>).detail; if (detail?.sessionId !== sessionId) return; void recorderRef.current.startRecording(); }; const stopHandler = (event: Event) => { const detail = (event as CustomEvent<{ sessionId?: string }>).detail; if (detail?.sessionId !== sessionId) return; if (!recorderRef.current.isRecording) return; void recorderRef.current.stopRecording().then(({ code }) => { setRecordedCode(code); setSaveRecordingOpen(true); }); }; const limitHandler = (event: Event) => { const detail = (event as CustomEvent<{ sessionId?: string; code?: string }>).detail; if (detail?.sessionId !== sessionId) return; setRecordedCode(detail.code ?? ''); setSaveRecordingOpen(true); }; window.addEventListener('netcatty:script:recording:start', startHandler); window.addEventListener('netcatty:script:recording:stop', stopHandler); window.addEventListener(SCRIPT_RECORDING_LIMIT_EVENT, limitHandler); return () => { window.removeEventListener('netcatty:script:recording:start', startHandler); window.removeEventListener('netcatty:script:recording:stop', stopHandler); window.removeEventListener(SCRIPT_RECORDING_LIMIT_EVENT, limitHandler); }; }, [sessionId]); useEffect(() => { if (recorder.isRecording) { setScriptRecordingState(sessionId, recorder.isPaused); } else if (getScriptRecordingSnapshot().sessionId === sessionId) { setScriptRecordingState(null); } }, [recorder.isRecording, recorder.isPaused, sessionId]); useEffect(() => () => { if (getScriptRecordingSnapshot().sessionId === sessionId) { setScriptRecordingState(null); } }, [sessionId]); useEffect(() => { setConnectionReuseFellBack(false); if (!reuseConnectionFromSessionId) return undefined; return terminalBackend.onConnectionReuseFallback?.((fallbackSessionId) => { if (fallbackSessionId === sessionId) { setConnectionReuseFellBack(true); } }); }, [reuseConnectionFromSessionId, sessionId, terminalBackend]); const synchronizedFitSchedulerRef = useRef | null>(null); if (!synchronizedFitSchedulerRef.current) { synchronizedFitSchedulerRef.current = createSynchronizedOutputFitScheduler(); } useEffect(() => () => synchronizedFitSchedulerRef.current?.dispose(), []); type SafeFitOptions = { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean }; const pendingWriteSafeFitRef = useRef<{ term: XTerm; options: SafeFitOptions; } | null>(null); const safeFit = (options?: SafeFitOptions) => { const fitAddon = fitAddonRef.current; if (!fitAddon) return; if (!isRendererActiveRef.current && !options?.allowHidden) { lastFittedSizeRef.current = null; return; } const container = containerRef.current; if (!container) return; const width = container.clientWidth; const height = container.clientHeight; if (width <= 0 || height <= 0) { // Terminal is hidden — invalidate the cached size so that when it // becomes visible again, a non-forced fit won't be suppressed by a // stale size match (e.g. after font metrics changed while hidden). lastFittedSizeRef.current = null; return; } if (!options?.force) { const lastSize = lastFittedSizeRef.current; if (lastSize && lastSize.width === width && lastSize.height === height) { autocompleteRepositionRef.current?.(); return; } } const runFit = () => { try { const term = termRef.current; if (!term) return; if (hasPendingTerminalWrites(term)) { let pending = pendingWriteSafeFitRef.current; if (pending?.term === term) { pending.options = { force: pending.options.force || options?.force, requireVisible: pending.options.requireVisible || options?.requireVisible, immediate: pending.options.immediate || options?.immediate, allowHidden: pending.options.allowHidden || options?.allowHidden, }; return; } pending = { term, options: { ...options } }; pendingWriteSafeFitRef.current = pending; const fitRequest = pending; const fitSessionId = sessionRef.current; void (async () => { let ranFit = false; const settled = await runWithTerminalOutputPausedAfterWritesSettle( term, fitSessionId, terminalBackend, () => { if ( pendingWriteSafeFitRef.current !== fitRequest || termRef.current !== term || sessionRef.current !== fitSessionId ) return; pendingWriteSafeFitRef.current = null; ranFit = true; safeFit({ ...fitRequest.options, immediate: true }); }, () => sessionRef.current === fitSessionId && termRef.current === term, ); if (ranFit) return; if (pendingWriteSafeFitRef.current !== fitRequest || termRef.current !== term) return; // A reconnect can reuse the same xterm while replacing the backend // session. Retry against the new source instead of resizing it // under the old session's pause. if (sessionRef.current !== fitSessionId) { pendingWriteSafeFitRef.current = null; setTimeout(() => safeFit(fitRequest.options), 0); return; } if (!settled) { setTimeout(() => { if (pendingWriteSafeFitRef.current !== fitRequest || termRef.current !== term) return; pendingWriteSafeFitRef.current = null; safeFit(fitRequest.options); }, 50); } })(); return; } // Keep the frozen buffer intact until synchronized output finishes. // Re-enter safeFit to use the latest size, buffer and reading position. if (synchronizedFitSchedulerRef.current?.defer(term, () => { if (termRef.current === term) { safeFitRef.current({ ...options, force: true, immediate: true }); } })) return; const buffer = term.buffer.active; const wasPinnedToBottom = buffer.viewportY >= buffer.baseY; const savedViewportY = buffer.viewportY; const dimensions = fitAddon.proposeDimensions(); if (!dimensions || Number.isNaN(dimensions.cols) || Number.isNaN(dimensions.rows)) return; lastFittedSizeRef.current = { width, height }; // addon-fit 0.11 clears the renderer before resizing, which can show // as a one-frame WebGL blink during layout changes. Resize directly // using the proposed dimensions to preserve the existing behavior // without forcing a blank intermediate frame. if (term.cols !== dimensions.cols || term.rows !== dimensions.rows) { term.resize(dimensions.cols, dimensions.rows); forceSyncRenderAfterResize(term); } else { // Pixel-only layout changes (opening the SFTP side panel, compose // bar, etc.) shrink the container without changing cols/rows, so // term.onResize — which clears the WebGL atlas — never fires. // Stale atlas glyphs then paint as black squares (#2013). xtermRuntimeRef.current?.clearTextureAtlas(); forceSyncRenderAfterResize(term); } // Reflow changes the buffer row before xterm updates its scroll range. // Align first so the relative restore below does not apply that delta twice. alignTerminalViewportScroll(term); // Preserve scroll position across resize (superset/Tabby pattern). if (wasPinnedToBottom) { term.scrollToBottom(); } else { const targetY = Math.min(savedViewportY, term.buffer.active.baseY); if (term.buffer.active.viewportY !== targetY) { term.scrollToLine(targetY); } } term.refresh(0, Math.max(0, term.rows - 1)); if (typeof requestAnimationFrame === "function") { requestAnimationFrame(() => { autocompleteRepositionRef.current?.(); }); } else { autocompleteRepositionRef.current?.(); } } catch (err) { logger.warn("Fit failed", err); } }; if ( XTERM_PERFORMANCE_CONFIG.resize.useRAF && typeof requestAnimationFrame === "function" && !options?.immediate ) { requestAnimationFrame(runFit); } else { runFit(); } }; const prevIsResizingRef = useRef(isResizing); const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false); disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false; // True only while createXTermRuntime is programmatically restoring the // selection right after a keystroke (preserveSelectionOnInput). Lets // copy-on-select skip a redundant clipboard write that would otherwise // clobber whatever the user copied elsewhere in the meantime. const isRestoringSelectionRef = useRef(false); const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true); scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true; const clearWipesScrollbackRef = useRef(terminalSettings?.clearWipesScrollback ?? true); clearWipesScrollbackRef.current = terminalSettings?.clearWipesScrollback ?? true; const normalizeTextOnCopyRef = useRef(terminalSettings?.normalizeTextOnCopy ?? true); normalizeTextOnCopyRef.current = terminalSettings?.normalizeTextOnCopy ?? true; const autoUploadClipboardImageOnPasteRef = useRef(terminalSettings?.autoUploadClipboardImageOnPaste ?? false); autoUploadClipboardImageOnPasteRef.current = terminalSettings?.autoUploadClipboardImageOnPaste ?? false; const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => { if (!termRef.current) return; scrollTerminalToBottomAfterInputIfEnabled( termRef.current, terminalSettingsRef.current, data, ); }, []); useEffect(() => { const bridge = netcattyBridge.get(); const dispose = bridge?.onScriptSessionInput?.(({ sessionId: sid, data }) => { if (sid !== sessionId) return; scrollToBottomAfterProgrammaticInput(data); }); return dispose; }, [scrollToBottomAfterProgrammaticInput, sessionId]); useEffect(() => { if (!activeScriptRun) return; termRef.current?.scrollToBottom(); }, [activeScriptRun]); const broadcastUserPasteData = useCallback((data: string) => { if ( !passwordPromptActiveRef.current && sessionRef.current && isBroadcastEnabledRef.current && onBroadcastInputRef.current ) { onBroadcastInputRef.current(data, sessionId); return true; } return false; }, [sessionId]); const executeSnippetCommand = useCallback(async ( command: string, noAutoRun?: boolean, options?: { broadcast?: boolean; multiLineRunMode?: Snippet["multiLineRunMode"]; /** When false, skip term.focus() so multi-tab fan-out does not steal focus. */ focus?: boolean; }, ): Promise => { // Hidden-tab hibernation clears termRef. Wake via the *connected* path so // reattachSession restores output listeners before we write the snippet. // (The reconnect helper skips reattach and leaves the tab detached.) if ((!termRef.current || !sessionRef.current) && hibernatedRef.current) { const wake = wakeHibernatedRuntimeForConnectedRef.current; if (wake) { try { await wake(); } catch { // Fall through; we still report failure if term is unavailable. } } } const term = termRef.current; const id = sessionRef.current; if (!term || !id) return false; // Multi-tab fan-out uses focus:false + broadcast:false. After wake, buffered // output may have activated a password prompt — refuse to inject the snippet. if ( options?.focus === false && options?.broadcast === false && passwordPromptActiveRef.current ) { return false; } let data = normalizeLineEndings(command); const lineDelayMs = shouldDelayAutoRunSnippetInput(data, { noAutoRun, multiLineRunMode: options?.multiLineRunMode, }) ? AUTO_RUN_SNIPPET_LINE_DELAY_MS : undefined; const isMultiLine = data.includes('\n'); // Wrap in bracketed paste BEFORE appending \r so the Enter is sent // outside the paste markers — otherwise shells treat it as pasted text // instead of a submit action. if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) { data = wrapBracketedPaste(data); } if (!noAutoRun) data = `${data}\r`; // Broadcast the exact bytes the active session receives so peers mirror it, // including the bracketed-paste wrapping and the auto-run \r. Broadcasting // the raw (un-wrapped) form would let a multi-line noAutoRun snippet run // line-by-line on peers, since handleBroadcastInput writes bytes directly // without re-wrapping. Without broadcasting at all, accepting a snippet in // broadcast mode would clear peer input (the clear keystrokes already go // through the broadcast-aware path) but never send the command. const sensitive = passwordPromptActiveRef.current; if (!sensitive && options?.broadcast !== false && isBroadcastEnabledRef.current && onBroadcastInputRef.current) { onBroadcastInputRef.current(data, sessionId, { noAutoRun, ...(lineDelayMs ? { lineDelayMs } : {}), }); } data = prepareProgrammaticSudoInput(data); terminalBackend.writeToSession(id, data, { automated: true, sensitive, ...(lineDelayMs ? { lineDelayMs } : {}), }); scrollToBottomAfterProgrammaticInput(data); if (options?.focus !== false) { term.focus(); } return true; }, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]); const executeSnippet = useCallback(async (snippet: Snippet) => { if (isScriptSnippet(snippet)) { if (!snippetCanRunInTerminal(snippet, host)) { toast.error(t('scripts.targets.currentHostMismatch')); return; } try { await runAutomationScript({ snippet, sessionId, sessionMeta: { connected: true, name: scriptSessionName, hostname: host.hostname, username: host.username, }, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); } return; } const command = await resolveSnippetCommand(snippet); if (command === null) return; executeSnippetCommand(command, snippet.noAutoRun, { multiLineRunMode: snippet.multiLineRunMode, }); }, [executeSnippetCommand, host, scriptSessionName, sessionId, t]); const onSnippetShortkeyRef = useRef(executeSnippet); onSnippetShortkeyRef.current = executeSnippet; const handleClipboardImageUploadResult = useCallback((result: RemoteClipboardImageUploadResult) => { const messageKey = getRemoteClipboardImageUploadErrorMessageKey(result); if (messageKey) toast.error(t(messageKey)); }, [t]); const terminalContextActions = useTerminalContextActions({ termRef, sourceSessionId: sessionId, sessionRef, scrollOnPasteRef, clearWipesScrollbackRef, normalizeTextOnCopyRef, isBroadcastEnabledRef, onBroadcastInputRef, passwordPromptActiveRef, isLocalConnection, supportsRemoteImagePaste, autoUploadClipboardImageOnPasteRef, terminalBackend, getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }), scrollToBottomAfterProgrammaticInput, onClipboardImageUploadResult: handleClipboardImageUploadResult, }); // Kept fresh on every render so the mouseTracking capture handler at // handleContextMenuCapture (which is bound once per sessionId) can // still invoke the latest paste / select-word callbacks without // re-binding on every action identity change. See #941. const terminalContextActionsRef = useRef(terminalContextActions); terminalContextActionsRef.current = terminalContextActions; const handleAddSelectionToAI = useCallback(() => { const term = termRef.current; if (!term) return; const selection = getHistoryPreviewSelectionFromRoot(term.element?.parentElement) || getTerminalSelectionForClipboard( term, terminalSettings?.normalizeTextOnCopy ?? true, ); if (!selection.trim()) return; onAddSelectionToAI?.(sessionId, selection); }, [onAddSelectionToAI, sessionId, terminalSettings?.normalizeTextOnCopy]); const handleSetTerminalEncoding = useCallback((encoding: TerminalEncodingPreference) => { setTerminalEncoding(encoding); setRememberedTerminalEncoding(encoding); userPickedEncodingRef.current = true; if (host.id && host.protocol !== 'local' && !host.id.startsWith('local-') && !host.id.startsWith('serial-')) { handleUpdateHostFromTerminal({ id: host.id, charset: terminalEncodingPreferenceToCharset(encoding), }); } if (sessionRef.current) { setSessionEncoding(sessionRef.current, encoding); } }, [handleUpdateHostFromTerminal, host.id, host.protocol, setRememberedTerminalEncoding, setSessionEncoding]); const handleOpenSFTP = useCallback(async () => { if (onOpenSftp) { // Delegate to parent (TerminalLayer) for shared SFTP side panel const initialPath = await resolveSftpInitialPath(); onOpenSftp( host, initialPath, undefined, sessionId, resolveSftpReuseSourceSessionId(host, sessionId), ); return; } // Fallback: toggle internal SFTP state (shouldn't happen with new architecture) if (showSFTP) { setShowSFTP(false); return; } setShowSFTP(true); }, [host, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]); const handleSendYmodem = useCallback(async () => { if (!isSerialConnection || statusRef.current !== "connected") return; if (!selectFileAvailable() || !serialYmodemAvailable()) { toast.error(t("terminal.ymodem.unavailable")); return; } try { const defaultPath = getRememberedYmodemSendDefaultPath(); const filePath = await selectFile( t("terminal.ymodem.selectFile"), defaultPath, [{ name: t("terminal.ymodem.allFiles"), extensions: ["*"] }], ); if (!filePath) return; rememberYmodemSendFilePath(filePath); const fileName = filePath.split(/[\\/]/).pop() || filePath; toast.info(t("terminal.ymodem.started", { fileName })); setYmodemInProgress(true); const result = await sendSerialYmodem(sessionRef.current || sessionId, filePath); if (result.success) { toast.success(t("terminal.ymodem.complete", { fileName: result.fileName || fileName })); } else { toast.error(result.error || t("terminal.ymodem.failed")); } } catch (error) { toast.error(error instanceof Error ? error.message : t("terminal.ymodem.failed")); } finally { setYmodemInProgress(false); } }, [isSerialConnection, selectFile, selectFileAvailable, sendSerialYmodem, serialYmodemAvailable, sessionId, t]); const handleReceiveYmodem = useCallback(async () => { if (!isSerialConnection || statusRef.current !== "connected") return; if (!selectDirectoryAvailable() || !serialYmodemReceiveAvailable()) { toast.error(t("terminal.ymodem.unavailable")); return; } try { const destinationDir = await selectDirectory(t("terminal.ymodem.selectReceiveDirectory")); if (!destinationDir) return; toast.info(t("terminal.ymodem.receiveStarted")); setYmodemInProgress(true); const result = await receiveSerialYmodem(sessionRef.current || sessionId, destinationDir); if (result.success) { if (result.fileCount && result.fileCount > 1) { toast.success(t("terminal.ymodem.receiveCompleteMultiple", { count: result.fileCount })); } else if (result.fileName) { toast.success(t("terminal.ymodem.receiveComplete", { fileName: result.fileName })); } else { toast.success(t("terminal.ymodem.receiveEmpty")); } } else { toast.error(t("terminal.ymodem.receiveFailed")); } } catch { toast.error(t("terminal.ymodem.receiveFailed")); } finally { setYmodemInProgress(false); } }, [ isSerialConnection, receiveSerialYmodem, selectDirectory, selectDirectoryAvailable, serialYmodemReceiveAvailable, sessionId, t, ]); const handleCancelConnect = () => { if (pendingHostKeyRequestId) { void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false); } clearAutoReconnect(); retryTokenRef.current = null; reconnectPreparationTokenRef.current = null; restoreCwdIntentRef.current = null; // Cancel must invalidate the boot the same way Disconnect does. Unmount is // no longer the only path that flips boot-active: under StrictMode the pane // can survive this click, so a late startSSH/startMosh attach could revive // the aborted attempt. Bump the epoch here and let closeSession target the // pre-bump epoch. invalidateBootEpochForClose(); isBootActiveRef.current = false; setIsCancelling(true); auth.setNeedsAuth(false); auth.setAuthRetryMessage(null); setNeedsHostKeyVerification(false); setPendingHostKeyInfo(null); setPendingHostKeyRequestId(null); setError("Connection cancelled"); setProgressLogs((prev) => [...prev, "Cancelled by user."]); void cleanupSession(); updateStatus("disconnected"); setChainProgress(null); setTimeout(() => setIsCancelling(false), 600); onCloseSession?.(sessionId); }; /** Tear down the live transport but keep the tab/pane for a later reconnect. */ const handleDisconnect = () => { if (attachExistingSession) return; if (statusRef.current === "disconnected") return; if (pendingHostKeyRequestId) { void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false); } clearAutoReconnect({ stopLoop: true }); retryTokenRef.current = null; reconnectPreparationTokenRef.current = null; restoreCwdIntentRef.current = null; // A hibernated reconnect may still be waking the runtime; invalidate that // continuation so it cannot re-arm boot after this disconnect. Keep any // runtime already created — wakeHibernatedRuntime clears hibernatedRef // before returning, and disposing here would leave no term and no // hibernation marker, permanently blocking later Reconnect. reconnectWakeInvalidateModeRef.current = "keep"; reconnectWakeTokenRef.current = null; reconnectWakeInFlightRef.current = false; // Cancel closes the tab (effect cleanup flips boot-active). Disconnect keeps // the pane mounted, so mark boot inactive here or a late startSSH/startMosh // attach can still bring the session back after the user asked to stop. // Bump the epoch so a later reconnect cannot revive this aborted attempt, // but closeSession must still target the pre-bump epoch. invalidateBootEpochForClose(); isBootActiveRef.current = false; setIsCancelling(true); auth.setNeedsAuth(false); auth.setAuthRetryMessage(null); setNeedsHostKeyVerification(false); setPendingHostKeyInfo(null); setPendingHostKeyRequestId(null); setError(null); setProgressLogs((prev) => [...prev, "Disconnected by user."]); void cleanupSession({ retainOwnership: true }); updateStatus("disconnected"); setChainProgress(null); setIsDisconnectedDialogDismissed(false); window.dispatchEvent(new CustomEvent("netcatty:terminal-session-disconnected", { detail: { sessionId }, })); setTimeout(() => setIsCancelling(false), 600); }; const handleDismissDisconnectedDialog = () => { setIsDisconnectedDialogDismissed(true); queueMicrotask(() => termRef.current?.focus()); }; const handleCloseDisconnectedSession = () => { clearAutoReconnect(); retryTokenRef.current = null; reconnectPreparationTokenRef.current = null; restoreCwdIntentRef.current = null; onCloseSession?.(sessionId); }; const handleHostKeyClose = () => { setNeedsHostKeyVerification(false); setPendingHostKeyInfo(null); setPendingHostKeyRequestId(null); handleCancelConnect(); }; const handleHostKeyContinue = () => { if (pendingHostKeyRequestId) { void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, true, false); } setNeedsHostKeyVerification(false); if (pendingConnectionRef.current) { pendingConnectionRef.current(); pendingConnectionRef.current = null; } setPendingHostKeyInfo(null); setPendingHostKeyRequestId(null); }; const handleHostKeyAddAndContinue = () => { if (pendingHostKeyInfo && onAddKnownHost) { onAddKnownHost(createKnownHostFromHostKeyInfo(pendingHostKeyInfo, host)); } if (pendingHostKeyRequestId) { void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, true, true); } setNeedsHostKeyVerification(false); if (pendingConnectionRef.current) { pendingConnectionRef.current(); pendingConnectionRef.current = null; } setPendingHostKeyInfo(null); setPendingHostKeyRequestId(null); }; const startReconnect = async (mode: "manual" | "auto" = "manual") => { if (attachExistingSession) return; if ( reconnectPreparationTokenRef.current !== null || reconnectWakeInFlightRef.current ) return; setManualReconnectActive(mode === "manual"); if (mode === "manual") terminalReconnectRegistry.setActive(sessionId, true); const reconnectAttemptMessage = mode === "auto" ? t("terminal.progress.autoReconnectAttempt", { attempt: autoReconnectAttemptRef.current }) : t("terminal.progress.reconnecting"); setReconnectNoticeMessage(reconnectAttemptMessage); const restoreDisconnectedAfterReconnectFailure = () => { if (mode === "manual") setReconnectNoticeMessage(null); updateStatus("disconnected"); }; if (!termRef.current && hibernatedRef.current) { if (reconnectWakeInFlightRef.current) return; const wakeForReconnect = wakeHibernatedRuntimeForReconnectRef.current; if (!wakeForReconnect) { restoreDisconnectedAfterReconnectFailure(); return; } reconnectWakeInFlightRef.current = true; const wakeToken = Symbol(); reconnectWakeInvalidateModeRef.current = "dispose"; reconnectWakeTokenRef.current = wakeToken; if (mode === "auto") updateStatus("connecting"); void wakeForReconnect().then((woke) => { if (reconnectWakeTokenRef.current !== wakeToken) { reconnectWakeInFlightRef.current = false; if (reconnectWakeInvalidateModeRef.current === "dispose") { disposeRuntimeOnly(); } return; } reconnectWakeTokenRef.current = null; reconnectWakeInFlightRef.current = false; if (woke) { startReconnectRef.current?.(mode); return; } restoreDisconnectedAfterReconnectFailure(); }).catch(() => { if (reconnectWakeTokenRef.current !== wakeToken) { reconnectWakeInFlightRef.current = false; if (reconnectWakeInvalidateModeRef.current === "dispose") { disposeRuntimeOnly(); } return; } reconnectWakeTokenRef.current = null; reconnectWakeInFlightRef.current = false; restoreDisconnectedAfterReconnectFailure(); }); return; } if (!termRef.current) { restoreDisconnectedAfterReconnectFailure(); return; } // Claim the retry before awaiting either script cancellation or backend // cleanup. A second reconnect/cancel invalidates this continuation. const retryToken = Symbol("retry"); retryTokenRef.current = retryToken; reconnectPreparationTokenRef.current = retryToken; const retryTokenStillCurrent = () => retryTokenRef.current === retryToken; const finishReconnectPreparation = () => { if (reconnectPreparationTokenRef.current === retryToken) { reconnectPreparationTokenRef.current = null; } }; const connectAutomationBatch = connectScriptsBatchRef.current; if (connectAutomationBatch) { try { await cancelConnectAutomationBatch(connectAutomationBatch); } catch (error) { finishReconnectPreparation(); const message = error instanceof Error ? error.message : String(error); toast.error(message); if (mode === "auto" && retryTokenStillCurrent()) { // Return to disconnected so the existing auto-reconnect loop can // schedule another attempt, including another exact stop request. updateStatus("disconnected"); } else if (mode === "manual" && retryTokenStillCurrent()) { restoreDisconnectedAfterReconnectFailure(); } return; } if (!retryTokenStillCurrent()) { finishReconnectPreparation(); return; } if (connectScriptsBatchRef.current === connectAutomationBatch) { connectScriptsBatchRef.current = null; connectScriptsInFlightRef.current = false; } } if (mode === "manual") { clearAutoReconnect({ clearNotice: false }); // Manual reconnect skips the disconnected status transition that normally // clears these refs, so onConnect scripts would otherwise stay consumed. if (shouldResetConnectAutomationOnReconnect( restoreState === "restored-disconnected" ? "restored" : "manual", )) { // The prior batch is now stopped, so its callbacks cannot mutate these // newly allocated guards for the fresh manual connection. connectScriptsConsumedRef.current = false; connectScriptsCompletedIdsRef.current = new Set(); connectScriptsInFlightRef.current = false; } prepareRestoredReconnect(); // A clone's first connection can fail (auth/host-key/transport) before the // inherited `cd` is consumed. prepareRestoredReconnect() just cleared the // intent for non-restored sessions, so re-arm it here; the callback no-ops // once the cwd was consumed or when there is no pending inherited cwd. prepareInitialCwdIntent(); } else { // An automatic reconnect replaces the transport but remains the same user // session. Stop old automation above, then preserve the existing policy of // not starting onConnect scripts again on the replacement transport. connectScriptsConsumedRef.current = true; restoreCwdIntentRef.current = null; suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic"); } try { await cleanupSession({ retainOwnership: true }); } catch (error) { finishReconnectPreparation(); if (mode === "manual" && retryTokenStillCurrent()) { restoreDisconnectedAfterReconnectFailure(); } throw error; } if (!retryTokenStillCurrent()) { finishReconnectPreparation(); return; } const term = termRef.current; if (!term) { finishReconnectPreparation(); if (mode === "manual" && retryTokenStillCurrent()) { restoreDisconnectedAfterReconnectFailure(); } return; } // closeSession wiped preload ready listeners; re-arm before startMosh so a // fast handshake cannot emit netcatty:mosh:ready into an empty map. prepareMoshReadySubscription(); // Keep the same retry token through the queued writes. If the user cancels / // closes / unmounts / kicks off another retry while the chained writes are // queued, the token is invalidated and callbacks abort before opening a // ghost backend session with no owning UI. const retryStillActive = () => retryTokenStillCurrent() && termRef.current === term; bootEpochRef.current += 1; // This start is a reconnect: force the bridge to dial a fresh connection // instead of borrowing a parked/live transport, so the server performs a // new login and picks up remote supplementary-group changes (#3293). requireFreshConnectionOnReconnectRef.current = true; publishBootEpoch(); isBootActiveRef.current = true; auth.resetForRetry(); terminalDataCapturedRef.current = false; if (mode === "manual") { hasRunStartupCommandRef.current = false; } setIsDisconnectedDialogDismissed(false); setConnectionReuseFellBack(false); setConnectionReuseAttemptSourceId(undefined); updateStatus("connecting"); setError(null); setProgressLogs((prev) => ( [...prev, reconnectAttemptMessage] )); setShowLogs(true); const startNewSession = () => { if (!retryStillActive()) { finishReconnectPreparation(); return; } finishReconnectPreparation(); xtermRuntimeRef.current?.resetKittyConnectionInputState(); resetKittyKeyboardModeStateForSession(terminalSettingsRef); if (effectiveTerminalProtocol.startsWith("plugin:")) { sessionStarters.startPluginConnection(term); } else if (host.protocol === "serial") { sessionStarters.startSerial(term); } else if (effectiveTerminalProtocol === 'local') { sessionStarters.startLocal(term); } else if (host.protocol === "telnet") { sessionStarters.startTelnet(term); } else if (effectiveTerminalProtocol === 'mosh') { // Defensive: xterm.write may fire after another cleanup raced us. prepareMoshReadySubscription(); sessionStarters.startMosh(term); } else if (effectiveTerminalProtocol === 'et') { sessionStarters.startEt(term); } else { sessionStarters.startSSH(term); } }; // Chain the whole preparation through xterm.write callbacks so everything // lands in strict order — see #695. xterm.write is async, so without // chaining, a fast reconnect path (local/serial especially) can interleave // the new session's first bytes with our reset sequence, corrupting the // first screen. // // 1. Exit the alternate screen first. preserveTerminalViewportInScrollback // is a no-op on the alt buffer (disconnect while in vim/less/top), so // we must be on the normal buffer before preserving. term.write('\x1b[?1049l', () => { if (!retryStillActive()) { finishReconnectPreparation(); return; } // 2. Push the previous session's viewport into scrollback so the user // can still read it after reconnect. preserveTerminalViewportInScrollback(term); // 3. Soft terminal reset (DECSTR, \x1b[!p) resets VT220-era modes that // full-screen apps may have left on — DECCKM (otherwise arrow keys // emit SS3 and break readline history), keypad mode, SGR, // insert/replace, origin, cursor visibility — without clearing the // buffer. DECSTR does not cover xterm-specific extensions, so also // explicitly disable mouse tracking (1000/1002/1003/1006) and // bracketed paste (2004). Finally home the cursor. term.write( '\x1b[!p\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[H', // 4. Only now — after every prep byte has been applied to the // terminal — start the new session, so its first output can't // interleave with the reset sequence. startNewSession, ); }); }; startReconnectRef.current = startReconnect; const handleRetry = () => { startReconnect("manual"); }; manualReconnectRequestRef.current = handleRetry; useEffect(() => { if (attachExistingSession) return undefined; return terminalReconnectRegistry.register( sessionId, () => manualReconnectRequestRef.current(), ); }, [attachExistingSession, sessionId]); const isReconnectActive = autoReconnectLoopActiveRef.current || manualReconnectActive; const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({ status, isLocalConnection, isSerialConnection, isDisconnectedDialogDismissed, disconnectedNoticeMode: terminalSettings.disconnectedNoticeMode, hasEverConnected: hasEverConnectedRef.current, restoreState, isReconnectActive, requiresUserInput: auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput, hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({ reuseConnectionFromSessionId: connectionReuseAttemptSourceId, host, connectionReuseFellBack, }), }); const showDisconnectedTerminalNotice = shouldShowTerminalDisconnectedNotice({ status, disconnectedNoticeMode: terminalSettings.disconnectedNoticeMode, hasEverConnected: hasEverConnectedRef.current, restoreState, isReconnectActive, requiresUserInput: auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput, }); const { handleDragEnter, handleDragLeave, handleDragOver, handleDrop, isDraggingOver, } = useTerminalDragDrop({ host, resolvedLoginUsername, resolvedSudoPassword: resolvedSudoAutofillPassword, isLocalConnection, isNetworkDevice, onOpenSftp, resolveSftpInitialPath, scrollToBottomAfterProgrammaticInput, sessionId, sessionRef, status, t, terminalBackend, isSensitiveInput: () => passwordPromptActiveRef.current, termRef, }); useTerminalFilePaste({ isLocalConnection, status, termRef, sessionRef, terminalBackend, isSensitiveInput: () => passwordPromptActiveRef.current, scrollOnPasteRef, onPasteData: broadcastUserPasteData, scrollToBottomAfterProgrammaticInput, containerRef, autoUploadClipboardImage: supportsRemoteImagePaste && terminalSettings?.autoUploadClipboardImageOnPaste === true, getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }), onClipboardImageUploadResult: handleClipboardImageUploadResult, }); const handleToggleSessionLog = useCallback(async () => { const currentSessionId = sessionRef.current ?? sessionId; if (!currentSessionId) { toast.error("Session log bridge is unavailable"); return; } try { const currentStatus = await getManualSessionLogStatus({ sessionId: currentSessionId }); if (currentStatus?.isLogging) { const stopResult = await stopManualSessionLog({ sessionId: currentSessionId }); if (stopResult?.stopped) { setIsSessionLogging(false); } if (!stopResult?.success) { toast.error(stopResult?.error || "Failed to stop session log"); } return; } // Choose the destination first, then re-sample terminal state. The save // dialog can stay open long enough for the user to enter/leave vim, so // alternate-screen / initial-line must be captured at stream activation. const chooseResult = await chooseManualSessionLogPath({ sessionId: currentSessionId, sessionName: host.label || host.hostname || currentSessionId, preferredDirectory: sessionLog?.directory, format: sessionLog?.format, }); if (!chooseResult?.success) { toast.error(chooseResult?.error || "Failed to start session log"); return; } if (chooseResult.canceled || !chooseResult.selectionToken) return; const startResult = await startManualSessionLog({ sessionId: currentSessionId, sessionName: host.label || host.hostname || currentSessionId, selectionToken: chooseResult.selectionToken, format: sessionLog?.format, timestampsEnabled: sessionLog?.timestampsEnabled, initialLine: termRef.current ? getSessionLogInitialLine(termRef.current) : "", alternateScreenActive: termRef.current ? isTerminalAlternateScreenActive(termRef.current) : false, }); if (startResult?.success) { if (!startResult?.started && startResult?.canceled) return; setIsSessionLogging(!!startResult?.started); } else { toast.error(startResult?.error || "Failed to start session log"); } } catch (err) { logger.error("[Terminal] Failed to toggle manual session log:", err); toast.error("Failed to toggle session log"); } }, [ chooseManualSessionLogPath, getManualSessionLogStatus, host.hostname, host.label, sessionId, sessionLog?.directory, sessionLog?.format, sessionLog?.timestampsEnabled, startManualSessionLog, stopManualSessionLog, ]); useEffect(() => { const currentSessionId = sessionRef.current ?? sessionId; if (!currentSessionId) { setIsSessionLogging(false); return; } let cancelled = false; void getManualSessionLogStatus({ sessionId: currentSessionId }).then((result) => { if (!cancelled) setIsSessionLogging(!!result?.isLogging); }).catch(() => { if (!cancelled) setIsSessionLogging(false); }); return () => { cancelled = true; }; }, [getManualSessionLogStatus, sessionId, status]); const handleToolbarRecordingToggle = useCallback(() => { const recording = getScriptRecordingSnapshot(); if (recording.sessionId && recording.sessionId !== sessionId) { toast.error(t('scripts.recording.alreadyActive')); return; } if (recording.sessionId === sessionId) { window.dispatchEvent(new CustomEvent('netcatty:script:recording:stop', { detail: { sessionId } })); return; } window.dispatchEvent(new CustomEvent('netcatty:script:recording:start', { detail: { sessionId } })); }, [sessionId, t]); const renderControls = useCallback((opts?: { showClose?: boolean; restorePaneLayout?: boolean }) => ( { void executeSnippet(snippet); }} onDeleteSnippets={onDeleteSnippets} onOpenSFTP={handleOpenSFTP} onSendYmodem={isSerialConnection ? handleSendYmodem : undefined} onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined} onOpenScripts={onOpenScripts ?? (() => {})} onOpenHistory={onOpenHistory} onOpenTheme={onOpenTheme ?? (() => {})} onConfigureOsc7={shouldOfferOsc7SetupAction({ protocol: host.protocol, isLocalConnection, isSerialConnection, isNetworkDevice, }) ? () => setOsc7SetupOpen(true) : undefined} onUpdateHost={handleUpdateHostFromTerminal} showClose={opts?.showClose} onClose={() => { if (opts?.restorePaneLayout) { onTogglePaneMagnification?.(); return; } onCloseSession?.(sessionId); }} closeLabel={t(opts?.restorePaneLayout ? 'terminal.paneMagnification.restore' : 'terminal.toolbar.closeSession')} isSearchOpen={isSearchOpen} onToggleSearch={handleToggleSearch} showLogButton onToggleSessionLog={handleToggleSessionLog} isSessionLogging={isSessionLogging} isSessionLogDisabled={status !== "connected" && !isSessionLogging} isComposeBarOpen={inWorkspace ? isWorkspaceComposeBarOpen : isComposeBarOpen} onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)} terminalEncoding={terminalEncoding} onSetTerminalEncoding={handleSetTerminalEncoding} recordingIndicator={recorder.isRecording ? ( { void recorder.stopRecording().then(({ code }) => { setRecordedCode(code); setSaveRecordingOpen(true); }); }} /> ) : undefined} onStartRecording={status === 'connected' ? handleToolbarRecordingToggle : undefined} /> ), [ compactToolbar, executeSnippet, handleOpenSFTP, handleReceiveYmodem, handleSendYmodem, handleSetTerminalEncoding, handleToggleSessionLog, handleToggleSearch, handleToolbarRecordingToggle, host, inWorkspace, isLocalConnection, isNetworkDevice, isSerialConnection, isComposeBarOpen, isSearchOpen, isSessionLogging, isWorkspaceComposeBarOpen, onCloseSession, onDeleteSnippets, onOpenScripts, onOpenHistory, onOpenTheme, onToggleComposeBar, onTogglePaneMagnification, setIsComposeBarOpen, handleUpdateHostFromTerminal, sessionId, snippetPackages, snippets, status, terminalEncoding, t, recorder, workspaceId, ]); const terminalPreviewVars = useMemo(() => { const { background, foreground, cursor } = effectiveTheme.colors; return { ['--terminal-ui-bg' as never]: background, ['--terminal-ui-fg' as never]: foreground, ['--terminal-ui-border' as never]: `color-mix(in srgb, ${foreground} 8%, ${background} 92%)`, ['--terminal-ui-toolbar-btn' as never]: `color-mix(in srgb, ${background} 88%, ${foreground} 12%)`, ['--terminal-ui-toolbar-btn-hover' as never]: `color-mix(in srgb, ${background} 78%, ${foreground} 22%)`, ['--terminal-ui-toolbar-btn-active' as never]: `color-mix(in srgb, ${cursor} 78%, ${background} 22%)`, }; }, [effectiveTheme.colors]); const effectiveComposeBarOpen = inWorkspace ? !!isWorkspaceComposeBarOpen : isComposeBarOpen; xTermRuntimeContextRef.current = { host, fontFamilyId, resolvedFontFamily, fontSize: effectiveFontSize, terminalTheme: effectiveTheme, terminalSettingsRef, kittyKeyboardProtocolEnabled: kittyKeyboardProtocolEnabledForSession, terminalBackend, sessionRef, hotkeySchemeRef, disableTerminalFontZoomRef, keyBindingsRef, onHotkeyActionRef, onTerminalFontSizeChange, onOpenExternalError, isBroadcastEnabledRef, onBroadcastInputRef, snippetsRef, onSnippetShortkeyRef, sessionId, statusRef, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onTrustedCommandSubmitted: pluginAwareOnCommandSubmitted, onCommandCompleted: pluginAwareOnCommandCompleted, requestPluginTerminalProviders, pluginProviderVisible: isVisible, isPluginTerminalProviderAvailable, onResize: pluginTerminalLifecycle.onResized, onAlternateScreenChange: pluginTerminalLifecycle.onAlternateScreenChanged, commandBufferRef, scriptRecorderRef: recorderRef, passwordPromptActiveRef, allowHostStyleGreaterThanPrompt: isNetworkDevice, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, promptLineBreakStateRef, sudoAutofillRef, requestSearchFocus, serialLocalEcho: serialConfig?.localEcho, serialLineMode: serialConfig?.lineMode, serialLineBufferRef, telnetLocalEchoRef, onTerminalLogData: captureTerminalLogData, terminalOutputHistory: terminalOutputHistoryRef.current, onCwdChange: (cwd: string) => { pluginAwareOnRuntimeCwdChange(cwd, { source: 'osc7' }); }, onTitleChange: (title: string | null) => { pluginAwareOnTerminalTitleChange(sessionId, title); }, onBell: () => { onTerminalBell?.(sessionId); }, onOscNotification: (notification) => { handleTerminalOscNotification({ notification, mode: terminalSettingsRef.current?.oscNotifications, sessionFocused: isFocusedRef.current, sessionId, fallbackTitle: host.label || host.hostname || "Netcatty", onSessionActivity: () => onTerminalBell?.(sessionId), }); }, onOsc52ReadRequest: handleOsc52ReadRequest, onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true, onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data), onAutocompleteReposition: () => autocompleteRepositionRef.current?.(), terminalContextActionsRef, isRestoringSelectionRef, }; const safeFitRef = useRef(safeFit); safeFitRef.current = safeFit; const wakeSoftHiddenRuntime = useCallback(() => { if (!softHiddenRef.current) return; terminalHiddenRendererStore.clearSoftHidden(sessionId); softHiddenRef.current = false; xtermRuntimeRef.current?.ensureWebglRenderer(); xtermRuntimeRef.current?.clearTextureAtlas(); safeFitRef.current({ force: true }); }, [sessionId]); wakeSoftHiddenRuntimeRef.current = wakeSoftHiddenRuntime; const resumeRendererAfterCancelledHibernateUpgrade = useCallback(() => { if (hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current) return; xtermRuntimeRef.current?.ensureWebglRenderer(); xtermRuntimeRef.current?.clearTextureAtlas(); safeFitRef.current({ force: true }); }, []); resumeRendererAfterCancelledHibernateUpgradeRef.current = resumeRendererAfterCancelledHibernateUpgrade; useEffect(() => { return terminalHiddenRendererStore.subscribe(() => { if (!terminalHiddenRendererStore.consumeEvictionRequest(sessionId)) return; if (!softHiddenRef.current || hibernatedRef.current) return; // The eviction request is consumed above so the store does not keep retrying, // but a session holding inline images stays soft-hidden: full hibernate would // drop bitmaps the text snapshot cannot restore. if (runtimeHasInlineImages()) return; upgradeSoftHiddenRuntimeToHibernate(); }); }, [ runtimeHasInlineImages, sessionId, upgradeSoftHiddenRuntimeToHibernate, ]); const wakeFromHibernateRuntime = useCallback(( getPayload: () => TerminalHibernateWakePayload, options: { sessionConnected: boolean }, ): boolean | Promise => { if (wakeInProgressRef.current) { return wakePromiseRef.current ?? false; } if (hasRuntimeRef.current) { logger.warn("[Terminal] Wake skipped", { sessionId, hasRuntime: hasRuntimeRef.current, }); return false; } const container = containerRef.current; const runtimeContext = xTermRuntimeContextRef.current; if (!container || !runtimeContext) { logger.warn("[Terminal] Wake skipped: missing mount prerequisites", { sessionId, hasContainer: !!container, hasRuntimeContext: !!runtimeContext, }); return false; } if (options.sessionConnected && !sessionRef.current) { logger.warn("[Terminal] Wake skipped: missing backend session", { sessionId }); return false; } wakeInProgressRef.current = true; setPasswordPickerState(null); const stopHibernateDataListener = () => { disposeDataRef.current?.(); disposeDataRef.current = null; }; const stopHibernateListeners = (opts?: { keepPaused?: boolean }) => { const backendId = sessionRef.current; stopHibernateDataListener(); disposeExitRef.current?.(); disposeExitRef.current = null; if (backendId) { flushTerminalSessionFlowAck(backendId); clearTerminalSessionFlowAck(backendId); if (!opts?.keepPaused) { terminalBackend.setSessionFlowPaused?.(backendId, false); } } }; const wakePromise = wakeTerminalFromHibernate({ refs: terminalRuntimeRefs, runtimeContext, container, getPayload, prepareWakeFlow: async () => { const backendId = sessionRef.current; if (!backendId) return true; // Always pause when a backend exists, including reconnect wakes // (sessionConnected=false) that will not reattach. Stopping the // hibernate listener without a pause drops live output into the // preload backlog without flow ACKs until cleanupSession. if (terminalBackend.setSessionFlowPausedAndWait) { const result = await terminalBackend.setSessionFlowPausedAndWait(backendId, true); return result?.success === true; } terminalBackend.setSessionFlowPaused?.(backendId, true); return false; }, takePendingBuffer: () => { const pending = hibernatePendingBufferRef.current + oscNotificationScannerRef.current.flush(); hibernatePendingBufferRef.current = ""; return pending; }, stopHibernateDataListener, setHibernatePendingCapDisabled: (disabled) => { hibernatePendingCapDisabledRef.current = disabled; }, stopHibernateListeners: () => stopHibernateListeners({ keepPaused: true }), restoreAfterFailedWake: (takenPending) => { hibernatePendingCapDisabledRef.current = false; const pendingStillInRef = hibernatePendingBufferRef.current; disposeRuntimeOnly(); const backendId = sessionRef.current; if (!backendId) { // No backend to reattach listeners to; still keep taken bytes for a // later wake of a disconnected hibernated tab. hibernatePendingBufferRef.current = appendHibernatePendingBuffer( takenPending || "", pendingStillInRef, ); return; } beginHibernatedSessionListeners(backendId); // beginHibernatedSessionListeners clears pending; restore take-and-cleared // bytes from this wake plus anything that arrived after the last take. const restored = appendHibernatePendingBuffer( takenPending || "", pendingStillInRef, ); if (restored) { hibernatePendingBufferRef.current = appendHibernatePendingBuffer( restored, hibernatePendingBufferRef.current, ); } }, resumeAfterReattach: () => { hibernatePendingCapDisabledRef.current = false; const backendId = sessionRef.current; if (!backendId) return; terminalBackend.setSessionFlowPaused?.(backendId, false); }, sessionConnected: options.sessionConnected, getSessionConnected: () => getSessionConnectedRef.current(), reattachSession: (term) => { sessionStartersRef.current?.reattachSession(term); }, safeFit: (...args) => safeFitRef.current(...args), resizeSession, forceSyncRenderAfterResize, lastFittedSizeRef, isBootActiveRef, sessionId, updateStatus: (next) => updateStatusRef.current(next), replayChunkBytes: resolveTerminalHibernateReplayChunkBytes(terminalSettings), additionalKeywordHighlightRules: pluginDecorationRulesRef.current, }).then((ok) => ok).catch((err) => { logger.error("[Terminal] Failed to resume from hibernate", err); return false; }).finally(() => { wakeInProgressRef.current = false; if (wakePromiseRef.current === wakePromise) { wakePromiseRef.current = null; } }); wakePromiseRef.current = wakePromise; return wakePromise; }, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings, beginHibernatedSessionListeners]); const wakeHibernatedRuntime = useCallback(async (sessionConnected: boolean): Promise => { if (!hibernatedRef.current) { return Boolean(termRef.current); } const getPayload = (): TerminalHibernateWakePayload => ({ snapshot: hibernateSnapshotRef.current, viewportSnapshot: hibernateViewportSnapshotRef.current || hibernateSnapshotRef.current, scrollbackSnapshot: hibernateScrollbackSnapshotRef.current, pendingBuffer: hibernatePendingBufferRef.current, alternateScreen: hibernateAlternateScreenRef.current, }); logger.info("[Terminal] Waking hibernated runtime", { sessionId, sessionConnected, snapshotChars: hibernateSnapshotRef.current.length, viewportChars: hibernateViewportSnapshotRef.current.length, scrollbackChars: hibernateScrollbackSnapshotRef.current.length, pendingChars: hibernatePendingBufferRef.current.length, }); const accepted = await Promise.resolve(wakeFromHibernateRuntime(getPayload, { sessionConnected })); if (accepted === false || !termRef.current) { return false; } clearHibernateRuntimeState(); // Connected multi-tab fan-out may wake still-hidden peers. Re-arm the // hibernate timer so the full xterm runtime does not stay mounted forever // (visibility/status do not change, so the hibernate effect will not rerun). if (sessionConnected && !isVisibleRef.current) { scheduleHibernateRetry(); } return true; }, [sessionId, wakeFromHibernateRuntime, clearHibernateRuntimeState, scheduleHibernateRetry]); wakeHibernatedRuntimeForReconnectRef.current = () => wakeHibernatedRuntime(false); wakeHibernatedRuntimeForConnectedRef.current = () => wakeHibernatedRuntime(true); const hibernateFileTransferActive = isTerminalFileTransferActive({ zmodemActive: zmodem.active, ymodemInProgress, isDraggingOver, }); hibernateFileTransferActiveRef.current = hibernateFileTransferActive; useTerminalHibernateEffect({ sessionId, isVisible, isVisibleRef, getSessionConnectedRef, status, isSearchOpen, hibernateEnabled: hibernateEnabled, hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings), fileTransferActive: hibernateFileTransferActive, hibernatedRef, softHiddenRef, hibernatePendingBufferRef, hibernateSnapshotRef, hibernateViewportSnapshotRef, hibernateScrollbackSnapshotRef, hibernateContextSnapshotRef, hibernateContextViewportSnapshotRef, hibernateContextScrollbackSnapshotRef, hibernateAlternateScreenRef, hasRuntimeRef, onHibernate: hibernateRuntime, onSoftHideWake: wakeSoftHiddenRuntime, onWake: wakeFromHibernateRuntime, }); useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, 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, 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 }); return ( <> { void pauseScriptRun(activeScriptRun.runId); }} onResume={() => { void resumeScriptRun(activeScriptRun.runId); }} onStop={() => { void stopScriptRun(activeScriptRun.runId); }} onDismiss={dismissScriptOverlay} compactTopChrome={terminalSettings?.showHostInfoBar === false} /> ) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable: !attachExistingSession, reconnectNoticeMessage, timeLeft, toast, zmodem }} isPaneMagnified={isPaneMagnified} /> setSaveRecordingOpen(false)} onSave={({ name, packagePath, code, editAfterSave }) => { window.dispatchEvent(new CustomEvent('netcatty:scripts:save-recorded', { detail: { name, packagePath, code, editAfterSave }, })); setSaveRecordingOpen(false); }} /> ); }; const Terminal = memo(TerminalComponent, terminalPropsAreEqual); Terminal.displayName = "Terminal"; export default Terminal;