Files
NetMesh/components/terminal/useTerminalEffects.ts

1953 lines
74 KiB
TypeScript
Raw Permalink Normal View History

/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useRef } from 'react';
import { publishPluginTerminalRuntimeLifecycleEvent } from '../../application/state/pluginTerminalRuntimeLifecycle';
import { markTerminalCommandCompletionPending } from './runtime/promptLineBreak';
import { resolveFontWeightBold } from '../../lib/fontWeightAvailability';
import { bundledFamiliesInStack } from '../../lib/fontAvailability';
import { isMacPlatform } from '../../lib/utils';
import { resolveXTermScrollback } from '../../infrastructure/config/xtermPerformance';
import {
createMacOptionForcedSelectionMouseEvent,
createRightClickMouseTrackingPressClaim,
shouldInterceptMouseTrackingContextMenu,
shouldReplayShiftMouseSelectionAsMacOption,
shouldStopRightClickMouseTrackingMouseUp,
} from './runtime/middleClickBehavior';
import {
hasOpenAppDialog,
TERMINAL_SESSION_RESTORE_FOCUS_EVENT,
type TerminalSessionRestoreFocusDetail,
} from './focusTerminalSession';
import {
resolveHibernatePreferWasmSerialize,
resolveTerminalHibernateEnabledForProtocol,
} from '../../domain/terminalHibernate';
import { setTerminalBootEpoch } from '../../domain/terminalBootEpoch';
import { applyUserCursorBlinkPreference } from './runtime/cursorPreference';
import { resolveCursorLineHighlightBackground } from '../../domain/cursorLineHighlight';
import { getFlowControllerForTerm } from './runtime/terminalSessionAttachment';
import {
prioritizeTerminalInput,
shouldArmTerminalInterruptDisplayGateForProtocol,
} from './runtime/terminalOutputPipeline';
import { scheduleTerminalThemeUpdate, applyTerminalThemeSync, cancelTerminalThemeUpdate } from './terminalThemeScheduler';
import { injectTerminalPaneAppearanceVars } from '../../infrastructure/theme/terminalAppearanceVars';
import {
isTerminalAlternateScreenActive,
nudgeAlternateScreenRedraw,
} from './terminalHibernateRuntime';
import {
cancelScheduledUnfocusedRepaint,
flushPendingTerminalWritesBeforeHibernate,
flushPendingTerminalWritesOnResume,
forceTerminalRepaintBypassingAnimationFrame,
isTerminalPageHidden,
repaintTerminalAfterReveal,
} from './runtime/terminalUnfocusedRepaint';
import { setTerminalOutputPressureVisibility } from './runtime/terminalOutputPressure';
import {
forceXTermFontRemeasure,
type XTermFontRemeasureTarget,
} from './runtime/terminalFontRemeasure';
import { shouldClaimTerminalKeyboardFocus } from '../../domain/terminalKeyboardFocus';
import { handleTerminalOscNotification } from '../../application/state/oscDesktopNotifications';
import { settleTerminalSearchAfterLayout } from './hooks/useTerminalSearch';
import {
isTerminalCloseGenerationCurrent,
resolveConnectionLogCapturePayload,
scheduleTerminalCloseTeardown,
serializeTerminalCloseFallback,
} from './runtime/terminalCloseCapture';
import {
CONNECTION_PROGRESS_START,
advanceIndeterminateConnectionProgress,
} from './connectionProgress';
import {
getConnectionTimeoutMs,
resolveActiveConnectionTimeoutHost,
shouldRunConnectionTimeout,
} from './connectionTimeouts';
import { resolveHostSshConnectionTimeouts } from '../../domain/sshConnectionTimeouts';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
type TerminalEffectsContext = Record<string, any>;
type SelectionOverlayPosition = {
left: number;
top: number;
} | null;
type ZmodemToastInput = {
active?: boolean;
completed?: boolean;
error?: string | null;
filename?: string | null;
transferType?: 'upload' | 'download' | null;
};
type ZmodemToast =
| { kind: 'error'; message: string; title: string }
| { kind: 'success'; message: string; title: string }
| null;
type ZmodemToastApi = {
success: (message: string, title?: string) => void;
error: (message: string, title?: string) => void;
};
const CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS = 3000;
export function resolveZmodemTransferToast(zmodem: ZmodemToastInput): ZmodemToast {
if (zmodem.active) return null;
if (zmodem.error) {
return { kind: 'error', message: zmodem.error, title: 'ZMODEM' };
}
if (!zmodem.completed) return null;
const action = zmodem.transferType === 'upload'
? 'Uploaded'
: zmodem.transferType === 'download'
? 'Downloaded'
: 'Transfer completed';
const message = zmodem.filename ? `${action}: ${zmodem.filename}` : action;
return { kind: 'success', message, title: 'ZMODEM' };
}
export function applyZmodemTransferToast(
zmodem: ZmodemToastInput,
toastedRef: { current: boolean },
toast: ZmodemToastApi,
): void {
if (zmodem.active) {
toastedRef.current = false;
return;
}
if (toastedRef.current) return;
const zmodemToast = resolveZmodemTransferToast(zmodem);
if (!zmodemToast) return;
toastedRef.current = true;
if (zmodemToast.kind === 'error') {
toast.error(zmodemToast.message, zmodemToast.title);
} else {
toast.success(zmodemToast.message, zmodemToast.title);
}
}
export function resolveSelectionOverlayPosition(term: any, container: HTMLElement | null): SelectionOverlayPosition {
if (!container || !term?.getSelectionPosition || !term.getSelection()) return null;
const range = term.getSelectionPosition();
if (!range) return null;
const start = range.start;
const end = range.end;
const startsBeforeEnd =
start.y < end.y
|| (start.y === end.y && start.x <= end.x);
const top = startsBeforeEnd ? start : end;
const bottom = startsBeforeEnd ? end : start;
const viewportY = term.buffer?.active?.viewportY ?? 0;
const row = top.y - viewportY;
const rows = Math.max(1, term.rows ?? 1);
const cols = Math.max(1, term.cols ?? 1);
if (row < 0 || row >= rows) return null;
const screen = container.querySelector<HTMLElement>(".xterm-screen") ?? container;
const screenRect = screen.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const cellWidth = screen.clientWidth / cols;
const cellHeight = screen.clientHeight / rows;
const spansRows = top.y !== bottom.y;
const rightCol = spansRows ? cols : Math.max(top.x, bottom.x);
const containerOffsetLeft = container.offsetLeft ?? 0;
const containerOffsetTop = container.offsetTop ?? 0;
const selectionRight = containerOffsetLeft + screenRect.left - containerRect.left + Math.min(cols, rightCol) * cellWidth;
const selectionTop = containerOffsetTop + screenRect.top - containerRect.top + row * cellHeight;
return {
left: Math.max(140, Math.min(selectionRight + 8, container.clientWidth - 8)),
top: Math.max(36, Math.min(selectionTop - 8, container.clientHeight - 8)),
};
}
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
terminalSettings,
effectiveTerminalProtocol,
) && !kittyKeyboardProtocolEnabledForSession;
ctx.pluginDecorationRulesRef.current = pluginDecorationRules;
const prevIsSearchOpenRef = useRef(isSearchOpen);
const isFocusedRef = useRef(!!isFocused);
isFocusedRef.current = !!isFocused;
const pluginAwareOnCommandSubmitted = (...args: Parameters<NonNullable<typeof onCommandSubmitted>>) => {
markTerminalCommandCompletionPending(promptLineBreakStateRef);
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandSubmitted');
void xtermRuntimeRef.current?.pluginProviderHost?.commandSubmitted(args[0]);
};
const pluginAwareOnCommandCompleted = () => {
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandCompleted');
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
};
const isRendererActive = isVisible || !hibernateHiddenTabs;
const isRendererActiveRef = useRef(isRendererActive);
isRendererActiveRef.current = isRendererActive;
// Remember the last layout we successfully refit while visible so revisiting
// the same workspace tab does not replay expensive force-fit/WebGL recovery.
const lastCommittedVisibleLayoutKeyRef = useRef<string | null>(null);
const lastWebglRecoveryLayoutKeyRef = useRef<string | null>(null);
const terminalBootCloseGenerationRef = useRef(0);
const hiddenAtRef = useRef<number | null>(null);
const wasVisibleRef = useRef(isVisible);
useEffect(() => {
if (xtermRuntimeRef.current) {
// Merge global rules with host-level rules
const globalRules = terminalSettings?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
const providerRules = pluginDecorationRules.map((rule) => ({
id: rule.id,
label: rule.label,
patterns: [...rule.patterns],
color: rule.color,
enabled: true,
providerId: rule.providerId,
}));
const globalEnabled = terminalSettings?.keywordHighlightEnabled ?? false;
// Host-level toggle: undefined = inherit global, true/false = explicit override
const hostEnabled = host?.keywordHighlightEnabled;
// Global and host-level highlights are independent:
// global toggle controls global rules, host toggle controls host-specific rules
const effectiveGlobalEnabled = globalEnabled;
const effectiveHostEnabled = hostEnabled ?? false;
const mergedRules = [
...(effectiveGlobalEnabled ? globalRules : []),
...(effectiveHostEnabled ? hostRules : []),
...providerRules,
];
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled || providerRules.length > 0;
xtermRuntimeRef.current.keywordHighlighter.setRules(mergedRules, isEnabled);
}
}, [
terminalSettings?.keywordHighlightEnabled,
terminalSettings?.keywordHighlightRules,
host?.keywordHighlightEnabled,
host?.keywordHighlightRules,
pluginDecorationRules,
]);
useEffect(() => {
const runtime = xtermRuntimeRef.current;
if (!runtime?.cursorLineHighlighter) return;
runtime.cursorLineHighlighter.setBackgroundColor(
resolveCursorLineHighlightBackground(effectiveTheme.colors),
);
runtime.cursorLineHighlighter.setEnabled(
terminalSettings?.highlightCursorLine ?? false,
);
}, [
effectiveTheme.colors.background,
effectiveTheme.colors.foreground,
effectiveTheme.colors.selection,
terminalSettings?.highlightCursorLine,
]);
useEffect(() => {
xtermRuntimeRef.current?.pluginProviderHost?.providerAvailabilityChanged(
effectiveTheme.colors.background,
);
xtermRuntimeRef.current?.pluginLinkProviderHost?.providerAvailabilityChanged();
}, [
effectiveTheme.colors.background,
pluginTerminalProviderRevision,
xtermRuntimeRef,
]);
useEffect(() => {
xtermRuntimeRef.current?.pluginProviderHost?.setActive(
status === 'connected',
effectiveTheme.colors.background,
);
xtermRuntimeRef.current?.pluginLinkProviderHost?.setActive(status === 'connected');
}, [effectiveTheme.colors.background, status, xtermRuntimeRef]);
useEffect(() => {
xtermRuntimeRef.current?.pluginProviderHost?.setVisible(
isVisible,
effectiveTheme.colors.background,
);
xtermRuntimeRef.current?.pluginLinkProviderHost?.setVisible(isVisible);
}, [effectiveTheme.colors.background, isVisible, xtermRuntimeRef]);
// Work around xterm.js WebGL renderer bug: glyphs rendered via the constructor
// look different from dynamically-set ones. After text appears on screen (status
// becomes "connected"), do a fontWeight round-trip to normalize the rendering.
useEffect(() => {
if (status !== 'connected' || fontWeightFixupDoneRef.current || !termRef.current) return;
fontWeightFixupDoneRef.current = true;
const timer = setTimeout(() => {
if (!termRef.current) return;
// Re-read the current weight at fire time to avoid stale closures
const w = termRef.current.options.fontWeight;
if (w === 'normal' || w === 400) return;
termRef.current.options.fontWeight = 'normal';
termRef.current.options.fontWeight = w;
}, 200);
return () => clearTimeout(timer);
}, [status]);
useEffect(() => {
clearTerminalCwd({ persistRestoreMetadata: false });
return () => clearTerminalCwd({ persistRestoreMetadata: false });
}, [clearTerminalCwd, host.id]);
useEffect(() => {
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet"
|| isPluginHostProtocol(host.protocol)) {
return;
}
if (status !== "connected" || !sessionRef.current || knownCwdRef.current) return;
let cancelled = false;
const timer = setTimeout(async () => {
const id = sessionRef.current;
if (!id) return;
try {
// The pwd probe opens an extra POSIX-shell exec channel, which strict
// network-device CLIs like Huawei VRP answer by closing the whole
// session (#1043). Skip it for known network devices; for a brand-new
// host (distro not classified yet on the first connect) consult the
// SSH banner, which is captured for free at handshake time.
const info = await terminalBackend.getSessionRemoteInfo?.(id);
if (cancelled || id !== sessionRef.current) return;
if (!shouldProbeSessionCwd({ isNetworkDevice, remoteSshVersion: info?.remoteSshVersion })) {
return;
}
const result = await terminalBackend.getSessionPwd(id);
if (!cancelled && !terminalCwdTracker.getRendererCwd() && result.success && result.cwd) {
onPluginRuntimeCwdChange(result.cwd);
}
} catch {
// Best effort only.
}
}, 150);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [host.protocol, status, terminalBackend, terminalCwdTracker, isNetworkDevice]);
useEffect(() => {
if (!isVisible) {
autocompleteCloseRef.current?.();
}
}, [isVisible]);
useEffect(() => {
applyZmodemTransferToast(zmodem, zmodemToastedRef, toast);
}, [zmodem.active, zmodem.completed, zmodem.error, zmodem.filename, zmodem.transferType]);
useEffect(() => {
if (!error) {
lastToastedErrorRef.current = null;
return;
}
if (lastToastedErrorRef.current === error) return;
lastToastedErrorRef.current = error;
toast.error(error, t("terminal.connectionErrorTitle"));
}, [error, t]);
useEffect(() => {
const dispose = terminalBackend.onHostKeyVerification?.((request) => {
if (request.sessionId !== sessionId) return;
// Disconnect keeps the pane mounted; reject late host-key prompts instead
// of reopening approval UI on a disconnected / aborted boot. After a
// reconnect the shared flags are active again, so also require the
// request's boot epoch (when present) to match the current boot.
const requestBootEpoch = request.bootEpoch;
const bootEpochMismatch = Number.isFinite(requestBootEpoch)
&& bootEpochRef
&& requestBootEpoch !== bootEpochRef.current;
if (
!isBootActiveRef.current
|| statusRef.current === "disconnected"
|| bootEpochMismatch
) {
void terminalBackend.respondHostKeyVerification?.(request.requestId, false);
return;
}
setPendingHostKeyRequestId(request.requestId);
setPendingHostKeyInfo(toHostKeyInfo(request));
setNeedsHostKeyVerification(true);
setError(null);
setProgressLogs((prev) => [
...prev,
request.status === 'changed'
? `Host key changed for ${request.hostname}. Waiting for confirmation...`
: `Host key verification required for ${request.hostname}.`,
]);
});
return () => {
dispose?.();
};
}, [sessionId, terminalBackend]);
useEffect(() => {
// Restored sessions can mount while vault init has published hosts but not
// yet decrypted keys. Wait for hydration (and re-run when it flips) so the
// first SSH dial does not go out with an empty keychain.
if (!attachExistingSession && !vaultInitialized) {
return;
}
let disposed = false;
let initialFitTimer: ReturnType<typeof setTimeout> | undefined;
let ownedRuntime: { dispose: () => void } | null = null;
// Every boot owns a controller so cleanup can cancel an in-flight start
// instead of waiting for the async capture/teardown below to finish.
const bootAbort = new AbortController();
const bootStartOptions = { signal: bootAbort.signal };
const closeGeneration = ++terminalBootCloseGenerationRef.current;
if (bootEpochRef) {
bootEpochRef.current += 1;
setTerminalBootEpoch(sessionId, bootEpochRef.current);
}
isBootActiveRef.current = true;
terminalDataCapturedRef.current = false;
connectionLogBufferRef.current.reset();
terminalOutputHistory?.clear();
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer();
setError(null);
hasConnectedRef.current = false;
pendingOutputScrollRef.current = false;
setProgressLogs([]);
setShowLogs(false);
setIsCancelling(false);
setIsDisconnectedDialogDismissed(false);
promptLineBreakStateRef.current = createPromptLineBreakState();
const disposeOwnedRuntime = () => {
const runtime = ownedRuntime;
ownedRuntime = null;
if (!runtime) return;
if (xtermRuntimeRef.current === runtime) {
xtermRuntimeRef.current = null;
termRef.current = null;
fitAddonRef.current = null;
serializeAddonRef.current = null;
searchAddonRef.current = null;
hasRuntimeRef.current = false;
}
try {
runtime.dispose();
} catch (err) {
logger.warn("Failed to dispose terminal runtime on unmount", err);
}
};
const boot = async () => {
try {
if (disposed || !containerRef.current) return;
const runtime = createXTermRuntime({
container: containerRef.current,
host,
fontFamilyId,
resolvedFontFamily,
fontSize,
terminalTheme: effectiveTheme,
terminalSettingsRef,
kittyKeyboardProtocolEnabled: kittyKeyboardProtocolEnabledForSession,
terminalBackend,
sessionRef,
hotkeySchemeRef,
disableTerminalFontZoomRef,
keyBindingsRef,
onHotkeyActionRef,
onTerminalFontSizeChange,
onOpenExternalError,
isBroadcastEnabledRef,
onBroadcastInputRef,
snippetsRef,
onSnippetShortkeyRef,
sessionId,
statusRef,
onCommandExecuted,
onCommandSubmitted,
onTrustedCommandSubmitted: pluginAwareOnCommandSubmitted,
onCommandCompleted: pluginAwareOnCommandCompleted,
requestPluginTerminalProviders,
pluginProviderVisible: isVisible,
isPluginTerminalProviderAvailable,
onResize: (cols: number, rows: number) => (
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'resized', { cols, rows })
),
onAlternateScreenChange: (alternateScreen: boolean) => (
publishPluginTerminalRuntimeLifecycleEvent(
pluginTerminalLifecycle,
'alternateScreenChanged',
{ alternateScreen },
)
),
commandBufferRef,
promptLineBreakStateRef,
scriptRecorderRef,
passwordPromptActiveRef,
allowHostStyleGreaterThanPrompt: isNetworkDevice,
onOutputTriggerUserInputRef,
sudoAutofillRef,
requestSearchFocus,
// Serial-specific options
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
telnetLocalEchoRef,
onTerminalLogData: captureTerminalLogData,
terminalOutputHistory,
onCwdChange: (cwd: string) => {
onPluginRuntimeCwdChange(cwd, { source: 'osc7' });
},
onTitleChange: (title: string | null) => {
terminalTitleRef.current = title || undefined;
publishPluginTerminalRuntimeLifecycleEvent(
pluginTerminalLifecycle,
'titleChanged',
{ ...(title ? { title } : {}) },
);
onTerminalTitleChange?.(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,
// Autocomplete integration
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
onAutocompleteReposition: () => autocompleteRepositionRef.current?.(),
terminalContextActionsRef,
isRestoringSelectionRef,
// Defer WebGL context creation for panes that mount hidden (e.g. the
// background tabs of a batch connect) until they first become visible.
initiallyVisible: isRendererActive,
});
if (disposed) {
runtime.dispose();
return;
}
ownedRuntime = runtime;
xtermRuntimeRef.current = runtime;
termRef.current = runtime.term;
fitAddonRef.current = runtime.fitAddon;
serializeAddonRef.current = runtime.serializeAddon;
searchAddonRef.current = runtime.searchAddon;
hasRuntimeRef.current = true;
// xterm boots asynchronously; ResizeObserver may have already run without
// fitAddon and will not re-attach until isVisible/isResizing changes.
initialFitTimer = setTimeout(() => {
if (disposed) return;
safeFit({ force: true, requireVisible: true });
}, 0);
// Apply merged keyword highlight rules immediately after runtime creation
// This fixes a timing issue where the useEffect for keyword highlighting
// runs before the runtime is created, causing host-level rules to be missed
const globalRules = terminalSettingsRef.current?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled;
const effectiveGlobalEnabled = globalEnabled;
const effectiveHostEnabled = hostEnabled ?? false;
const mergedRules = [
...(effectiveGlobalEnabled ? globalRules : []),
...(effectiveHostEnabled ? hostRules : [])
];
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled;
runtime.keywordHighlighter.setRules(mergedRules, isEnabled);
runtime.cursorLineHighlighter.setBackgroundColor(
resolveCursorLineHighlightBackground(effectiveTheme.colors),
);
runtime.cursorLineHighlighter.setEnabled(
terminalSettingsRef.current?.highlightCursorLine ?? false,
);
const term = runtime.term;
const restoredReconnect = restoreState === "restored-disconnected";
if (restoredReconnect) {
prepareRestoredReconnect?.();
} else {
prepareInitialCwdIntent?.();
}
const setBackendConnectingStatus = () => {
if (restoredReconnect) {
updateStatus("connecting");
} else {
setStatus("connecting");
}
};
if (attachExistingSession) {
// Observe popup: pause → snapshot → rebind → resume so no live bytes
// fall into the gap between snapshot and route handoff.
let outputRebound = false;
let reboundHomeWebContentsId: number | null = null;
const outputPauseLease = await terminalBackend.acquireSessionFlowPauseLease(sessionId);
try {
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");
}
if (disposed) {
outputPauseLease.release();
return;
}
// Snapshot while home still owns the display route (and stream is paused).
const snap = await terminalBackend.requestSessionSnapshot?.(
sessionId,
attachAuthorization || "",
);
if (disposed) {
outputPauseLease.release();
return;
}
if (!snap?.success) {
throw new Error(snap?.error || "Failed to capture terminal snapshot");
}
if (typeof snap.kittyKeyboardProtocolEnabled === "boolean") {
runtime.setKittyKeyboardProtocolEnabled(snap.kittyKeyboardProtocolEnabled);
}
if (typeof snap.passwordPromptActive === "boolean") {
passwordPromptActiveRef.current = snap.passwordPromptActive;
}
if (snap.cwd !== undefined) {
const cwd = terminalCwdTracker.setRendererCwd(snap.cwd, "snapshot");
knownCwdRef.current = cwd;
publishPluginTerminalRuntimeLifecycleEvent(
pluginTerminalLifecycle,
'cwdChanged',
{ ...(cwd ? { cwd } : {}) },
);
onTerminalCwdChange?.(sessionId, cwd ?? null);
}
if (snap.title !== undefined) {
const title = snap.title || null;
terminalTitleRef.current = title ?? undefined;
publishPluginTerminalRuntimeLifecycleEvent(
pluginTerminalLifecycle,
'titleChanged',
{ ...(title ? { title } : {}) },
);
onTerminalTitleChange?.(sessionId, title);
}
if (snap.kittyKeyboardModeState) {
runtime.restoreKittyKeyboardModeState(snap.kittyKeyboardModeState);
}
if (snap?.success && snap.snapshot) {
try {
await new Promise<void>((resolve) => term.write(snap.snapshot!, resolve));
} catch (writeErr) {
logger.warn("Failed to write attach snapshot to popup terminal", writeErr);
}
}
// Snapshot was applied with term.write(), not the connection-log
// sanitizer. Re-seed from the restored buffer so live TUI redraws
// after attach (vim already open) stay omitted until leave.
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer({
alternateScreenActive: isTerminalAlternateScreenActive(term),
});
const rebind = await terminalBackend.rebindSessionOutput?.(
sessionId,
attachAuthorization || "",
);
if (disposed) {
if (rebind?.success) {
try {
await terminalBackend.restoreSessionOutput?.(
sessionId,
rebind.previousWebContentsId ?? null,
attachAuthorization || "",
);
} catch {
// Main-process closed lifecycle remains the final fallback.
}
}
outputPauseLease.release();
return;
}
if (!rebind?.success) {
outputPauseLease.release();
setError(rebind?.error || "Failed to attach to session");
updateStatus("disconnected");
isBootActiveRef.current = false;
return;
}
outputRebound = true;
reboundHomeWebContentsId = rebind.previousWebContentsId ?? null;
if (attachHomeWebContentsIdRef) {
attachHomeWebContentsIdRef.current = rebind.previousWebContentsId ?? null;
}
sessionRef.current = sessionId;
const attached = sessionStarters.reattachSession(term);
if (!attached) {
throw new Error("Failed to attach display to session");
}
// Resume only after the popup has subscribed to the live session.
outputPauseLease.release();
hasConnectedRef.current = true;
updateStatus("connected");
setTimeout(() => {
if (disposed) return;
safeFit({ force: true, requireVisible: true });
try {
term.scrollToBottom?.();
} catch {
// ignore
}
try {
terminalBackend.resizeSession?.(sessionId, term.cols, term.rows);
} catch {
// ignore
}
}, 50);
} catch (attachErr) {
if (outputRebound) {
try {
const restored = await terminalBackend.restoreSessionOutput?.(
sessionId,
reboundHomeWebContentsId,
attachAuthorization || "",
);
if (restored?.success) outputPauseLease.release();
} catch {
// Main-process close/recovery lifecycle remains the fallback.
}
} else {
outputPauseLease.release();
}
if (disposed) return;
logger.error("Failed to attach existing session", attachErr);
setError(attachErr instanceof Error ? attachErr.message : String(attachErr));
updateStatus("disconnected");
}
isBootActiveRef.current = false;
return;
}
if (!shouldStartTerminalBackend()) {
isBootActiveRef.current = false;
return;
}
const bootStillActive = () => !disposed && isBootActiveRef.current;
if (effectiveTerminalProtocol.startsWith("plugin:")) {
setBackendConnectingStatus();
setProgressLogs(["Initializing plugin connection..."]);
await sessionStarters.startPluginConnection(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "serial") {
setBackendConnectingStatus();
setProgressLogs(["Initializing serial connection..."]);
await sessionStarters.startSerial(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "local") {
setBackendConnectingStatus();
setProgressLogs(["Initializing local shell..."]);
await sessionStarters.startLocal(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "telnet") {
setBackendConnectingStatus();
setProgressLogs(["Initializing Telnet connection..."]);
await sessionStarters.startTelnet(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "mosh") {
setBackendConnectingStatus();
setProgressLogs(["Initializing Mosh connection..."]);
await sessionStarters.startMosh(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "et") {
setBackendConnectingStatus();
setProgressLogs(["Initializing EternalTerminal connection..."]);
await sessionStarters.startEt(term, bootStartOptions);
if (!bootStillActive()) return;
} else {
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const hasPassword = !!resolvedAuth.password;
const hasKey = !!resolvedAuth.keyId;
const hasPendingAuth = pendingAuthRef.current;
if (
!hasPassword &&
!hasKey &&
!hasPendingAuth &&
!resolvedAuth.username
) {
auth.setNeedsAuth(true);
setStatus("disconnected");
return;
}
setBackendConnectingStatus();
setProgressLogs(["Initializing secure channel..."]);
await sessionStarters.startSSH(term, bootStartOptions);
if (!bootStillActive()) return;
}
} catch (err) {
if (disposed || !isBootActiveRef.current) return;
logger.error("Failed to initialize terminal", err);
setError(err instanceof Error ? err.message : String(err));
updateStatus("disconnected");
}
};
queueMicrotask(() => {
if (disposed) return;
void boot();
});
return () => {
disposed = true;
if (initialFitTimer !== undefined) clearTimeout(initialFitTimer);
// Abort first: a start still awaiting the bridge must stop counting as
// the current attempt before anything below yields to a microtask.
bootAbort.abort();
isBootActiveRef.current = false;
if (hibernatedRef?.current) {
forceCloseHibernatedSession?.();
disposeOwnedRuntime();
return;
}
// A boot that never reached "connected" has nothing worth capturing, and
// the teardown below awaits terminal drain plus serialization before it
// closes anything. Tell the main process now so it can abort the pending
// boot (passphrase prompt, TCP dial) for this epoch instead of letting it
// finish against a pane that is already gone. Attached popups never own
// the backend session (skip closeSession) but still sync-dispose xterm.
if (!hasConnectedRef.current) {
if (!attachExistingSession) {
try {
const closeResult = terminalBackend.closeSession(
sessionRef.current ?? sessionId,
{ bootEpoch: bootEpochRef ? bootEpochRef.current : undefined },
);
void Promise.resolve(closeResult).catch((err: unknown) => {
logger.warn("Failed to cancel pending terminal boot on unmount", err);
});
} catch (err) {
logger.warn("Failed to cancel pending terminal boot on unmount", err);
}
}
disposeOwnedRuntime();
return;
}
const persistCloseCapture = (data: string, source: string, dataLength: number) => {
logger.info("[Terminal] Capturing data on unmount", {
sessionId,
source,
dataLength,
});
handleTerminalDataCaptureOnce(sessionId, data, { finalized: true });
};
const term = termRef.current;
const completeClose = async () => {
if (term) {
// A hidden terminal can have more output queued than the synchronous
// resume helper intentionally drains. Wait for the complete ordered
// backlog before finalizing the saved capture.
const flushed = await flushPendingTerminalWritesBeforeHibernate(term);
if (!isTerminalCloseGenerationCurrent(
closeGeneration,
terminalBootCloseGenerationRef.current,
)) {
// Remount already replaced the live refs; still drop this boot's
// orphaned DOM node if dispose was deferred past the generation bump.
disposeOwnedRuntime();
return;
}
if (!flushed) {
logger.warn("Terminal output did not drain before close capture; skipping stale capture");
teardown();
ownedRuntime = null;
return;
}
}
if (!isTerminalCloseGenerationCurrent(
closeGeneration,
terminalBootCloseGenerationRef.current,
)) {
disposeOwnedRuntime();
return;
}
const connectionLogPayload = !terminalDataCapturedRef.current
? resolveConnectionLogCapturePayload(finalizeTerminalLogData)
: null;
if (connectionLogPayload) {
persistCloseCapture(
connectionLogPayload.data,
connectionLogPayload.source,
connectionLogPayload.data.length,
);
ownedRuntime = null;
scheduleTerminalCloseTeardown(teardown);
return;
}
const serializeAddon = serializeAddonRef.current;
if (!terminalDataCapturedRef.current && term && serializeAddon) {
const preferWasm = resolveHibernatePreferWasmSerialize(terminalSettingsRef.current);
try {
const payload = await serializeTerminalCloseFallback(term, serializeAddon, {
preferWasm,
prepare: () => xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization()
?? Promise.resolve(),
});
if (!isTerminalCloseGenerationCurrent(
closeGeneration,
terminalBootCloseGenerationRef.current,
)) {
disposeOwnedRuntime();
return;
}
if (payload) {
persistCloseCapture(payload.data, payload.source, payload.data.length);
}
ownedRuntime = null;
scheduleTerminalCloseTeardown(teardown);
} catch (err) {
if (!isTerminalCloseGenerationCurrent(
closeGeneration,
terminalBootCloseGenerationRef.current,
)) {
disposeOwnedRuntime();
return;
}
logger.warn("Failed to serialize terminal data on unmount:", err);
ownedRuntime = null;
scheduleTerminalCloseTeardown(teardown);
}
return;
}
ownedRuntime = null;
teardown();
};
void completeClose();
};
}, [
attachExistingSession,
forceCloseHibernatedSession,
handleTerminalDataCaptureOnce,
host.id,
sessionId,
vaultInitialized,
]);
// Connection timeline and timeout visuals
useEffect(() => {
const hasSshTcpConnectProgress = host.protocol !== "local"
&& host.protocol !== "serial"
&& host.protocol !== "telnet"
&& host.protocol !== "mosh"
&& !host.moshEnabled
&& host.protocol !== "et"
&& !host.etEnabled
&& !isPluginHostProtocol(host.protocol);
const timeoutState = {
status,
needsAuth: auth.needsAuth,
isLocalConnection,
isSerialConnection,
hasSshTcpConnectProgress,
needsHostKeyVerification: Boolean(needsHostKeyVerification),
isConnectionAwaitingUserInput: Boolean(isConnectionAwaitingUserInput),
isConnectionPastTcpDial: Boolean(isConnectionPastTcpDial),
};
if (!shouldRunConnectionTimeout(timeoutState)) return;
const activeTimeoutHost = resolveActiveConnectionTimeoutHost(
host,
chainHosts,
chainProgress?.currentHop,
chainProgress?.connectionPhase,
);
const hostConnectionTimeouts = resolveHostSshConnectionTimeouts(activeTimeoutHost);
const connectionTimeout = getConnectionTimeoutMs(timeoutState, {
tcpConnectTimeoutMs: hostConnectionTimeouts.tcpConnectTimeoutSeconds * 1000,
authReadyTimeoutMs: hostConnectionTimeouts.authReadyTimeoutSeconds * 1000,
}) || CONNECTION_TIMEOUT;
setTimeLeft(connectionTimeout / 1000);
const countdown = setInterval(() => {
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
}, 1000);
const timeout = setTimeout(() => {
setError("Connection timed out. Please try again.");
updateStatus("disconnected");
setProgressLogs((prev) => [...prev, "Connection timed out."]);
}, connectionTimeout);
const prog = setInterval(() => {
setProgressValue(advanceIndeterminateConnectionProgress);
}, 200);
return () => {
clearInterval(countdown);
clearTimeout(timeout);
clearInterval(prog);
};
}, [status, auth.needsAuth, host.protocol, host.hostname, host.moshEnabled, host.etEnabled, host.sshTcpConnectTimeoutSeconds, host.sshAuthReadyTimeoutSeconds, chainHosts, chainProgress?.currentHop, chainProgress?.connectionPhase, isLocalConnection, isSerialConnection, needsHostKeyVerification, isConnectionAwaitingUserInput, isConnectionPastTcpDial]);
useEffect(() => {
if (status === "connecting") {
setIsDisconnectedDialogDismissed(false);
setProgressValue(CONNECTION_PROGRESS_START);
}
}, [status]);
const effectiveThemeKey = `${effectiveTheme.id}:${effectiveTheme.colors.background}:${effectiveTheme.colors.foreground}:${effectiveTheme.colors.cursor}`;
// Sync xterm theme before browser paint; apply synchronously on visible panes.
useLayoutEffect(() => {
const term = termRef.current;
if (!term) return;
if (isRendererActiveRef.current || isFocused) {
cancelTerminalThemeUpdate(sessionId);
applyTerminalThemeSync(term, effectiveTheme);
injectTerminalPaneAppearanceVars(sessionId, effectiveTheme);
return;
}
scheduleTerminalThemeUpdate(
sessionId,
effectiveTheme,
{ visible: false, focused: false },
() => termRef.current,
);
}, [effectiveThemeKey, isFocused, isVisible, sessionId, effectiveTheme]);
// Keep font-size sync separate from terminalSettings so unrelated setting
// updates (or focus/layout re-renders) do not reset a wheel/Ctrl zoom that
// has not yet propagated into React props.
useEffect(() => {
if (!termRef.current) return;
termRef.current.options.fontSize = effectiveFontSize;
xtermRuntimeRef.current?.clearTextureAtlas();
if (isRendererActiveRef.current) {
const refitTimer = setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
return () => clearTimeout(refitTimer);
} else {
lastFittedSizeRef.current = null;
}
}, [effectiveFontSize]);
useEffect(() => {
if (!termRef.current) return;
termRef.current.options.fontFamily = resolvedFontFamily;
if (terminalSettings) {
applyUserCursorPreference(termRef.current, terminalSettings);
termRef.current.options.scrollback = resolveXTermScrollback(terminalSettings.scrollback);
termRef.current.options.fontWeight = effectiveFontWeight as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
const resolvedFontWeightBold = resolveFontWeightBold({
fontFamilyCss: termRef.current?.options.fontFamily || "",
normalWeight: effectiveFontWeight,
desiredBoldWeight: terminalSettings.fontWeightBold,
fontSize: effectiveFontSize,
});
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
termRef.current.options.lineHeight = 1 + terminalSettings.linePadding / 10;
termRef.current.options.drawBoldTextInBrightColors =
terminalSettings.drawBoldInBrightColors;
termRef.current.options.minimumContrastRatio =
terminalSettings.minimumContrastRatio;
termRef.current.options.smoothScrollDuration =
terminalSettings.smoothScrolling
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
: 0;
termRef.current.options.scrollOnUserInput =
shouldEnableNativeUserInputAutoScroll(terminalSettings);
const altKeyOpts = terminalAltKeyOptions(terminalSettings.altAsMeta);
termRef.current.options.macOptionIsMeta = altKeyOpts.macOptionIsMeta;
termRef.current.options.altClickMovesCursor = altKeyOpts.altClickMovesCursor;
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
}
// Changing the font can leave the WebGL renderer drawing stale glyphs from
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
// Clear the texture atlas so glyphs re-rasterize with the new font.
xtermRuntimeRef.current?.clearTextureAtlas();
if (isRendererActiveRef.current) {
const refitTimer = setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
return () => clearTimeout(refitTimer);
} else {
lastFittedSizeRef.current = null;
}
}, [effectiveFontSize, effectiveFontWeight, resolvedFontFamily, terminalSettings]);
const runImmediateRefit = (options?: { force?: boolean; repeatOnNextFrame?: boolean }) => {
const force = options?.force === true;
const repeatOnNextFrame = options?.repeatOnNextFrame ?? force;
if (force) {
lastFittedSizeRef.current = null;
}
safeFit({ force, requireVisible: true, immediate: true });
if (force && repeatOnNextFrame && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => {
safeFit({ force: true, requireVisible: true, immediate: true });
});
}
};
const syncPtySizeAfterLayout = () => {
const term = termRef.current;
const id = sessionRef.current;
if (!term || !id) return;
try {
if (isTerminalAlternateScreenActive(term)) {
nudgeAlternateScreenRedraw(term);
} else {
resizeSession(id, term.cols, term.rows);
}
} catch (err) {
logger.warn('Sync session size after layout failed', err);
}
};
const finishLayoutRecovery = () => {
const term = termRef.current;
if (term) {
forceSyncRenderAfterResize(term);
}
syncPtySizeAfterLayout();
};
const finishLayoutRecoveryAfterFit = () => {
const run = () => finishLayoutRecovery();
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => requestAnimationFrame(run));
return;
}
window.setTimeout(run, 0);
};
const layoutRecoveryTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const clearLayoutRecoveryTimers = () => {
for (const timerId of layoutRecoveryTimersRef.current) {
clearTimeout(timerId);
}
layoutRecoveryTimersRef.current = [];
};
// Re-fit after the app returns from the background, macOS fullscreen toggles,
// or other layout changes that do not reliably fire window.resize /
// ResizeObserver (common after App Nap / GPU context eviction).
const scheduleLayoutRecoveryRefit = (delaysMs: number[] = [80, 250]) => {
clearLayoutRecoveryTimers();
for (const delayMs of delaysMs) {
const timerId = setTimeout(() => {
layoutRecoveryTimersRef.current = layoutRecoveryTimersRef.current.filter((id) => id !== timerId);
if (!isVisibleRef.current) return;
runImmediateRefit({ force: true, repeatOnNextFrame: false });
finishLayoutRecoveryAfterFit();
}, delayMs);
layoutRecoveryTimersRef.current.push(timerId);
}
};
useEffect(() => {
const handleRestoreFocus = (event: Event) => {
const detail = (event as CustomEvent<TerminalSessionRestoreFocusDetail>).detail;
if (detail?.sessionId !== sessionId) return;
if (!isVisibleRef.current) return;
const term = termRef.current;
if (!term) return;
applyUserCursorBlinkPreference(term, terminalSettingsRef.current);
if (hasOpenAppDialog()) return;
term.focus();
scheduleLayoutRecoveryRefit([0, 100]);
};
window.addEventListener(TERMINAL_SESSION_RESTORE_FOCUS_EVENT, handleRestoreFocus);
return () => window.removeEventListener(TERMINAL_SESSION_RESTORE_FOCUS_EVENT, handleRestoreFocus);
}, [sessionId]);
const layoutAlreadyCommitted = () => (
lastCommittedVisibleLayoutKeyRef.current === paneLayoutKey
);
const commitVisibleLayout = () => {
lastCommittedVisibleLayoutKeyRef.current = paneLayoutKey;
};
const currentContainerSizeAlreadyFit = () => {
const container = containerRef.current;
const lastSize = lastFittedSizeRef.current;
if (!container || !lastSize) return false;
const width = container.clientWidth;
const height = container.clientHeight;
return width > 0 && height > 0 && lastSize.width === width && lastSize.height === height;
};
const getHiddenDurationMs = () => (
hiddenAtRef.current !== null
? Date.now() - hiddenAtRef.current
: Number.POSITIVE_INFINITY
);
const flushPendingOutputScroll = () => {
if (!pendingOutputScrollRef.current) return;
termRef.current?.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
termRef.current?.scrollToBottom();
});
}
pendingOutputScrollRef.current = false;
};
// The output-pressure visibility flag is read live by the keyword
// highlighter's write callback, but the write path only refreshes it at
// ingress and coalescer flush. A batch dispatched while hidden can still be
// parsing when the pane/page is revealed, and a stale `background: true`
// would then skip the in-frame viewport recolor (#3272). Re-sync the flag
// on every visibility transition so in-flight writes always see the
// current pane/document state.
const syncOutputPressureVisibility = () => {
const term = termRef.current;
if (!term) return;
setTerminalOutputPressureVisibility(
term,
isVisibleRef.current && !isTerminalPageHidden(),
);
};
const flushTerminalWritesAfterBecomeVisible = () => {
lastCommittedVisibleLayoutKeyRef.current = null;
const term = termRef.current;
if (term) {
syncOutputPressureVisibility();
cancelScheduledUnfocusedRepaint(term);
flushPendingTerminalWritesOnResume(term);
repaintTerminalAfterReveal(term, () => isVisibleRef.current);
}
};
const recoverTerminalAfterBecomeVisible = () => {
flushTerminalWritesAfterBecomeVisible();
if (
getHiddenDurationMs() < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS
&& currentContainerSizeAlreadyFit()
) {
lastWebglRecoveryLayoutKeyRef.current = paneLayoutKey;
flushPendingOutputScroll();
commitVisibleLayout();
return;
}
xtermRuntimeRef.current?.ensureWebglRenderer();
xtermRuntimeRef.current?.clearTextureAtlas();
lastWebglRecoveryLayoutKeyRef.current = paneLayoutKey;
if (currentContainerSizeAlreadyFit()) {
finishLayoutRecovery();
flushPendingOutputScroll();
commitVisibleLayout();
return;
}
lastFittedSizeRef.current = null;
runImmediateRefit({ force: true, repeatOnNextFrame: false });
finishLayoutRecoveryAfterFit();
flushPendingOutputScroll();
commitVisibleLayout();
scheduleLayoutRecoveryRefit([100, 350]);
};
// Refit synchronously when a split pane becomes visible or its bounds change.
// Tab switches hide inactive panes without resizing xterm; becoming visible
// again does not always fire ResizeObserver, leaving a gap below content.
// Skip during split-divider drag — refit runs once when split resizing ends.
// Recover every visible pane immediately so split workspaces return with
// fresh terminal buffers, scroll positions, and PTY sizes.
useLayoutEffect(() => {
const becameVisible = isVisible && !wasVisibleRef.current;
if (!isVisible) {
wasVisibleRef.current = false;
syncOutputPressureVisibility();
return;
}
if (splitResizeActive) return;
wasVisibleRef.current = true;
if (becameVisible) {
recoverTerminalAfterBecomeVisible();
return;
}
if (layoutAlreadyCommitted()) {
safeFit({ requireVisible: true });
return;
}
commitVisibleLayout();
runImmediateRefit({ force: true, repeatOnNextFrame: false });
finishLayoutRecoveryAfterFit();
}, [isVisible, paneLayoutKey, splitResizeActive]);
useLayoutEffect(() => {
if (isVisible) return;
lastCommittedVisibleLayoutKeyRef.current = null;
lastWebglRecoveryLayoutKeyRef.current = null;
if (hiddenAtRef.current === null) {
hiddenAtRef.current = Date.now();
}
}, [isVisible]);
useEffect(() => {
if (!isVisible) return;
const hiddenMs = hiddenAtRef.current
? Date.now() - hiddenAtRef.current
: Number.POSITIVE_INFINITY;
hiddenAtRef.current = null;
if (
lastWebglRecoveryLayoutKeyRef.current === paneLayoutKey
&& hiddenMs < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS
) {
flushPendingOutputScroll();
return;
}
const timer = setTimeout(() => {
lastWebglRecoveryLayoutKeyRef.current = paneLayoutKey;
// A pane that mounted hidden deferred its WebGL renderer; create it now
// that it's visible (no-op if already active or WebGL is disabled).
xtermRuntimeRef.current?.ensureWebglRenderer();
// Recover the WebGL renderer now that this tab is visible again. Hidden
// panes stay mounted off-screen (visibility:hidden) so each keeps a live
// WebGL context; creating another terminal's context — or the GPU dropping
// a non-composited off-screen canvas — can leave this terminal's drawing
// buffer corrupted ("花屏", issue #1063). Because a hidden pane keeps its
// dimensions, becoming visible triggers no resize and therefore no redraw,
// so the corruption persists until the user resizes the window. Force the
// same recovery a resize performs: clear the texture atlas (no-op on the
// DOM renderer) and synchronously repaint every row.
xtermRuntimeRef.current?.clearTextureAtlas();
runImmediateRefit({ force: true });
finishLayoutRecoveryAfterFit();
flushPendingOutputScroll();
}, 50);
return () => clearTimeout(timer);
}, [isVisible, paneLayoutKey]);
useEffect(() => {
let cancelled = false;
// Re-derive cell metrics and repaint after a font finishes loading. xterm
// measures the cell grid once at open(); a webfont that swaps in later
// (font-display: swap on the bundled JetBrains Mono / Sarasa Mono SC, or
// any user-chosen webfont) leaves the grid sized to the fallback until a
// manual resize (#1647). This performs the same recovery a resize does.
const remeasureAfterFontLoad = () => {
const term = termRef.current as {
cols: number;
rows: number;
} & XTermFontRemeasureTarget | null;
if (cancelled || !term) return;
if (!isRendererActiveRef.current) {
lastFittedSizeRef.current = null;
return;
}
const fitAddon = fitAddonRef.current;
try {
if (!forceXTermFontRemeasure(term)) {
logger.warn("Font remeasure skipped: xterm measurement hook unavailable");
}
} catch (err) {
logger.warn("Font remeasure failed", err);
}
// Font remeasurement does not invalidate cells rasterized before fonts were ready.
xtermRuntimeRef.current?.clearTextureAtlas();
const visibleTerm = termRef.current;
if (visibleTerm) {
forceSyncRenderAfterResize(visibleTerm);
}
try {
fitAddon?.fit();
} catch (err) {
logger.warn("Fit after fonts ready failed", err);
}
if (terminalSettings && termRef.current) {
const resolvedBold = resolveFontWeightBold({
fontFamilyCss: termRef.current.options?.fontFamily || "",
normalWeight: effectiveFontWeight,
desiredBoldWeight: terminalSettings.fontWeightBold,
fontSize: effectiveFontSize,
});
termRef.current.options.fontWeightBold = resolvedBold as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
}
const id = sessionRef.current;
if (id) {
try {
resizeSession(id, term.cols, term.rows);
} catch (err) {
logger.warn("Resize session after fonts ready failed", err);
}
}
};
const fontFaceSet = document.fonts as FontFaceSet | undefined;
// Coalesce bursts of loadingdone events (a cold start loads several faces
// in quick succession) into a single remeasure.
let remeasureTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleRemeasure = () => {
if (cancelled) return;
if (remeasureTimer) clearTimeout(remeasureTimer);
remeasureTimer = setTimeout(() => {
remeasureTimer = null;
remeasureAfterFontLoad();
}, 50);
};
// Any font finishing later than the initial measurement — including a
// user-chosen webfont primary — triggers a remeasure, so recovery no
// longer depends on the single document.fonts.ready resolution below.
const onLoadingDone = () => scheduleRemeasure();
fontFaceSet?.addEventListener?.("loadingdone", onLoadingDone);
const waitForFonts = async () => {
try {
if (!fontFaceSet?.ready) return;
await fontFaceSet.ready;
if (cancelled) return;
// Ensure bundled Nerd Font icon fallbacks are loaded at the terminal's
// cell size. Shell prompts can arrive before these faces finish loading
// on cold start (Linux), leaving Powerline glyphs cached as tofu (#1363).
try {
await fontFaceSet.load(`${effectiveFontSize}px "Symbols Nerd Font Mono"`);
} catch (err) {
logger.warn("Nerd Font preload failed", err);
}
// Explicitly load the bundled webfonts the terminal actually renders
// with (the Latin JetBrains Mono fallback, the Sarasa Mono SC CJK
// fallback). document.fonts.ready can resolve before these are even
// requested, so without this the cold-start grid stays mis-sized (#1647).
const fontFamilyCss = (termRef.current as { options?: { fontFamily?: string } } | null)
?.options?.fontFamily || "";
for (const family of bundledFamiliesInStack(fontFamilyCss)) {
try {
await fontFaceSet.load(`${effectiveFontSize}px "${family}"`);
} catch (err) {
logger.warn(`Bundled font preload failed: ${family}`, err);
}
}
if (cancelled) return;
remeasureAfterFontLoad();
} catch (err) {
logger.warn("Waiting for fonts failed", err);
}
};
waitForFonts();
return () => {
cancelled = true;
if (remeasureTimer) clearTimeout(remeasureTimer);
fontFaceSet?.removeEventListener?.("loadingdone", onLoadingDone);
};
}, [effectiveFontSize, effectiveFontWeight, resizeSession, resolvedFontFamily, terminalSettings]);
useEffect(() => {
if (!isRendererActive || !containerRef.current) return;
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const observer = new ResizeObserver(() => {
if (deferTerminalResizeRef?.current || !isRendererActiveRef.current) return;
if (!fitAddonRef.current) return;
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(() => {
safeFit({ requireVisible: true });
}, 250);
});
observer.observe(containerRef.current);
return () => {
if (resizeTimeout) clearTimeout(resizeTimeout);
observer.disconnect();
};
}, [isRendererActive, isResizing]);
useLayoutEffect(() => {
if (splitResizeActive) {
prevIsResizingRef.current = true;
return;
}
if (!prevIsResizingRef.current || !isVisible) {
prevIsResizingRef.current = splitResizeActive;
return;
}
prevIsResizingRef.current = splitResizeActive;
lastFittedSizeRef.current = null;
safeFit({ force: true, requireVisible: true });
finishLayoutRecoveryAfterFit();
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => {
safeFit({ force: true, requireVisible: true });
const term = termRef.current;
if (term) {
forceSyncRenderAfterResize(term);
syncPtySizeAfterLayout();
}
});
}
}, [splitResizeActive, isVisible]);
useEffect(() => {
if (!isVisible || !fitAddonRef.current) return;
// Fit twice: once after initial layout (100ms) and again after layout settles
// (350ms) to handle race conditions during split operations where the container
// dimensions may not be final on the first pass.
const timer1 = setTimeout(() => {
safeFit({ requireVisible: true });
finishLayoutRecoveryAfterFit();
}, 100);
const timer2 = setTimeout(() => {
safeFit({ force: true, requireVisible: true });
finishLayoutRecoveryAfterFit();
}, 350);
return () => { clearTimeout(timer1); clearTimeout(timer2); };
}, [inWorkspace, isVisible]);
// When search bar opens/closes, re-fit terminal and maintain scroll position
useEffect(() => {
const term = termRef.current;
const wasSearchOpen = prevIsSearchOpenRef.current;
prevIsSearchOpenRef.current = isSearchOpen;
if (!term || !fitAddonRef.current) return;
const buffer = term.buffer.active;
const wasAtBottom = buffer.viewportY >= buffer.baseY;
const prevViewportY = buffer.viewportY;
const closingSearch = wasSearchOpen && !isSearchOpen;
let raf = 0;
const settleClosedSearch = () => {
// A later open flips prevIsSearchOpenRef before this stale frame runs.
if (!closingSearch || prevIsSearchOpenRef.current) return;
settleTerminalSearchAfterLayout(
searchAddonRef.current,
term,
() => xtermRuntimeRef.current?.clearTextureAtlas(),
);
};
const timer = setTimeout(() => {
safeFit({ force: true, requireVisible: true });
settleClosedSearch();
raf = requestAnimationFrame(() => {
raf = 0;
settleClosedSearch();
if (wasAtBottom) {
term.scrollToBottom();
} else {
term.scrollToLine(prevViewportY);
}
});
}, 0);
return () => {
clearTimeout(timer);
if (raf) cancelAnimationFrame(raf);
};
}, [isSearchOpen]);
// When compose bar opens/closes, re-fit terminal and maintain scroll position
useEffect(() => {
const term = termRef.current;
if (!term || !fitAddonRef.current) return;
const buffer = term.buffer.active;
const wasAtBottom = buffer.viewportY >= buffer.baseY;
const prevViewportY = buffer.viewportY;
const timer = setTimeout(() => {
safeFit({ force: true, requireVisible: true });
requestAnimationFrame(() => {
safeFit({ force: true, requireVisible: true });
if (wasAtBottom) {
term.scrollToBottom();
} else {
term.scrollToLine(prevViewportY);
}
});
}, 0);
return () => clearTimeout(timer);
}, [isComposeBarOpen]);
useEffect(() => {
const shouldAutoFocus = isVisible && termRef.current && (!inWorkspace || isFocusMode);
if (shouldAutoFocus) {
const timer = setTimeout(() => {
// Pane focus ≠ document focus: do not yank caret from AI/side-panel inputs.
if (!shouldClaimTerminalKeyboardFocus(document.activeElement)) return;
termRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [isVisible, inWorkspace, isFocusMode]);
useEffect(() => {
if (isFocused && termRef.current && isVisible) {
const timer = setTimeout(() => {
if (!shouldClaimTerminalKeyboardFocus(document.activeElement)) return;
termRef.current?.focus();
}, 10);
return () => clearTimeout(timer);
}
}, [isFocused, isVisible, sessionId]);
// Selection overlay + copy-on-select live in TerminalSelectionAIOverlay so
// selection churn does not re-render the whole Terminal tree (e.g. when focus
// moves to the AI side panel and the selection clears).
// Track whether the terminal application has enabled mouse tracking
// (e.g. tmux with `set -g mouse on`, vim with `set mouse=a`).
// When mouse tracking is active, disable Netcatty's context menu to avoid
// conflicting with the application's own mouse handling.
useEffect(() => {
const term = termRef.current;
if (!term) return;
const disposable = term.onWriteParsed(() => {
const tracking = term.modes.mouseTrackingMode !== 'none';
if (tracking !== mouseTrackingRef.current) {
mouseTrackingRef.current = tracking;
setHasMouseTracking(tracking);
}
});
// Set initial state
const initial = term.modes.mouseTrackingMode !== 'none';
mouseTrackingRef.current = initial;
setHasMouseTracking(initial);
return () => disposable.dispose();
}, [sessionId]);
// Prevent xterm.js's built-in rightClickHandler from interfering with
// mouse-tracking TUIs. On macOS, xterm forces selection with Option, while
// most terminal users expect Shift to bypass mouse reporting. Replay
// Shift+left-click as that native xterm force-selection gesture before
// xterm receives the original.
// - mousedown (button 2 + Shift / fullscreen-apps menu): keep the press
// local so the terminal app does not also receive it
// - contextmenu: xterm.js calls textarea.select() which steals focus
// - mouseup (button 2): only stop when we also claimed the matching
// mousedown. App-owned right-clicks (Herdr, tmux, vim, ...) must receive
// the release or the TUI stays stuck with the button held (#2721).
// Intercepted at the capture phase before xterm.js's own listeners.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const rightClickPressClaim = createRightClickMouseTrackingPressClaim();
const handleContextMenuCapture = (e: MouseEvent) => {
if (!shouldInterceptMouseTrackingContextMenu({
event: e,
mouseTracking: mouseTrackingRef.current,
terminalMouseTrackingMode: termRef.current?.modes.mouseTrackingMode,
status: statusRef.current,
rightClickBehavior: terminalSettingsRef.current?.rightClickBehavior,
forceMenuInAlternateScreen: terminalSettingsRef.current?.showContextMenuOverFullscreenApps,
})) {
return;
}
e.preventDefault();
e.stopImmediatePropagation();
// stopImmediatePropagation blocks the event from reaching React's
// bubble-phase root listener, so the onContextMenu handler in
// TerminalContextMenu (which dispatches paste / select-word) never
// fires inside a mouse-tracking TUI. Without dispatching the user's
// chosen action here, right-click paste silently stops working in
// opencode, tmux with `mouse on`, vim with `set mouse=a`, etc. (#941).
// Middle-click still works because its auxclick listener lives in
// createXTermRuntime and isn't gated by mouseTracking.
const behavior = terminalSettingsRef.current?.rightClickBehavior;
if (behavior === 'paste') {
void terminalContextActionsRef.current?.onPaste?.();
} else if (behavior === 'select-word') {
terminalContextActionsRef.current?.onSelectWord?.();
}
// 'context-menu' is intentionally not handled — Radix opens the
// menu via its own pointerdown listener, which our capture handler
// does not intercept.
};
const handleMouseDownCapture = (e: MouseEvent) => {
if (rightClickPressClaim.noteMouseDown({
event: e,
mouseTracking: mouseTrackingRef.current,
terminalMouseTrackingMode: termRef.current?.modes.mouseTrackingMode,
status: statusRef.current,
rightClickBehavior: terminalSettingsRef.current?.rightClickBehavior,
forceMenuInAlternateScreen: terminalSettingsRef.current?.showContextMenuOverFullscreenApps,
})) {
e.stopImmediatePropagation();
return;
}
if (!shouldReplayShiftMouseSelectionAsMacOption({
event: e,
mouseTracking: mouseTrackingRef.current,
status: statusRef.current,
isMacPlatform: isMacPlatform(),
})) {
return;
}
const target = e.target as EventTarget | null;
if (!target || typeof target.dispatchEvent !== 'function') return;
e.preventDefault();
e.stopImmediatePropagation();
target.dispatchEvent(createMacOptionForcedSelectionMouseEvent(e));
};
const handleMouseUpCapture = (e: MouseEvent) => {
if (shouldStopRightClickMouseTrackingMouseUp({
event: e,
claimedMatchingMouseDown: rightClickPressClaim.consumeMouseUpClaim(e),
})) {
e.stopImmediatePropagation();
}
};
el.addEventListener('mousedown', handleMouseDownCapture, true);
el.addEventListener('contextmenu', handleContextMenuCapture, true);
el.addEventListener('mouseup', handleMouseUpCapture, true);
return () => {
el.removeEventListener('mousedown', handleMouseDownCapture, true);
el.removeEventListener('contextmenu', handleContextMenuCapture, true);
el.removeEventListener('mouseup', handleMouseUpCapture, true);
};
}, [sessionId]);
useEffect(() => {
if (!isRendererActive) return;
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const handler = () => {
if (!isRendererActiveRef.current) return;
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(() => {
safeFit({ requireVisible: true });
}, 250);
};
window.addEventListener("resize", handler);
return () => {
if (resizeTimeout) clearTimeout(resizeTimeout);
window.removeEventListener("resize", handler);
};
}, [isRendererActive]);
useEffect(() => {
if (!isVisible) return;
const recoverWebglRendererOnAppResume = () => {
xtermRuntimeRef.current?.ensureWebglRenderer();
};
const recoverTerminalOnAppResume = () => {
const term = termRef.current;
if (term) {
syncOutputPressureVisibility();
cancelScheduledUnfocusedRepaint(term);
flushPendingTerminalWritesOnResume(term);
forceTerminalRepaintBypassingAnimationFrame(term);
}
flushPendingOutputScroll();
recoverWebglRendererOnAppResume();
scheduleLayoutRecoveryRefit([0, 100, 300]);
};
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') {
syncOutputPressureVisibility();
return;
}
recoverTerminalOnAppResume();
};
const handleWindowFocus = () => {
recoverTerminalOnAppResume();
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleWindowFocus);
const unsubscribeWindowShown = terminalBackend.onWindowShown?.(() => {
recoverTerminalOnAppResume();
});
// Fullscreen changes layout for every visible pane.
const unsubscribeFullscreen = terminalBackend.onWindowFullScreenChanged?.((isFullscreen) => {
scheduleLayoutRecoveryRefit(isFullscreen ? [0, 150, 400] : [0, 100, 300]);
});
return () => {
clearLayoutRecoveryTimers();
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleWindowFocus);
unsubscribeWindowShown?.();
unsubscribeFullscreen?.();
};
}, [isVisible, terminalBackend]);
// Only register the snippet executor once the terminal session is ready.
// Before that, TerminalLayer falls back to raw writeToSession which is the
// correct path for sessions that are still connecting.
useEffect(() => {
if (status !== "connected") {
onSnippetExecutorChange?.(sessionId, null);
return;
}
onSnippetExecutorChange?.(sessionId, executeSnippetCommand);
return () => onSnippetExecutorChange?.(sessionId, null);
}, [executeSnippetCommand, onSnippetExecutorChange, sessionId, status]);
useEffect(() => {
if (status !== "connected") {
onBroadcastInterruptPriorityChange?.(sessionId, null);
return;
}
const prioritizeBroadcastInterrupt = () => {
const term = termRef.current;
if (!term) return;
prioritizeTerminalInput(
term,
sessionId,
getFlowControllerForTerm(term),
terminalBackend,
{
reason: "interrupt",
drainStaleOutput: shouldArmTerminalInterruptDisplayGateForProtocol(host.protocol),
},
);
};
onBroadcastInterruptPriorityChange?.(sessionId, prioritizeBroadcastInterrupt);
return () => onBroadcastInterruptPriorityChange?.(sessionId, null);
}, [
host.protocol,
onBroadcastInterruptPriorityChange,
sessionId,
status,
terminalBackend,
termRef,
]);
}