Files
Netcatty/components/Terminal.tsx
T
2026-06-30 17:01:27 +08:00

2522 lines
100 KiB
TypeScript

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, Sparkles, SquareArrowOutUpRight } 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 {
shouldEnableNativeUserInputAutoScroll,
shouldScrollOnTerminalInput,
} 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 } from "../domain/sessionRestore";
import {
buildTerminalContextReadResult,
buildTerminalContextSnapshotText,
normalizeTerminalContextRange,
resolveTerminalContextLineWindow,
type TerminalContextReader,
} from "../domain/terminalContextRead";
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
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";
// 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 { SSH_TCP_CONNECT_TIMEOUT_MS } from "./terminal/connectionTimeouts";
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 { useScriptRecorder } from "@/application/state/useScriptRecorder.ts";
import { getScriptRecordingSnapshot, setScriptRecordingState } from "@/application/state/scriptRecordingStore.ts";
import {
runAutomationScript,
runConnectScriptsSequential,
subscribeScriptRuns,
pauseScriptRun,
resumeScriptRun,
stopScriptRun,
} from "@/application/state/scriptAutomationCoordinator.ts";
import { resolveConnectScriptsForHost, hasUnresolvedConnectScriptBindings } from "@/domain/hostConnectScripts.ts";
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
import { isScriptSnippet } from "@/domain/snippetScript.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 { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
import {
createPromptLineBreakState,
type PromptLineBreakState,
} from "./terminal/runtime/promptLineBreak";
import {
prepareSudoAutofillInput,
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 { 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, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
import { useTerminalEffects } from "./terminal/useTerminalEffects";
import { useTerminalHibernateEffect } from "./terminal/useTerminalHibernateEffect";
import { readActiveTerminalBufferTextRange } from "./terminal/terminalContextBuffer";
import {
appendHibernatePendingBuffer,
isTerminalAlternateScreenActive,
serializeTerminalForHibernate,
} from "./terminal/terminalHibernateRuntime";
import {
ackTerminalSessionFlow,
clearTerminalSessionFlowAck,
flushTerminalSessionFlowAck,
} from "./terminal/runtime/terminalFlowAckBuffer";
import { releaseTerminalFlowBeforeHibernate } from "./terminal/runtime/terminalSessionAttachment";
import {
isTerminalFileTransferActive,
resolveHibernateKeepRendererCount,
resolveHibernatePreferWasmSerialize,
resolveHibernateSkipAltScreen,
resolveTerminalHibernateDelayMs,
resolveTerminalHibernateEnabled,
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 {
getInitialTerminalStatus,
shouldStartTerminalBackend,
} from "./terminal/restoredSessionGate";
import {
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
forceSyncRenderAfterResize,
MAX_CONNECTION_LOG_DATA_CHARS,
shouldDelayAutoRunSnippetInput,
shouldHideConnectingDialogForConnectionReuse,
shouldShowTerminalConnectionDialog,
type TerminalProps,
} from "./terminal/terminalHelpers";
import { terminalPropsAreEqual } from "./terminal/terminalMemo";
const TerminalComponent: React.FC<TerminalProps> = ({
host,
keys,
identities,
snippets,
snippetPackages = [],
compactToolbar = false,
lineTimestampsAvailable = true,
chainHosts = [],
appearanceTheme,
knownHosts = [],
isVisible,
paneLayoutKey,
inWorkspace,
isResizing,
isFocusMode,
isFocused,
fontFamilyId,
fontSize,
terminalTheme,
followAppTerminalTheme = false,
accentMode = "theme",
customAccent = "",
terminalSettings,
sessionId,
restoreState,
shellType,
lastCwd,
restoreTerminalCwd = false,
startupCommand,
noAutoRun,
multiLineRunMode,
pendingScriptId,
pendingScript,
reuseConnectionFromSessionId,
serialConfig,
hotkeyScheme = "disabled",
disableTerminalFontZoom = false,
keyBindings = [],
onHotkeyAction,
onTerminalFontSizeChange,
onStatusChange,
onSessionExit,
onTerminalDataCapture,
onOsDetected,
onCloseSession,
onUpdateHost,
onAddKnownHost,
onExpandToFocus,
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,
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 CONNECTION_TIMEOUT = SSH_TCP_CONNECT_TIMEOUT_MS;
const { t } = useI18n();
const connectScriptsConsumedRef = useRef(false);
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
const connectScriptsInFlightRef = useRef(false);
const pendingScriptRunIdRef = useRef<string | null>(null);
const pendingScriptHandledRef = useRef<Snippet | 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 [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
const dismissedScriptRunIdRef = useRef<string | null>(null);
useEffect(() => {
return subscribeScriptRuns((runs) => {
const sessionRuns = runs.filter((run) => run.sessionId === sessionId);
const liveRun = sessionRuns.find((run) => run.status === 'running' || run.status === 'paused');
if (liveRun) {
dismissedScriptRunIdRef.current = null;
setActiveScriptRun(liveRun);
return;
}
const finishedRun = sessionRuns
.filter((run) =>
(run.status === 'completed' || run.status === 'failed')
&& run.runId !== dismissedScriptRunIdRef.current,
)
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
setActiveScriptRun(finishedRun);
});
}, [sessionId]);
const dismissScriptOverlay = useCallback(() => {
if (activeScriptRun) {
dismissedScriptRunIdRef.current = activeScriptRun.runId;
}
setActiveScriptRun(undefined);
}, [activeScriptRun]);
const outputTriggers = useOutputTriggers({
sessionId,
hostId: host.id,
snippets,
onRunScript: (snippet, sid) => runAutomationScript({ snippet, sessionId: sid }).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<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
const terminalCwdTracker = useMemo(() => createTerminalCwdTracker(), []);
const knownCwdRef = useRef<string | undefined>(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 hibernateAlternateScreenRef = useRef(false);
const wakeInProgressRef = useRef(false);
const sessionRef = useRef<string | null>(null);
const isBootActiveRef = useRef(false);
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<symbol | null>(null);
const terminalDataCapturedRef = useRef(false);
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
const commandLogRewriterRef = useRef(createProgrammaticCommandLogRewriter());
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const commandBufferRef = useRef<string>("");
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
const [hasMouseTracking, setHasMouseTracking] = useState(false);
const mouseTrackingRef = useRef(false);
const serialLineBufferRef = useRef<string>("");
const telnetLocalEchoRef = useRef(false);
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const handleUpdateHostFromTerminal = useCallback((hostUpdate: TerminalHostUpdate) => {
onUpdateHost?.(hostUpdate as Host);
}, [onUpdateHost]);
onTerminalDataCaptureRef.current = onTerminalDataCapture;
const isVisibleRef = useRef(isVisible);
isVisibleRef.current = isVisible;
const pendingOutputScrollRef = useRef(false);
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
const fontWeightFixupDoneRef = useRef(false);
const captureTerminalLogData = useCallback((data: string) => {
const readableCommandData = commandLogRewriterRef.current.append(data);
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<TerminalContextReader>(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);
const term = termRef.current;
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 fullText = snapshot.fullText || hibernateContextSnapshotRef.current;
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: snapshot.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) => {
if (!data) return;
captureTerminalLogData(data);
termRef.current?.write(data);
}, [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 <TerminalAutocomplete> 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 { 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<TerminalSession["status"]>(() => (
getInitialTerminalStatus()
));
const [error, setError] = useState<string | null>(null);
const lastToastedErrorRef = useRef<string | null>(null);
const [showLogs, setShowLogs] = useState(false);
const [progressLogs, setProgressLogs] = useState<string[]>([]);
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(15);
const [hasSelection, setHasSelection] = useState(false);
const [selectionOverlayPosition, setSelectionOverlayPosition] = useState<{ left: number; top: number } | null>(null);
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
const statusRef = useRef<TerminalSession["status"]>(status);
statusRef.current = status;
const getSessionConnectedRef = useRef(() => statusRef.current === "connected" && Boolean(sessionRef.current));
getSessionConnectedRef.current = () => statusRef.current === "connected" && Boolean(sessionRef.current);
const sudoAutofillRef = useRef<SudoPasswordAutofill | null>(null);
const sudoAutofillPasswordRef = useRef(sudoAutofillPassword);
sudoAutofillPasswordRef.current = sudoAutofillPassword;
const [chainProgress, setChainProgress] = useState<{
currentHop: number;
totalHops: number;
currentHostLabel: string;
} | null>(null);
const [isConnectionAwaitingUserInput, setIsConnectionAwaitingUserInput] = useState(false);
const [isConnectionPastTcpDial, setIsConnectionPastTcpDial] = useState(false);
// 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<TerminalEncodingPreference>(() => {
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;
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) {
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);
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);
for (const ch of text) {
if (ch === "\r") {
writeLocalTerminalData("\r\n");
} else if (ch.charCodeAt(0) >= 32) {
writeLocalTerminalData(ch);
}
}
} else {
terminalBackend.writeToSession(id, textToWrite);
}
// Broadcast to other sessions if broadcast mode is enabled
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(text, sessionId);
}
// 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;
if (recorderRef.current.isRecording) {
void recorderRef.current.recordEnter({
sensitive: passwordPromptActiveRef.current,
});
passwordPromptActiveRef.current = false;
}
recordTerminalCommandExecution(rawCommand, {
host,
sessionId,
onCommandExecuted,
onCommandSubmitted,
commandBufferRef,
promptLineBreakStateRef,
}, termRef.current);
} 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 <TerminalAutocomplete> 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 autocompleteHostOs: "linux" | "windows" | "macos" = host.protocol === "local"
? detectLocalOs(navigator.userAgent || navigator.platform)
: (host.os || "linux");
const autocompleteSettings = resolveTerminalAutocompleteSettings({
protocol: host.protocol,
terminalSettings,
});
const resolveSftpInitialPath = useCallback(async (options?: { preferFreshBackend?: boolean }): Promise<string | undefined> => {
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: terminalCwdTracker.getRendererCwd(),
sessionId: sessionRef.current,
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
preferFreshBackend: options?.preferFreshBackend,
});
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 &&
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 =
!isNetworkDevice &&
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
const isSystemSidebarEligible =
!!onOpenSystem &&
isSupportedOs &&
!isLocalConnection &&
!isSerialConnection &&
host.protocol !== 'telnet';
// Server-stats polling now lives inside <TerminalServerStats> (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<PendingAuth>(null);
useEffect(() => {
sudoAutofillRef.current?.updatePassword(sudoAutofillPassword);
}, [sudoAutofillPassword]);
const sessionStartersRef = useRef<ReturnType<typeof createTerminalSessionStarters> | null>(null);
const auth = useTerminalAuthState({
host,
pendingAuthRef,
termRef,
onUpdateHost: handleUpdateHostFromTerminal,
onStartSession: (term) => {
const starters = sessionStartersRef.current;
if (!starters) return;
if (host.moshEnabled) {
starters.startMosh(term);
return;
}
if (host.etEnabled) {
starters.startEt(term);
return;
}
starters.startSSH(term);
},
setStatus: (next) => setStatus(next),
setProgressLogs,
});
const [needsHostKeyVerification, setNeedsHostKeyVerification] = useState(false);
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
const [pendingHostKeyRequestId, setPendingHostKeyRequestId] = useState<string | null>(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<boolean> => {
// 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(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<HTMLDivElement>) => {
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 effectiveTheme = useMemo(() => {
if (appearanceTheme) return appearanceTheme;
if (followAppTerminalTheme) {
return applyCustomAccentToTerminalTheme(terminalTheme, accentMode, customAccent);
}
const themeId = resolveHostTerminalThemeId(
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
terminalTheme.id,
);
let baseTheme = terminalTheme;
if (themeId) {
const hostTheme = getBuiltinTerminalThemeById(themeId)
|| customThemes.find((t) => t.id === themeId);
if (hostTheme) baseTheme = hostTheme;
}
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [accentMode, appearanceTheme, customAccent, customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme]);
const resolvedChainHosts =
chainHosts;
const updateStatus = (next: TerminalSession["status"]) => {
setStatus(next);
hasConnectedRef.current = next === "connected";
onStatusChange?.(sessionId, next);
};
const updateStatusRef = useRef(updateStatus);
updateStatusRef.current = updateStatus;
const prepareRestoredReconnect = useCallback(() => {
if (restoreState !== "restored-disconnected") {
suppressHostStartupCommandRef.current = false;
restoreCwdIntentRef.current = null;
return;
}
suppressHostStartupCommandRef.current = true;
restoreCwdIntentRef.current = resolveRestoreCwdIntent({
enabled: restoreTerminalCwd,
session: {
status: "disconnected",
restoreState,
protocol: host.protocol,
shellType,
lastCwd,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
},
isNetworkDevice,
});
}, [
host.etEnabled,
host.moshEnabled,
host.protocol,
isNetworkDevice,
lastCwd,
restoreState,
restoreTerminalCwd,
shellType,
]);
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 cleanupSession = () => {
const closingSessionId = sessionRef.current;
disposeDataRef.current?.();
disposeDataRef.current = null;
disposeExitRef.current?.();
disposeExitRef.current = null;
disposeTelnetEchoModeRef.current?.();
disposeTelnetEchoModeRef.current = null;
telnetLocalEchoRef.current = false;
if (closingSessionId) {
const activeTerm = termRef.current;
if (activeTerm) {
releaseTerminalFlowBeforeHibernate(terminalBackend, activeTerm, closingSessionId, {
resumeBackend: false,
});
} else {
flushTerminalSessionFlowAck(closingSessionId);
clearTerminalSessionFlowAck(closingSessionId);
}
try {
terminalBackend.closeSession(closingSessionId);
} catch (err) {
logger.warn("Failed to close SSH session", err);
}
}
sessionRef.current = null;
};
const disposeRuntimeOnly = () => {
xtermRuntimeRef.current?.dispose();
xtermRuntimeRef.current = null;
termRef.current = null;
fitAddonRef.current = null;
serializeAddonRef.current = null;
searchAddonRef.current = null;
hasRuntimeRef.current = false;
};
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);
try {
terminalBackend.closeSession(closingSessionId);
} catch (err) {
logger.warn("Failed to close hibernated session", err);
}
}
sessionRef.current = null;
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);
}, [handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
const beginHibernatedSessionListeners = useCallback((backendId: string) => {
disposeDataRef.current?.();
flushTerminalSessionFlowAck(backendId);
terminalBackend.setSessionFlowPaused?.(backendId, false);
hibernatePendingBufferRef.current = "";
disposeDataRef.current = terminalBackend.onSessionData(
backendId,
(chunk) => {
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
hibernatePendingBufferRef.current,
chunk,
);
ackTerminalSessionFlow(terminalBackend, backendId, chunk.length);
},
{ replayBacklog: true },
);
disposeExitRef.current?.();
disposeExitRef.current = terminalBackend.onSessionExit(backendId, (evt) => {
disposeTelnetEchoModeRef.current?.();
disposeTelnetEchoModeRef.current = null;
telnetLocalEchoRef.current = false;
updateStatusRef.current("disconnected");
if (evt.error) {
setError(evt.error);
}
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
hibernatePendingBufferRef.current,
exitMessage,
);
onSessionExit?.(sessionId, evt);
});
}, [onSessionExit, sessionId, terminalBackend]);
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 fullHibernateRuntime = useCallback(async () => {
if (hibernatedRef.current || softHiddenRef.current || !termRef.current || !serializeAddonRef.current) return;
const backendId = sessionRef.current;
if (!backendId) return;
terminalHiddenRendererStore.clearSoftHidden(sessionId);
softHiddenRef.current = false;
const snapshot = await serializeTerminalForHibernate(
termRef.current,
serializeAddonRef.current,
{ preferWasm: resolveHibernatePreferWasmSerialize(terminalSettings) },
);
if (snapshot.alternateScreen && snapshot.snapshot.length === 0) {
logger.info("[Terminal] Skipping hibernate: alternate screen snapshot unavailable", { sessionId });
return;
}
applyHibernateSnapshot(snapshot);
isBootActiveRef.current = false;
if (termRef.current) {
releaseTerminalFlowBeforeHibernate(terminalBackend, termRef.current, backendId);
}
disposeDataRef.current?.();
disposeDataRef.current = null;
disposeExitRef.current?.();
disposeExitRef.current = null;
disposeRuntimeOnly();
beginHibernatedSessionListeners(backendId);
hibernatedRef.current = true;
logger.info("[Terminal] Hibernated runtime", {
sessionId,
snapshotChars: hibernateSnapshotRef.current.length,
viewportChars: hibernateViewportSnapshotRef.current.length,
scrollbackChars: hibernateScrollbackSnapshotRef.current.length,
alternateScreen: snapshot.alternateScreen,
});
}, [
applyHibernateSnapshot,
beginHibernatedSessionListeners,
sessionId,
terminalBackend,
terminalSettings,
]);
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 || softHiddenRef.current || !termRef.current) return;
if (
isTerminalAlternateScreenActive(termRef.current)
&& resolveHibernateSkipAltScreen(terminalSettings)
) {
logger.info("[Terminal] Skipping hibernate: alternate screen active", { sessionId });
return;
}
const keepCount = resolveHibernateKeepRendererCount(terminalSettings);
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, sessionId, terminalSettings]);
const terminalRuntimeRefs = useMemo<TerminalRuntimeRefs>(() => ({
xtermRuntimeRef,
termRef,
fitAddonRef,
serializeAddonRef,
searchAddonRef,
hasRuntimeRef,
}), []);
const xTermRuntimeContextRef = useRef<Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible"> | null>(null);
const teardown = () => {
isBootActiveRef.current = false;
retryTokenRef.current = null;
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = false;
cleanupSession();
disposeRuntimeOnly();
};
const sessionStarters = createTerminalSessionStarters({
host,
keys,
identities,
knownHosts,
resolvedChainHosts,
sessionId,
reuseConnectionFromSessionId,
startupCommand,
noAutoRun,
multiLineRunMode,
shellType,
suppressHostStartupCommandRef,
terminalSettings,
terminalSettingsRef,
terminalBackend,
serialConfig,
telnetLocalEchoRef,
isVisibleRef,
isBootActiveRef,
pendingOutputScrollRef,
sessionRef,
hasConnectedRef,
hasRunStartupCommandRef,
restoreCwdIntentRef,
disposeDataRef,
disposeExitRef,
disposeTelnetEchoModeRef,
fitAddonRef,
serializeAddonRef,
pendingAuthRef,
promptLineBreakStateRef,
sudoAutofillRef,
onSudoHint: (active: boolean) => sudoHintRef.current?.(active) ?? false,
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 isSSH = !isLocal && !isSerial && !isTelnet && !isMosh && !isEt;
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;
},
onSessionExit: (closedSessionId, evt) => {
clearTerminalCwd();
onSessionExit?.(closedSessionId, evt);
},
onTerminalDataCapture: handleTerminalDataCaptureOnce,
onTerminalOutput: onTerminalOutput
? (chunk: string) => {
if (/password|passphrase|口令/i.test(chunk)) {
passwordPromptActiveRef.current = true;
}
appendOutputTriggerOutputRef.current(chunk);
onTerminalOutput(sessionId, chunk);
}
: (chunk: string) => {
if (/password|passphrase|口令/i.test(chunk)) {
passwordPromptActiveRef.current = true;
}
appendOutputTriggerOutputRef.current(chunk);
},
onTerminalLogData: captureTerminalLogData,
onProgrammaticCommandLogRewrite: queueProgrammaticCommandLogRewrite,
onOsDetected,
onCommandExecuted,
onCommandSubmitted,
sessionLog,
sshDebugLogEnabled,
sudoAutofillPassword,
sudoAutofillPasswordRef,
});
sessionStartersRef.current = sessionStarters;
useEffect(() => {
if (status === 'disconnected') {
connectScriptsConsumedRef.current = false;
connectScriptsCompletedIdsRef.current = new Set();
}
}, [status]);
useEffect(() => {
pendingScriptRunIdRef.current = null;
pendingScriptHandledRef.current = null;
}, [pendingScript?.id, pendingScriptId]);
const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => {
if (snippet.id) {
return pendingScriptRunIdRef.current === snippet.id;
}
return pendingScriptHandledRef.current === snippet;
}, []);
useEffect(() => {
if (status !== 'connected') 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 shouldEvaluateConnect = !connectScriptsConsumedRef.current;
const hasPendingWork = Boolean(pendingOne);
if (!shouldEvaluateConnect && !hasPendingWork) return;
if (connectScriptsInFlightRef.current) return;
// Defer until xterm has rendered login output and the main-process output tap
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
const timer = window.setTimeout(() => {
const runPending = Boolean(pendingOne);
const connectQueueNow = connectScriptsConsumedRef.current
? []
: resolveConnectScriptsForHost(host, snippets).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 resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets);
const allConnectScriptsDone = resolvedConnectScripts.length === 0
|| resolvedConnectScripts.every(
(item) => item.id && connectScriptsCompletedIdsRef.current.has(item.id),
);
if (scriptsToRun.length === 0) {
if (
!connectScriptsConsumedRef.current
&& allConnectScriptsDone
&& isVaultInitialized()
&& snippets.length > 0
&& !hasUnresolvedConnectScriptBindings(host, snippets)
) {
connectScriptsConsumedRef.current = true;
}
return;
}
const pendingScriptToMark = runPending ? pendingOne : undefined;
const connectIdsInBatch = new Set(
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
);
connectScriptsInFlightRef.current = true;
void runConnectScriptsSequential({
scripts: scriptsToRun,
sessionId,
sessionMeta: {
connected: true,
hostname: host.hostname,
username: host.username,
},
onScriptComplete: (snippet) => {
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(() => {
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) => {
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,
sessionMeta: {
connected: true,
hostname: host.hostname,
username: host.username,
},
onScriptComplete: (snippet) => {
if (snippet.id) {
pendingScriptRunIdRef.current = snippet.id;
} else {
pendingScriptHandledRef.current = snippet;
}
},
});
} catch (pendingErr) {
const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr);
toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage);
}
})
.finally(() => {
connectScriptsInFlightRef.current = false;
});
}, 400);
return () => window.clearTimeout(timer);
}, [host, isPendingScriptAlreadyHandled, pendingScript, pendingScriptId, sessionId, snippets, status, t]);
useEffect(() => {
return registerScreenSnapshotProvider(sessionId, () => {
const term = termRef.current;
if (!term?.buffer?.active) {
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);
});
};
window.addEventListener('netcatty:script:recording:start', startHandler);
window.addEventListener('netcatty:script:recording:stop', stopHandler);
return () => {
window.removeEventListener('netcatty:script:recording:start', startHandler);
window.removeEventListener('netcatty:script:recording:stop', stopHandler);
};
}, [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 safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
const fitAddon = fitAddonRef.current;
if (!fitAddon) return;
if (options?.requireVisible && !isVisibleRef.current) 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;
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);
}
// 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"
) {
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 scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
termRef.current.scrollToBottom();
}
}, []);
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 (sessionRef.current && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(data, sessionId);
return true;
}
return false;
}, [sessionId]);
const executeSnippetCommand = useCallback((
command: string,
noAutoRun?: boolean,
options?: { broadcast?: boolean; multiLineRunMode?: Snippet["multiLineRunMode"] },
) => {
const term = termRef.current;
const id = sessionRef.current;
if (!term || !id) return;
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.
if (options?.broadcast !== false && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(data, sessionId, {
noAutoRun,
...(lineDelayMs ? { lineDelayMs } : {}),
});
}
data = prepareProgrammaticSudoInput(data);
terminalBackend.writeToSession(id, data, {
automated: true,
...(lineDelayMs ? { lineDelayMs } : {}),
});
scrollToBottomAfterProgrammaticInput(data);
term.focus();
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
const executeSnippet = useCallback(async (snippet: Snippet) => {
if (isScriptSnippet(snippet)) {
try {
await runAutomationScript({ snippet, sessionId });
} 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, 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,
onHasSelectionChange: setHasSelection,
scrollOnPasteRef,
clearWipesScrollbackRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
isLocalConnection,
supportsRemoteImagePaste,
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 selection = termRef.current?.getSelection() ?? "";
if (!selection.trim()) return;
onAddSelectionToAI?.(sessionId, selection);
}, [onAddSelectionToAI, sessionId]);
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);
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 filePath = await selectFile(
t("terminal.ymodem.selectFile"),
undefined,
[{ name: t("terminal.ymodem.allFiles"), extensions: ["*"] }],
);
if (!filePath) return;
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);
}
retryTokenRef.current = null;
restoreCwdIntentRef.current = null;
setIsCancelling(true);
auth.setNeedsAuth(false);
auth.setAuthRetryMessage(null);
setNeedsHostKeyVerification(false);
setPendingHostKeyInfo(null);
setPendingHostKeyRequestId(null);
setError("Connection cancelled");
setProgressLogs((prev) => [...prev, "Cancelled by user."]);
cleanupSession();
updateStatus("disconnected");
setChainProgress(null);
setTimeout(() => setIsCancelling(false), 600);
onCloseSession?.(sessionId);
};
const handleDismissDisconnectedDialog = () => {
setIsDisconnectedDialogDismissed(true);
};
const handleCloseDisconnectedSession = () => {
retryTokenRef.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 handleRetry = () => {
if (!termRef.current) return;
prepareRestoredReconnect();
cleanupSession();
const term = termRef.current;
// Claim a fresh retry token. If the user cancels / closes / unmounts /
// kicks off another retry while the chained writes below are still
// queued, the token will be invalidated and our callbacks will abort
// before opening a ghost backend session with no owning UI.
const retryToken = Symbol("retry");
retryTokenRef.current = retryToken;
const retryStillActive = () => retryTokenRef.current === retryToken && termRef.current === term;
isBootActiveRef.current = true;
auth.resetForRetry();
terminalDataCapturedRef.current = false;
hasRunStartupCommandRef.current = false;
setIsDisconnectedDialogDismissed(false);
setConnectionReuseFellBack(false);
updateStatus("connecting");
setError(null);
setProgressLogs(["Retrying secure channel..."]);
setShowLogs(true);
const startNewSession = () => {
if (!retryStillActive()) return;
if (host.protocol === "serial") {
sessionStarters.startSerial(term);
} else if (host.protocol === "local" || host.hostname === "localhost") {
sessionStarters.startLocal(term);
} else if (host.protocol === "telnet") {
sessionStarters.startTelnet(term);
} else if (host.moshEnabled) {
sessionStarters.startMosh(term);
} else if (host.etEnabled) {
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()) 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,
);
});
};
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
status,
isLocalConnection,
isSerialConnection,
isDisconnectedDialogDismissed,
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
reuseConnectionFromSessionId,
host,
connectionReuseFellBack,
}),
});
const {
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
isDraggingOver,
} = useTerminalDragDrop({
host,
isLocalConnection,
isNetworkDevice,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
sessionId,
sessionRef,
status,
t,
terminalBackend,
termRef,
});
useTerminalFilePaste({
isLocalConnection,
status,
termRef,
sessionRef,
terminalBackend,
scrollOnPasteRef,
onPasteData: broadcastUserPasteData,
scrollToBottomAfterProgrammaticInput,
containerRef,
});
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?.success) {
setIsSessionLogging(false);
} else {
toast.error(stopResult?.error || "Failed to stop session log");
}
return;
}
const startResult = await startManualSessionLog({
sessionId: currentSessionId,
sessionName: host.label || host.hostname || currentSessionId,
preferredDirectory: sessionLog?.directory,
initialLine: termRef.current ? getSessionLogInitialLine(termRef.current) : "",
});
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");
}
}, [
getManualSessionLogStatus,
host.hostname,
host.label,
sessionId,
sessionLog?.directory,
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 }) => (
<TerminalToolbar
status={status}
host={host}
compactToolbar={compactToolbar}
snippets={snippets}
snippetPackages={snippetPackages}
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
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={() => onCloseSession?.(sessionId)}
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 ? (
<ScriptRecordingIndicator
elapsedMs={recorder.elapsedMs}
isPaused={recorder.isPaused}
onPause={recorder.pauseRecording}
onResume={recorder.resumeRecording}
onStop={() => {
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,
onOpenScripts,
onOpenHistory,
onOpenTheme,
onToggleComposeBar,
setIsComposeBarOpen,
handleUpdateHostFromTerminal,
sessionId,
snippetPackages,
snippets,
status,
terminalEncoding,
recorder,
]);
const statusDotTone =
status === "connected"
? "bg-emerald-400"
: status === "connecting"
? "bg-amber-400"
: "bg-rose-500";
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,
terminalBackend,
sessionRef,
hotkeySchemeRef,
disableTerminalFontZoomRef,
keyBindingsRef,
onHotkeyActionRef,
onTerminalFontSizeChange,
isBroadcastEnabledRef,
onBroadcastInputRef,
snippetsRef,
onSnippetShortkeyRef,
sessionId,
statusRef,
onCommandExecuted,
onCommandSubmitted,
commandBufferRef,
scriptRecorderRef: recorderRef,
passwordPromptActiveRef,
onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef,
promptLineBreakStateRef,
sudoAutofillRef,
requestSearchFocus,
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
telnetLocalEchoRef,
onTerminalLogData: captureTerminalLogData,
onCwdChange: (cwd: string) => {
terminalCwdTracker.setRendererCwd(cwd);
knownCwdRef.current = cwd;
onTerminalCwdChange?.(sessionId, cwd);
},
onTitleChange: (title: string | null) => {
onTerminalTitleChange?.(sessionId, title);
},
onBell: () => {
onTerminalBell?.(sessionId);
},
onOsc52ReadRequest: handleOsc52ReadRequest,
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
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]);
useEffect(() => {
return terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest(sessionId)) return;
if (!softHiddenRef.current || hibernatedRef.current) return;
softHiddenRef.current = false;
terminalHiddenRendererStore.clearSoftHidden(sessionId);
void fullHibernateRuntime();
});
}, [fullHibernateRuntime, sessionId]);
const wakeFromHibernateRuntime = useCallback((
getPayload: () => TerminalHibernateWakePayload,
options: { sessionConnected: boolean },
): boolean | Promise<boolean> => {
if (wakeInProgressRef.current || hasRuntimeRef.current) {
logger.warn("[Terminal] Wake skipped", {
sessionId,
wakeInProgress: wakeInProgressRef.current,
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;
const stopHibernateListeners = () => {
const backendId = sessionRef.current;
disposeDataRef.current?.();
disposeDataRef.current = null;
disposeExitRef.current?.();
disposeExitRef.current = null;
if (backendId) {
flushTerminalSessionFlowAck(backendId);
clearTerminalSessionFlowAck(backendId);
terminalBackend.setSessionFlowPaused?.(backendId, false);
}
};
return wakeTerminalFromHibernate({
refs: terminalRuntimeRefs,
runtimeContext,
container,
getPayload,
stopHibernateListeners,
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),
}).then((ok) => ok).catch((err) => {
logger.error("[Terminal] Failed to resume from hibernate", err);
return false;
}).finally(() => {
wakeInProgressRef.current = false;
});
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings]);
useTerminalHibernateEffect({
sessionId,
isVisible,
isVisibleRef,
getSessionConnectedRef,
status,
isSearchOpen,
hibernateEnabled: resolveTerminalHibernateEnabled(terminalSettings),
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
fileTransferActive: isTerminalFileTransferActive({
zmodemActive: zmodem.active,
ymodemInProgress,
isDraggingOver,
}),
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, captureTerminalLogData, 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, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
onResume={() => { void resumeScriptRun(activeScriptRun.runId); }}
onStop={() => { void stopScriptRun(activeScriptRun.runId); }}
onDismiss={dismissScriptOverlay}
/>
) : null, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
<ScriptSaveRecordingDialog
open={saveRecordingOpen}
code={recordedCode}
packages={snippetPackages}
defaultName={`recorded-${new Date().toISOString().slice(0, 10)}`}
onClose={() => 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;