Files
NetMesh/components/terminal/runtime/createXTermRuntime.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

2950 lines
112 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { stringCellWidth } from "../autocomplete/terminalStringCellWidth";
import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image";
import { SearchAddon } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal as XTerm } from "@xterm/xterm";
import type { RefObject } from "react";
import {
checkAppShortcut,
getAppLevelActions,
getTerminalPassthroughActions,
} from "../../../application/state/useGlobalHotkeys";
import { fontStore } from "../../../application/state/fontStore";
import { KeywordHighlighter } from "../keywordHighlight";
import { installSearchDecorationTracker } from "../hooks/useTerminalSearch";
import { CursorLineHighlighter } from "./cursorLineHighlight";
import { resolveCursorLineHighlightBackground } from "../../../domain/cursorLineHighlight";
import {
registerPluginTerminalLinkProvider,
type PluginTerminalLinkProviderHost,
type RequestPluginTerminalProviders,
} from "../pluginTerminalLinkProvider";
import { PluginTerminalVisualProviderHost } from "../pluginTerminalVisualProviderHost";
import {
XTERM_PERFORMANCE_CONFIG,
resolveXTermScrollback,
type XTermPlatform,
resolveXTermPerformanceConfig,
} from "../../../infrastructure/config/xtermPerformance";
import {
scrollTerminalToBottomAfterInputIfEnabled,
shouldEnableNativeUserInputAutoScroll,
shouldScrollOnTerminalPaste,
} from "../../../domain/terminalScroll";
import { resolveTerminalInlineImageAddonOptions } from "../../../domain/terminalInlineImages";
import {
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalFontWeight,
} from "../../../domain/terminalAppearance";
import { DEFAULT_TERMINAL_SCROLLBACK } from "../../../domain/models/terminal";
import {
Osc99Assembler,
parseOsc777Payload,
parseOsc9Payload,
type OscNotification,
} from "../../../domain/terminalOscNotifications";
import { resolveFontWeightBold } from "../../../lib/fontWeightAvailability";
import { isPluginHostProtocol } from "../../../domain/pluginConnection";
import { resolveTerminalFontFamilyId } from "../../../infrastructure/config/fonts";
import { logger } from "../../../lib/logger";
import { isMacPlatform } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import {
clearTerminalViewportAndSyncPty,
installEraseInDisplayHandlers,
} from "../clearTerminalViewport";
import { pulseCopyOnSelectUserCommand } from "../copyOnSelect";
import { getTerminalSelectionForClipboard } from "../normalizeTerminalSelection";
import {
createKittyKeyboardSessionStateStore,
encodeKittyCompositionText,
encodeKittyKeyEvent,
isKittyKeyboardModeActive,
restoreKittyKeyboardModeState,
shouldEncodeKittyCompositionText,
shouldDeferKittyKeyEvent,
shouldExpectLegacyKeyboardData,
shouldMarkKittyTextInputEvent,
shouldTrackKittyKeyRelease,
shouldTreatKittyAltAsText,
snapshotKittyKeyboardModeState,
type KittyKeyboardEvent,
type KittyKeyboardModeState,
} from "./kittyKeyboardProtocol";
import { installKittyKeyboardProtocolHandlersIfEnabled } from "./kittyKeyboardRuntime";
import {
clearKittyKeyboardBroadcastPairingState,
createKittyKeyboardBroadcastForwarder,
createKittyKeyboardBroadcastHandler,
flushKittyKeyboardBroadcastReleases,
registerKittyKeyboardBroadcastHandler,
resolveWin32InputLogicalData,
upsertKittyKeyboardForwardedPress,
type KittyKeyboardBroadcastInput,
type KittyKeyboardForwardedPress,
} from "./kittyKeyboardBroadcast";
import { installUserCursorPreferenceGuard } from "./cursorPreference";
import { terminalAltKeyOptions } from "./altKeyOptions";
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
import { optionYankLastArgSequence } from "./optionYankLastArg";
import { watchDevicePixelRatio } from "./rendererDprWatch";
import { dispatchWin32InputModeEvent } from "./win32InputMode";
import { shouldDeferWebglUntilVisible } from "./webglRendererPolicy";
import { createWebglRendererController } from "./webglRendererController";
import {
captureMiddleClickTerminalMouseEvent,
markMiddleClickContextMenuEvent,
resolveMiddleClickBehavior,
} from "./middleClickBehavior";
import { handleSerialLineModeInput } from "./serialLineInput";
import {
doesKittyEncodingPreserveShiftEnter,
getShiftEnterSubmittedInput,
resolveShiftEnterText,
shouldSendShiftEnterText,
} from "./shiftEnterText";
import {
isUnchangedDeferredImeTextInput,
shouldBlockKeyPressForImeTextInput,
shouldCommitDeferredImeTextInput,
shouldDeferKeyDownForImeTextInput,
resolveDeferredKeyupRelease,
shouldDiscardStaleDeferredImeTextInput,
shouldFlushDeferredImeTextInputOnKeyUp,
shouldFlushStaleDeferredImeTextInput,
} from "./terminalImeTextInput";
import { formatSerialLocalEcho } from "./serialLocalEcho";
import { mapTerminalBackspaceInput } from "./terminalBackspaceInput";
import {
getTextInputWireChunks,
shouldSplitImeTextInputForWire,
shouldSplitRawPasteInputForWire,
} from "./terminalPerCharacterInput";
import { sanitizeTerminalInput } from "./terminalInputSanitize";
import { formatTelnetLocalEcho } from "./telnetLocalEcho";
import {
isTerminalFontSizeAction,
nextTerminalFontSizeForAction,
nextTerminalFontSizeForWheel,
shouldHandleTerminalFontSizeAction,
terminalFontSizeWheelListenerOptions,
} from "./terminalFontZoom";
import {
HISTORY_PREVIEW_HIDE_EVENT,
HISTORY_PREVIEW_OVERLAY_ATTR,
HISTORY_PREVIEW_WRAP_ATTR,
bufferHasPreviewScrollback,
encodeHistoryPreviewWrapFlags,
type HistoryPreviewRow,
getHistoryPreviewRows,
getHistoryPreviewSelectionFromRoot,
forcedHistoryScrollLinesForWheel,
forcedHistoryScrollPageToLines,
forcedHistoryScrollPagesForKey,
forcedHistoryScrollWheelListenerOptions,
isHistoryPreviewDismissClick,
isHistoryPreviewPointerTarget,
nextHistoryPreviewTop,
selectHistoryPreviewAll,
shouldHideHistoryPreviewOnMouseDown,
shouldKeepHistoryPreviewOnKey,
} from "./terminalHistoryScrollOverride";
import {
nextOutputHistoryPreviewTop,
type TerminalOutputHistoryPreview,
} from "./terminalOutputHistory";
import { shouldPassThroughCopyShortcut } from "./terminalCopyShortcut";
import { shouldUseUrgentTerminalInterrupt } from "./terminalInterruptShortcut";
import {
createTerminalInterruptTrace,
logTerminalInterruptTrace,
} from "./terminalInterruptDiagnostics";
import { clearTerminalInputStateForInterrupt } from "./terminalInterruptInputState";
import { getFlowControllerForTerm } from "./terminalSessionAttachment";
import { createTerminalResizeScheduler } from "./terminalResizeScheduler";
import { createTerminalLinkHandler } from "./terminalLinkHandler";
import { writeLocalTerminalDataInOrder } from "./terminalUnfocusedRepaint";
import {
prioritizeTerminalInput,
shouldArmTerminalInterruptDisplayGateForProtocol,
} from "./terminalOutputPipeline";
import {
markExpectedTerminalCursorPositionReport,
pasteTextIntoTerminal,
shouldBroadcastTerminalUserInput,
shouldSuppressTerminalInputScrollForUserPaste,
} from "./terminalUserPaste";
import {
consumeOsc133CommandCompletion,
type PromptLineBreakState,
} from "./promptLineBreak";
import { recordTerminalCommandExecution } from "./terminalCommandExecution";
import {
getSingleBracketedPasteLine,
getSinglePastedCommand,
prepareSudoAutofillInput,
type SudoPasswordAutofill,
} from "./terminalSudoAutofill";
import type {
Host,
KeyBinding,
TerminalSession,
TerminalSettings,
TerminalTheme,
} from "../../../types";
import {
DEFAULT_TERMINAL_WORD_SEPARATORS,
matchesKeyBinding,
type Snippet,
} from "../../../domain/models";
type TerminalBackendApi = {
openExternalAvailable: () => boolean;
openExternal: (url: string) => Promise<void>;
writeToSession: (sessionId: string, data: string) => void;
interruptSession?: (sessionId: string, trace?: NetcattyTerminalInterruptTrace) => void;
signalPluginConnection?: (
sessionId: string,
signal?: "interrupt" | "terminate" | "kill" | "eof" | "break",
) => Promise<unknown>;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
clearSessionPtyBuffer?: (sessionId: string) => void;
setSessionFlowPaused?: (sessionId: string, paused: boolean) => void;
};
// A TerminalSettings ref is owned by one mounted terminal session and survives
// renderer hibernation. Weak ownership keeps negotiated keyboard state alive
// for that session without introducing a process-wide session registry.
const kittyKeyboardStates = createKittyKeyboardSessionStateStore();
const resolveKittyKeyboardModeState = (
ctx: Pick<
CreateXTermRuntimeContext,
"terminalSettingsRef" | "kittyKeyboardModeState" | "deferWebglUntilReplayComplete"
>,
): KittyKeyboardModeState => {
if (ctx.kittyKeyboardModeState) return ctx.kittyKeyboardModeState;
const owner = ctx.terminalSettingsRef as object;
return kittyKeyboardStates.resolve(owner, ctx.deferWebglUntilReplayComplete === true);
};
export type XTermRuntime = {
term: XTerm;
fitAddon: FitAddon;
serializeAddon: SerializeAddon;
searchAddon: SearchAddon;
dispose: () => void;
/** Current working directory detected via OSC 7 */
currentCwd: string | undefined;
keywordHighlighter: KeywordHighlighter;
cursorLineHighlighter: CursorLineHighlighter;
pluginProviderHost: PluginTerminalVisualProviderHost | null;
pluginLinkProviderHost: PluginTerminalLinkProviderHost | null;
/**
* Clear the WebGL renderer's glyph texture atlas so glyphs re-rasterize on the
* next frame. No-op when the DOM renderer is active. Used to recover from the
* persistent "garbled / 花屏" corruption (issue #1049) that the WebGL atlas can
* fall into after font changes or device pixel ratio changes.
*/
clearTextureAtlas: () => void;
/**
* Create the WebGL renderer if it was deferred (pane mounted hidden) and has
* not been created yet. Idempotent; a no-op when WebGL is disabled or already
* active. Called when a deferred pane first becomes visible.
*/
ensureWebglRenderer: () => void;
/** Drop the WebGL addon while keeping the terminal alive (soft-hide). */
suspendWebglRenderer: () => void;
/**
* True while this terminal holds decoded inline images (Kitty / SIXEL / IIP).
* Hibernate snapshots are text-only, so a session that reports true must not
* be fully hibernated — the images would be gone on wake. Returns false when
* inline images are disabled, or once the image cache has been emptied by a
* terminal reset or FIFO eviction.
*/
hasInlineImages: () => boolean;
/** Clear local/per-target keyboard state before reusing this runtime. */
resetKittyConnectionInputState: () => void;
/** Emit any owed releases before detaching or closing this renderer. */
flushKittyKeyboardReleases: () => void;
/** Transfer negotiated keyboard state across renderer attach handoffs. */
getKittyKeyboardModeState: () => KittyKeyboardModeState;
restoreKittyKeyboardModeState: (state: KittyKeyboardModeState) => void;
getKittyKeyboardProtocolEnabled: () => boolean;
setKittyKeyboardProtocolEnabled: (enabled: boolean) => void;
};
export const resetKittyKeyboardModeStateForSession = (
terminalSettingsRef: object,
): void => kittyKeyboardStates.reset(terminalSettingsRef);
export type CreateXTermRuntimeContext = {
container: HTMLDivElement;
host: Host;
fontFamilyId: string;
resolvedFontFamily: string;
fontSize: number;
terminalTheme: TerminalTheme;
terminalSettingsRef: RefObject<TerminalSettings | undefined>;
kittyKeyboardProtocolEnabled?: boolean;
kittyKeyboardModeState?: KittyKeyboardModeState;
terminalBackend: TerminalBackendApi;
sessionRef: RefObject<string | null>;
hotkeySchemeRef: RefObject<"disabled" | "mac" | "pc">;
disableTerminalFontZoomRef: RefObject<boolean>;
keyBindingsRef: RefObject<KeyBinding[]>;
onHotkeyActionRef: RefObject<
((action: string, event: KeyboardEvent) => void) | undefined
>;
onTerminalFontSizeChange?: (fontSize: number) => void;
onOpenExternalError?: (error: unknown) => void;
isBroadcastEnabledRef: RefObject<boolean | undefined>;
onBroadcastInputRef: RefObject<
((
data: string,
sourceSessionId: string,
options?: { kittyKeyboardInput?: KittyKeyboardBroadcastInput },
) => void) | undefined
>;
// Snippets for shortkey support
snippetsRef?: RefObject<Snippet[]>;
onSnippetShortkeyRef?: RefObject<((snippet: Snippet) => void) | undefined>;
sessionId: string;
statusRef: RefObject<TerminalSession["status"]>;
onCommandExecuted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
onCommandSubmitted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
onTrustedCommandSubmitted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
onCommandCompleted?: () => void;
requestPluginTerminalProviders?: RequestPluginTerminalProviders;
pluginProviderVisible?: boolean;
isPluginTerminalProviderAvailable?: (kind: NetcattyTerminalProviderKind) => boolean;
onResize?: (cols: number, rows: number) => void;
onAlternateScreenChange?: (active: boolean) => void;
commandBufferRef: RefObject<string>;
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
scriptRecorderRef?: RefObject<{
isRecording: boolean;
recordInput: (data: string) => void;
recordBackspace: () => void;
recordClearLine: () => void;
recordEnter: (options?: { sensitive?: boolean }) => Promise<void>;
} | undefined>;
passwordPromptActiveRef?: RefObject<boolean>;
allowHostStyleGreaterThanPrompt?: boolean;
onOutputTriggerUserInputRef?: RefObject<((data: string) => void) | undefined>;
sudoAutofillRef?: RefObject<SudoPasswordAutofill | null>;
// Opens the search bar, or refocuses its input if already open. Used by the
// searchTerminal hotkey so Cmd/Ctrl+F re-grabs focus when the bar is open but
// unfocused (issue #1789).
requestSearchFocus: () => void;
// Serial-specific options
serialLocalEcho?: boolean;
serialLineMode?: boolean;
serialLineBufferRef?: RefObject<string>;
telnetLocalEchoRef?: RefObject<boolean>;
onTerminalLogData?: (data: string) => void;
/**
* Captured session output used as the history preview source while an app
* owns the alternate buffer and the normal buffer has no scrollback (#2516).
*/
terminalOutputHistory?: TerminalOutputHistoryPreview;
// Callback when shell reports CWD change via OSC 7
onCwdChange?: (cwd: string) => void;
// Callback when shell reports window/icon title via OSC 0/2
onTitleChange?: (title: string | null) => void;
// Callback when the shell rings the terminal bell
onBell?: () => void;
// Callback when a remote program requests a desktop notification via OSC 9/777/99
onOscNotification?: (notification: OscNotification) => void;
// Callback when remote requests clipboard read in 'prompt' mode; resolves to user's decision
onOsc52ReadRequest?: () => Promise<boolean>;
// Autocomplete key event handler — returns false if event was consumed
onAutocompleteKeyEvent?: (e: KeyboardEvent) => boolean;
// Autocomplete input handler — called on every character input
onAutocompleteInput?: (data: string) => void;
// Recompute the popup after wrap/scroll so it follows the command start (#3061)
onAutocompleteReposition?: () => void;
terminalContextActionsRef?: RefObject<{
onPaste?: () => void | Promise<void>;
onSelectWord?: () => void;
} | undefined>;
// Set to true while we're programmatically restoring a selection so that
// copy-on-select listeners can suppress redundant clipboard writes.
isRestoringSelectionRef?: RefObject<boolean>;
// Whether the pane is visible at creation time. When false, WebGL renderer
// creation is deferred until the pane first becomes visible (batch-connect
// background tabs) to avoid spinning up many WebGL contexts at once. Defaults
// to visible (immediate WebGL) when omitted.
initiallyVisible?: boolean;
/** When true, keep the DOM renderer until replay completes (hibernate wake). */
deferWebglUntilReplayComplete?: boolean;
};
const detectPlatform = (): XTermPlatform => {
if (
typeof process !== "undefined" &&
(process.platform === "darwin" ||
process.platform === "win32" ||
process.platform === "linux")
) {
return process.platform;
}
if (typeof navigator !== "undefined") {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes("win")) return "win32";
if (ua.includes("linux")) return "linux";
}
return "darwin";
};
const csiParamsInclude = (
params: readonly (number | number[])[],
target: number,
): boolean => params.some((param) => (
Array.isArray(param)
? param.includes(target)
: param === target
));
/**
* Extract the primary font family from a CSS font-family string that may
* include fallback fonts. Used by autocomplete and other helpers that need
* the first face without the CJK / icon fallback stack.
*/
export const primaryFontFamily = (fontFamily: string): string => {
// Split on commas that are NOT inside quotes to handle font names like "Foo, Bar"
const match = fontFamily.match(/^(?:"[^"]*"|'[^']*'|[^,])+/);
const first = match?.[0]?.trim();
return first || fontFamily;
};
export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime => {
const platform = detectPlatform();
const deviceMemoryGb =
typeof navigator !== "undefined" &&
typeof (navigator as { deviceMemory?: number }).deviceMemory === "number"
? (navigator as { deviceMemory?: number }).deviceMemory
: undefined;
const settings = ctx.terminalSettingsRef.current;
const rendererType = settings?.rendererType ?? "auto";
const bridge = netcattyBridge.get();
const isLocalTerminalHost = ctx.host.protocol === "local";
const windowsPty =
platform === "win32" && isLocalTerminalHost
? bridge?.getWindowsPtyInfo?.() ?? { backend: "conpty" as const }
: undefined;
const performanceConfig = resolveXTermPerformanceConfig({
platform,
deviceMemoryGb,
rendererType,
});
const hostFontId = resolveTerminalFontFamilyId(
resolveHostTerminalFontFamilyId(ctx.host, ctx.fontFamilyId),
typeof navigator !== "undefined" ? navigator.platform : "",
);
// Use fontStore for font lookup - guarantees non-empty result
const fontObj = fontStore.getFontById(hostFontId);
const fontFamily = ctx.resolvedFontFamily || fontObj.family;
const effectiveFontSize = resolveHostTerminalFontSize(ctx.host, ctx.fontSize);
const cursorStyle = settings?.cursorShape ?? "block";
const cursorBlink = settings?.cursorBlink ?? true;
const rawScrollback = settings?.scrollback ?? DEFAULT_TERMINAL_SCROLLBACK;
const scrollback = resolveXTermScrollback(rawScrollback);
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
const fontWeight = resolveHostTerminalFontWeight(ctx.host, settings?.fontWeight ?? 400);
const fontWeightBold = settings?.fontWeightBold ?? 700;
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
const scrollOnUserInput = shouldEnableNativeUserInputAutoScroll(settings);
const smoothScrollDuration = settings?.smoothScrolling
? performanceConfig.options.smoothScrollDuration
: 0;
const altIsMeta = settings?.altAsMeta ?? false;
const wordSeparator = settings?.wordSeparators ?? DEFAULT_TERMINAL_WORD_SEPARATORS;
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
const keywordHighlightEnabled = settings?.keywordHighlightEnabled ?? false;
// The state may outlive this renderer while a connected session is hibernated.
// Keeping it in the owning Terminal prevents the remote app and a recreated
// xterm instance from disagreeing about the active protocol flags.
const kittyKeyboardMode = resolveKittyKeyboardModeState(ctx);
// Negotiation handlers and key encoding must use the same runtime snapshot.
// Settings changes take effect when Terminal recreates this runtime.
let kittyKeyboardProtocolEnabled =
ctx.kittyKeyboardProtocolEnabled ?? settings?.kittyKeyboardProtocolEnabled === true;
let kittyKeyboardDisposable: ReturnType<
typeof installKittyKeyboardProtocolHandlersIfEnabled
>;
const resolvedFontWeightBold = resolveFontWeightBold({
fontFamilyCss: fontFamily,
normalWeight: fontWeight,
desiredBoldWeight: fontWeightBold,
fontSize: effectiveFontSize,
});
const canActivateTerminalLink = (event: MouseEvent): boolean => {
const currentLinkModifier = ctx.terminalSettingsRef.current?.linkModifier ?? "none";
switch (currentLinkModifier) {
case "none":
return true;
case "ctrl":
return event.ctrlKey;
case "alt":
return event.altKey;
case "meta":
return event.metaKey;
}
return false;
};
const terminalLinkHandler = createTerminalLinkHandler({
canActivate: canActivateTerminalLink,
openExternalAvailable: ctx.terminalBackend.openExternalAvailable,
openExternal: ctx.terminalBackend.openExternal,
confirmOscLink: (uri) => window.confirm(
`Do you want to navigate to ${uri}?\n\nWARNING: This link could potentially be dangerous`,
),
onError: ctx.onOpenExternalError,
warn: (...args) => logger.warn(...args),
});
const term = new XTerm({
...performanceConfig.options,
...(windowsPty ? { windowsPty } : {}),
// ConPTY asks the frontend for Win32 INPUT_RECORD encoding with DECSET
// 9001. Preserve browser key modifiers for native Windows applications
// instead of collapsing them into legacy VT bytes.
vtExtensions: {
win32InputMode: windowsPty?.backend === "conpty",
},
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
// Rescale glyphs that would visually overlap into the next cell (CJK compliance)
rescaleOverlappingGlyphs: true,
fontSize: effectiveFontSize,
fontFamily,
fontWeight: fontWeight as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900
| "normal"
| "bold",
fontWeightBold: resolvedFontWeightBold as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900
| "normal"
| "bold",
lineHeight,
cursorStyle,
cursorBlink,
scrollback,
// Cursor-line rendering and Unicode width handling use proposed APIs.
allowProposedApi: true,
drawBoldTextInBrightColors,
minimumContrastRatio,
smoothScrollDuration,
scrollOnUserInput,
macOptionClickForcesSelection: true,
linkHandler: {
activate: terminalLinkHandler.activateOsc,
},
...terminalAltKeyOptions(altIsMeta),
wordSeparator,
theme: {
...ctx.terminalTheme.colors,
selectionBackground: ctx.terminalTheme.colors.selection,
// Scrollbar theming (xterm 6.0) — derive from foreground color
scrollbarSliderBackground: ctx.terminalTheme.colors.foreground + '33', // 20% opacity
scrollbarSliderHoverBackground: ctx.terminalTheme.colors.foreground + '66', // 40% opacity
scrollbarSliderActiveBackground: ctx.terminalTheme.colors.foreground + '80', // 50% opacity
},
});
installSearchDecorationTracker(term);
type MaybeRenderer = {
constructor?: { name?: string };
type?: string;
};
type IntrospectableTerminal = XTerm & {
_core?: {
_renderService?: {
_renderer?: MaybeRenderer;
};
};
options?: {
rendererType?: string;
};
};
const trackRenderer = (attempt = 0) => {
const introspected = term as IntrospectableTerminal;
const renderer = introspected._core?._renderService?._renderer;
const candidates = [
renderer?.type,
renderer?.constructor?.name,
introspected.options?.rendererType,
];
const rendererName =
candidates.find((value) => typeof value === "string" && value.length > 0) ||
undefined;
const normalized = rendererName
? rendererName.toLowerCase().includes("webgl")
? "webgl"
: rendererName.toLowerCase().includes("canvas")
? "canvas"
: rendererName
: "unknown";
const scopedWindow = window as Window & { __xtermRenderer?: string };
scopedWindow.__xtermRenderer = normalized;
if (normalized === "unknown" && attempt < 3) {
setTimeout(() => trackRenderer(attempt + 1), 150);
}
};
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
const serializeAddon = new SerializeAddon();
term.loadAddon(serializeAddon);
const searchAddon = new SearchAddon();
term.loadAddon(searchAddon);
for (const orphan of Array.from(ctx.container.querySelectorAll(":scope > .xterm"))) {
orphan.remove();
}
term.open(ctx.container);
// Inline raster images (Kitty graphics / SIXEL / iTerm IIP). Loaded right after
// term.open so the addon can patch IRenderService.setRenderer before the WebGL
// renderer is created: on every renderer swap (WebGL create / suspend / context
// loss recovery) the addon detaches its canvas layers and re-inserts them on the
// next render, so images survive DOM <-> WebGL transitions. Options are resolved
// once per terminal; changing them takes effect on the next terminal, exactly
// like rendererType.
const inlineImageOptions = resolveTerminalInlineImageAddonOptions(settings);
let imageAddon: ImageAddon | null = null;
if (inlineImageOptions) {
try {
imageAddon = new ImageAddon(inlineImageOptions);
term.loadAddon(imageAddon);
} catch (err) {
logger.warn("[XTerm] Inline image addon failed to load", err);
imageAddon = null;
}
}
// Decoded bitmaps live outside the terminal buffer, so they are absent from the
// text snapshot a hibernated tab is rebuilt from. Terminal.tsx reads this to keep
// a session with images out of full hibernate (soft-hide is still fine).
const hasInlineImages = (): boolean => {
if (!imageAddon) return false;
try {
return imageAddon.storageUsage > 0;
} catch {
return false;
}
};
type KeyboardLayoutMapLike = { get: (code: string) => string | undefined };
type KeyboardApiLike = {
getLayoutMap?: () => Promise<KeyboardLayoutMapLike>;
addEventListener?: (type: "layoutchange", listener: () => void) => void;
removeEventListener?: (type: "layoutchange", listener: () => void) => void;
};
const keyboardApi = (navigator as Navigator & { keyboard?: KeyboardApiLike }).keyboard;
let kittyKeyboardLayoutMap: KeyboardLayoutMapLike | undefined;
const refreshKittyKeyboardLayout = () => {
void keyboardApi?.getLayoutMap?.().then((layoutMap) => {
kittyKeyboardLayoutMap = layoutMap;
}).catch(() => {
kittyKeyboardLayoutMap = undefined;
});
};
keyboardApi?.addEventListener?.("layoutchange", refreshKittyKeyboardLayout);
refreshKittyKeyboardLayout();
const kittyKeyboardLockState = { capsLock: false, numLock: false };
const toKittyKeyboardEvent = (event: KeyboardEvent): KittyKeyboardEvent => {
const unshiftedKey = kittyKeyboardLayoutMap?.get(event.code);
kittyKeyboardLockState.capsLock = event.getModifierState("CapsLock");
kittyKeyboardLockState.numLock = event.getModifierState("NumLock");
return {
type: event.type,
key: event.key,
code: event.code,
location: event.location,
repeat: event.repeat,
isComposing: event.isComposing,
keyCode: event.keyCode,
shiftKey: event.shiftKey,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
getModifierState: (key) => event.getModifierState(key),
unshiftedKey,
altKeyProducesText: shouldTreatKittyAltAsText(
event,
isMacPlatform(),
ctx.terminalSettingsRef.current?.altAsMeta ?? altIsMeta,
),
applicationCursorMode: term.modes.applicationCursorKeysMode,
};
};
// Intercept native copy (Edit > Copy, browser/Electron copy event) before
// xterm's built-in handler writes selectionText, so normalizeTextOnCopy applies.
const writePreviewSelectionToClipboard = (event: ClipboardEvent, previewSelection: string) => {
if (event.clipboardData) {
event.clipboardData.setData("text/plain", previewSelection);
} else {
void navigator.clipboard.writeText(previewSelection).catch((err) => {
logger.warn("[XTerm] History preview copy failed:", err);
});
}
event.preventDefault();
event.stopImmediatePropagation();
};
const handlePreviewNativeCopy = (event: ClipboardEvent) => {
const previewSelection = getHistoryPreviewSelectionFromRoot(ctx.container);
if (!previewSelection) return;
writePreviewSelectionToClipboard(event, previewSelection);
};
const handleNativeCopy = (event: ClipboardEvent) => {
const previewSelection = getHistoryPreviewSelectionFromRoot(ctx.container);
if (previewSelection) {
writePreviewSelectionToClipboard(event, previewSelection);
return;
}
if (!term.hasSelection()) return;
const normalize = ctx.terminalSettingsRef.current?.normalizeTextOnCopy ?? true;
if (!normalize) return; // let xterm write raw selectionText
const selection = getTerminalSelectionForClipboard(term, true);
if (!selection) return;
if (event.clipboardData) {
event.clipboardData.setData("text/plain", selection);
} else {
void navigator.clipboard.writeText(selection).catch((err) => {
logger.warn("[XTerm] Normalized native copy failed:", err);
});
}
event.preventDefault();
event.stopImmediatePropagation();
};
term.element?.addEventListener("copy", handleNativeCopy, true);
ctx.container.addEventListener("copy", handleNativeCopy, true);
let webglLoaded = false;
let runtimeDisposed = false;
const scopedWindow = window as Window & {
__xtermWebGLLoaded?: boolean;
__xtermRendererPreference?: string;
};
// Idempotent: creates the WebGL renderer on first call and no-ops afterwards
// (or when WebGL is disabled for this device). Panes that mount hidden defer
// this until they first become visible — see shouldDeferWebglUntilVisible —
// so batch-connecting many hosts doesn't spin up every WebGL context at once.
const repaintTerminal = () => {
if (runtimeDisposed || term.rows < 1) return;
try {
term.refresh(0, term.rows - 1);
} catch (err) {
logger.warn("[XTerm] renderer repaint failed", err);
}
};
const webglController = createWebglRendererController({
enabled: performanceConfig.useWebGLAddon,
createAddon: () => new WebglAddon(),
loadAddon: (addon) => term.loadAddon(addon),
repaint: repaintTerminal,
setLoaded: (loaded) => {
webglLoaded = loaded;
scopedWindow.__xtermWebGLLoaded = loaded;
},
warn: (message, error) => {
if (error === undefined) logger.warn(message);
else logger.warn(message, error);
},
});
const loadWebglRenderer = webglController.ensure;
const suspendWebglRenderer = webglController.suspend;
if (!performanceConfig.useWebGLAddon) {
logger.info(
"[XTerm] Skipping WebGL addon (DOM preferred for low-memory devices)",
);
} else if (
shouldDeferWebglUntilVisible({
useWebGLAddon: performanceConfig.useWebGLAddon,
initiallyVisible: ctx.initiallyVisible ?? true,
}) || ctx.deferWebglUntilReplayComplete
) {
logger.info("[XTerm] Deferring WebGL addon until pane becomes visible or replay completes");
} else {
loadWebglRenderer();
}
scopedWindow.__xtermWebGLLoaded = webglLoaded;
scopedWindow.__xtermRendererPreference = performanceConfig.preferDOMRenderer
? "dom"
: "webgl";
// The WebGL renderer caches rasterized glyphs in a texture atlas. Heavy TUIs
// (claude code / gemini cli / opencode and other full-screen agents), font
// changes, and device pixel ratio changes can leave that atlas in a corrupted
// state that persists for the life of the terminal — the "garbled / 花屏"
// report in issue #1049 where only opening a brand-new terminal helps. Clearing
// the atlas forces glyphs to re-rasterize at the correct scale on the next
// frame. No-op for the DOM renderer.
const clearWebglTextureAtlas = () => {
const webglAddon = webglController.getAddon();
if (!webglAddon) return;
try {
webglAddon.clearTextureAtlas();
} catch (err) {
logger.warn("[XTerm] clearTextureAtlas failed", err);
}
};
// Recover the renderer when the device pixel ratio changes (moving the window
// between monitors with different DPI, or changing OS display scaling — a
// common Windows trigger). matchMedia change does not fire a normal resize, so
// this is needed in addition to the resize handling below.
let stopDprWatch: () => void = () => {};
if (
typeof window !== "undefined" &&
typeof window.matchMedia === "function"
) {
stopDprWatch = watchDevicePixelRatio({
getDevicePixelRatio: () => window.devicePixelRatio || 1,
matchMedia: (query) => window.matchMedia(query),
onChange: () => {
clearWebglTextureAtlas();
try {
fitAddon.fit();
} catch (err) {
logger.warn("[XTerm] fit after devicePixelRatio change failed", err);
}
},
});
}
const webLinksAddon = new WebLinksAddon((event, uri) => {
terminalLinkHandler.activate(event, uri);
});
term.loadAddon(webLinksAddon);
const pluginLinkProviderHost = ctx.requestPluginTerminalProviders
? registerPluginTerminalLinkProvider({
term,
request: ctx.requestPluginTerminalProviders,
canActivate: canActivateTerminalLink,
openExternal: terminalLinkHandler.open,
isProviderAvailable: ctx.isPluginTerminalProviderAvailable,
active: ctx.statusRef.current === 'connected',
visible: ctx.pluginProviderVisible ?? true,
})
: null;
const pluginProviderHost = ctx.requestPluginTerminalProviders
? new PluginTerminalVisualProviderHost({
term,
request: ctx.requestPluginTerminalProviders,
terminalBackground: ctx.terminalTheme.colors.background,
active: ctx.statusRef.current === 'connected',
visible: ctx.pluginProviderVisible ?? true,
isProviderAvailable: ctx.isPluginTerminalProviderAvailable,
})
: null;
// Enable Unicode graphemes for accurate CJK / emoji / Nerd Font character width handling
const unicodeGraphemes = new UnicodeGraphemesAddon();
term.loadAddon(unicodeGraphemes);
term.unicode.activeVersion = '15-graphemes';
trackRenderer();
const appLevelActions = getAppLevelActions();
const terminalActions = getTerminalPassthroughActions();
const broadcastUserPasteData = (data: string) => {
if (
ctx.passwordPromptActiveRef?.current !== true
&& ctx.isBroadcastEnabledRef.current
&& ctx.onBroadcastInputRef.current
) {
ctx.onBroadcastInputRef.current(data, ctx.sessionId);
return true;
}
return false;
};
const scrollToBottomAfterInput = (data: string) => {
scrollTerminalToBottomAfterInputIfEnabled(
term,
ctx.terminalSettingsRef.current,
data,
);
};
const currentTerminalFontSize = () => {
const optionFontSize = term.options.fontSize;
return typeof optionFontSize === "number" ? optionFontSize : effectiveFontSize;
};
const applyTerminalFontSize = (nextFontSize: number | null): boolean => {
if (nextFontSize === null) return false;
const currentFontSize = currentTerminalFontSize();
if (nextFontSize !== currentFontSize) {
term.options.fontSize = nextFontSize;
clearWebglTextureAtlas();
try {
fitAddon.fit();
} catch (err) {
logger.warn("[XTerm] fit after font size change failed", err);
}
ctx.onTerminalFontSizeChange?.(nextFontSize);
}
return true;
};
const handleFontSizeWheel = (event: WheelEvent) => {
const currentScheme = ctx.hotkeySchemeRef.current;
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
const nextFontSize = nextTerminalFontSizeForWheel(
event,
currentTerminalFontSize(),
isMac,
ctx.disableTerminalFontZoomRef.current,
);
if (nextFontSize === null) return;
event.preventDefault();
event.stopPropagation();
applyTerminalFontSize(nextFontSize);
};
let historyPreviewOverlay: HTMLPreElement | null = null;
let historyPreviewTop: number | null = null;
let historyPreviewPointerDown: { clientX: number; clientY: number } | null = null;
const hideHistoryPreview = () => {
if (!historyPreviewOverlay) {
historyPreviewPointerDown = null;
document.removeEventListener("copy", handlePreviewNativeCopy, true);
return;
}
historyPreviewOverlay.remove();
historyPreviewOverlay = null;
historyPreviewTop = null;
historyPreviewPointerDown = null;
document.removeEventListener("copy", handlePreviewNativeCopy, true);
term.focus();
};
const copyHistoryPreviewSelectionIfEnabled = () => {
if (!ctx.terminalSettingsRef.current?.copyOnSelect) return;
if (ctx.isRestoringSelectionRef?.current) return;
const selection = getHistoryPreviewSelectionFromRoot(ctx.container);
if (selection) {
void navigator.clipboard.writeText(selection).catch((err) => {
logger.warn("[XTerm] History preview copy-on-select failed:", err);
});
}
};
const ensureHistoryPreviewOverlay = () => {
if (historyPreviewOverlay) return historyPreviewOverlay;
const overlay = document.createElement("pre");
overlay.setAttribute(HISTORY_PREVIEW_OVERLAY_ATTR, "");
overlay.setAttribute("role", "document");
overlay.addEventListener(HISTORY_PREVIEW_HIDE_EVENT, hideHistoryPreview);
document.addEventListener("copy", handlePreviewNativeCopy, true);
Object.assign(overlay.style, {
position: "absolute",
inset: "0",
zIndex: "8",
margin: "0",
padding: "0 6px",
overflow: "hidden",
pointerEvents: "auto",
userSelect: "text",
webkitUserSelect: "text",
cursor: "text",
whiteSpace: "pre",
fontFamily: String(term.options.fontFamily ?? fontFamily),
fontSize: `${currentTerminalFontSize()}px`,
lineHeight: String(term.options.lineHeight ?? lineHeight),
color: ctx.terminalTheme.colors.foreground,
background: ctx.terminalTheme.colors.background,
} satisfies Partial<CSSStyleDeclaration>);
ctx.container.appendChild(overlay);
historyPreviewOverlay = overlay;
return overlay;
};
const showAlternateScreenHistoryPreview = (lines: number) => {
if (term.buffer.active.type !== "alternate") return false;
const normalBuffer = term.buffer.normal;
// Inside screen/vim/codex the app owns the alternate buffer, so the normal
// buffer has no rows above the viewport and there is nothing to preview.
// Fall back to the captured session output (#2516). Apps that request mouse
// reporting never get here — xterm hands those wheel events to the app.
const outputHistory = ctx.terminalOutputHistory;
const outputRowCount = outputHistory && !bufferHasPreviewScrollback(normalBuffer)
? outputHistory.getPreviewRowCount(term.cols)
: 0;
let previewRows: HistoryPreviewRow[];
if (outputHistory && outputRowCount > 0) {
historyPreviewTop = nextOutputHistoryPreviewTop({
currentTop: historyPreviewTop,
lines,
rows: term.rows,
totalRows: outputRowCount,
});
previewRows = outputHistory.getPreviewRows({
cols: term.cols,
rows: term.rows,
top: historyPreviewTop,
}).rows;
} else {
historyPreviewTop = nextHistoryPreviewTop({
buffer: normalBuffer,
currentTop: historyPreviewTop,
lines,
});
previewRows = getHistoryPreviewRows({
buffer: normalBuffer,
rows: term.rows,
top: historyPreviewTop,
});
}
const overlay = ensureHistoryPreviewOverlay();
overlay.style.fontSize = `${currentTerminalFontSize()}px`;
overlay.style.fontFamily = String(term.options.fontFamily ?? fontFamily);
overlay.style.lineHeight = String(term.options.lineHeight ?? lineHeight);
// Native font advances differ from xterm's device-pixel-rounded cells.
// Fit each row to its terminal cell width, including double-width glyphs,
// rather than applying a single-cell correction once per Unicode glyph.
overlay.style.fontWeight = String(term.options.fontWeight ?? "normal");
overlay.style.fontKerning = "none";
overlay.style.fontVariantLigatures = "none";
const screenRect = term.element?.querySelector(".xterm-screen")?.getBoundingClientRect();
if (screenRect && screenRect.width > 0 && screenRect.height > 0
&& term.cols > 0 && term.rows > 0) {
overlay.style.lineHeight = `${screenRect.height / term.rows}px`;
const rowElements = previewRows.map((row) => {
const span = document.createElement("span");
span.style.display = "inline-block";
span.style.verticalAlign = "top";
span.style.transformOrigin = "left top";
span.textContent = row.text;
return span;
});
const fragment = document.createDocumentFragment();
rowElements.forEach((span, index) => {
if (index > 0) fragment.appendChild(document.createTextNode("\n"));
fragment.appendChild(span);
});
overlay.replaceChildren(fragment);
// Batch all measurements before writing transforms to avoid one forced
// layout per row. Text nodes and explicit newlines stay unchanged for copy.
const nativeWidths = rowElements.map((span) => span.getBoundingClientRect().width);
rowElements.forEach((span, index) => {
const nativeWidth = nativeWidths[index];
const cells = stringCellWidth(previewRows[index].text, term);
if (nativeWidth > 0 && cells > 0) {
span.style.transform = `scaleX(${cells * screenRect.width / term.cols / nativeWidth})`;
}
});
} else {
overlay.textContent = previewRows.map((row) => row.text).join("\n");
}
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, encodeHistoryPreviewWrapFlags(previewRows));
return true;
};
const scrollForcedHistoryLines = (lines: number) => {
if (showAlternateScreenHistoryPreview(lines)) return;
hideHistoryPreview();
term.scrollLines(lines);
};
const handleForcedHistoryScrollWheel = (event: WheelEvent) => {
const lines = forcedHistoryScrollLinesForWheel(event);
if (lines === null) {
hideHistoryPreview();
return;
}
event.preventDefault();
event.stopPropagation();
scrollForcedHistoryLines(lines);
};
ctx.container.addEventListener(
"wheel",
handleForcedHistoryScrollWheel,
forcedHistoryScrollWheelListenerOptions,
);
ctx.container.addEventListener(
"wheel",
handleFontSizeWheel,
terminalFontSizeWheelListenerOptions,
);
const historyPreviewBufferChangeDisposable = term.buffer.onBufferChange(() => {
hideHistoryPreview();
ctx.onAlternateScreenChange?.(term.buffer.active.type === "alternate");
});
const writeLocalTerminalData = (nextData: string) => {
writeLocalTerminalDataInOrder(term, nextData, ctx.onTerminalLogData);
};
const handleTerminalInputData = (
data: string,
options?: {
source?: "terminal" | "shift-enter" | "kitty";
/**
* User-facing input represented by a transport-specific wire payload.
* `null` means the payload has no text/editing semantics (for example a
* Win32 key-up record). The wire `data` is still written unchanged.
*/
logicalData?: string | null;
/** Skip string broadcast when peers will re-resolve from a key chord. */
skipBroadcast?: boolean;
/**
* Send plain text as one write per character. Strict bastion prompts
* (QAX) treat one channel write as a keystroke and drop multi-character
* chunks, so IME commits and short raw pastes must go out per
* character (#3077). Bookkeeping still sees the whole payload once.
*/
perCharacterWrites?: boolean;
},
) => {
// Strip zero-width / invisible formatting characters that CJK IMEs
// (notably Microsoft Pinyin / Sogou on Windows) emit when switching
// composition modes. With the 15-graphemes Unicode version these render
// at width 0, so they become hidden characters in the command line and
// cause the executed command to fail (#3138).
data = sanitizeTerminalInput(data);
if (!data) return;
const logicalData = options?.logicalData === null
? null
: sanitizeTerminalInput(options?.logicalData ?? data);
// Clipboard paste / typed password while assist is open must dismiss the
// hint first. Otherwise Enter is still hijacked for confirmFill and can
// append the host session password after the user's pasted secret (#2198).
if (logicalData) {
ctx.sudoAutofillRef?.current?.dismissOnUserContentInput(logicalData);
}
const inputSource = options?.source ?? "terminal";
const id = ctx.sessionRef.current;
const dataToWrite = data;
const sensitive = ctx.passwordPromptActiveRef?.current === true;
let handledSubmittedInput = false;
const submittedInput: { text: string; lineEnding: "\r\n" | "\r" | "\n" } | null =
logicalData === null
? null
: inputSource === "shift-enter"
? getShiftEnterSubmittedInput(logicalData)
: logicalData === "\r" || logicalData === "\n"
? { text: "", lineEnding: logicalData as "\r" | "\n" }
: null;
const onBroadcastInput = ctx.onBroadcastInputRef.current;
const broadcastDataBeforeSudo = mapTerminalBackspaceInput(
logicalData ?? "",
ctx.host.backspaceBehavior,
);
const suppressTerminalBroadcast = inputSource === "terminal" && suppressNextTerminalDataBroadcast;
if (suppressTerminalBroadcast) suppressNextTerminalDataBroadcast = false;
// skipBroadcast only suppresses the raw-string fan-out. Peers still receive
// the Shift+Enter chord, so sudo autofill must treat this as a broadcast.
const canBroadcastInput = !sensitive &&
inputSource !== "kitty" &&
!handlingKittyBroadcast &&
!suppressTerminalBroadcast &&
!!id && shouldBroadcastTerminalUserInput(term, broadcastDataBeforeSudo, {
isBroadcastEnabled: ctx.isBroadcastEnabledRef.current,
hasBroadcastInputHandler: !!onBroadcastInput,
});
const willBroadcastInput = canBroadcastInput && options?.skipBroadcast !== true;
if (ctx.statusRef.current === "connected" && submittedInput) {
if (submittedInput.text) {
ctx.commandBufferRef.current += submittedInput.text;
ctx.scriptRecorderRef?.current?.recordInput(submittedInput.text);
}
if (ctx.scriptRecorderRef?.current?.isRecording) {
void ctx.scriptRecorderRef.current.recordEnter({
sensitive,
});
}
if (ctx.passwordPromptActiveRef) ctx.passwordPromptActiveRef.current = false;
const recordedCommand = recordTerminalCommandExecution(
ctx.commandBufferRef.current,
ctx,
term,
{ sensitive, allowHostStyleGreaterThanPrompt: ctx.allowHostStyleGreaterThanPrompt },
);
handledSubmittedInput = true;
// Recipients of a key-chord broadcast must not arm password assistance.
// handlingKittyBroadcast already blocks re-fan-out via canBroadcastInput.
if (!canBroadcastInput && !handlingKittyBroadcast) {
prepareSudoAutofillInput(
submittedInput.lineEnding === "\r\n" ? "\n" : submittedInput.lineEnding,
recordedCommand,
ctx.sudoAutofillRef?.current,
);
}
} else if (
ctx.statusRef.current === "connected" &&
!canBroadcastInput &&
!handlingKittyBroadcast &&
inputSource !== "shift-enter"
) {
const pastedCommand = logicalData === null ? null : getSinglePastedCommand(logicalData);
if (pastedCommand) {
if (ctx.passwordPromptActiveRef) ctx.passwordPromptActiveRef.current = false;
const recordedCommand = recordTerminalCommandExecution(
`${ctx.commandBufferRef.current}${pastedCommand.command}`,
ctx,
term,
{ sensitive, allowHostStyleGreaterThanPrompt: ctx.allowHostStyleGreaterThanPrompt },
);
handledSubmittedInput = true;
if (recordedCommand) {
prepareSudoAutofillInput(
`${recordedCommand}${pastedCommand.lineEnding}`,
null,
ctx.sudoAutofillRef?.current,
);
}
}
}
if (id) {
prioritizeTerminalInput(
term,
id,
getFlowControllerForTerm(term),
ctx.terminalBackend,
);
// Serial line mode: buffer input and send on Enter
if (
inputSource !== "kitty" &&
ctx.host.protocol === "serial" &&
ctx.serialLineMode &&
ctx.serialLineBufferRef
) {
handleSerialLineModeInput(dataToWrite, {
bufferRef: ctx.serialLineBufferRef,
localEcho: ctx.serialLocalEcho,
writeToSession: (nextData) => {
ctx.onOutputTriggerUserInputRef?.current?.(nextData);
ctx.terminalBackend.writeToSession(id, nextData, { sensitive });
},
writeToTerminal: writeLocalTerminalData,
});
} else {
// Character mode (default): send immediately
// When backspaceBehavior is configured, remap the Backspace key output
const outData = mapTerminalBackspaceInput(dataToWrite, ctx.host.backspaceBehavior);
ctx.onOutputTriggerUserInputRef?.current?.(outData);
for (const chunk of getTextInputWireChunks(outData, options?.perCharacterWrites === true)) {
ctx.terminalBackend.writeToSession(id, chunk, { sensitive });
}
// Local echo for serial connections only when explicitly enabled
if (inputSource !== "kitty" && ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
const localEcho = formatSerialLocalEcho(dataToWrite);
if (localEcho) writeLocalTerminalData(localEcho);
}
if (inputSource !== "kitty" && ctx.host.protocol === "telnet" && ctx.telnetLocalEchoRef?.current) {
const localEcho = formatTelnetLocalEcho(dataToWrite);
if (localEcho) writeLocalTerminalData(localEcho);
}
}
// Use logical data for raw-string broadcast. Transport-specific wire
// records are dispatched separately from their normalized key event.
const broadcastData = mapTerminalBackspaceInput(
logicalData ?? "",
ctx.host.backspaceBehavior,
);
if (willBroadcastInput) {
onBroadcastInput?.(broadcastData, ctx.sessionId);
}
if (
logicalData !== null &&
!shouldSuppressTerminalInputScrollForUserPaste(term, logicalData)
) {
scrollToBottomAfterInput(logicalData);
}
// Notify autocomplete of input
if (logicalData !== null) ctx.onAutocompleteInput?.(logicalData);
if (ctx.statusRef.current === "connected" && logicalData !== null) {
if (handledSubmittedInput || submittedInput) {
// Command recording and sudo command preparation happen before the
// input is written so sudo can receive a one-time prompt marker.
} else if (logicalData === "\x7f" || logicalData === "\b") {
ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1);
ctx.scriptRecorderRef?.current?.recordBackspace();
} else if (logicalData === "\x03") {
ctx.commandBufferRef.current = "";
ctx.scriptRecorderRef?.current?.recordClearLine();
// Hard-abort password assist when Ctrl+C reaches the input path
// (e.g. broadcast peers) so a later su re-arms cleanly (#2191).
ctx.sudoAutofillRef?.current?.abort();
} else if (logicalData === "\x15") {
ctx.commandBufferRef.current = "";
ctx.scriptRecorderRef?.current?.recordClearLine();
} else if (logicalData.length === 1 && logicalData.charCodeAt(0) >= 32) {
ctx.commandBufferRef.current += logicalData;
ctx.scriptRecorderRef?.current?.recordInput(logicalData);
} else if (logicalData.length > 1 && !logicalData.startsWith("\x1b")) {
ctx.commandBufferRef.current += logicalData;
ctx.scriptRecorderRef?.current?.recordInput(logicalData);
} else {
const pastedLine = getSingleBracketedPasteLine(logicalData);
if (pastedLine) {
ctx.commandBufferRef.current += pastedLine;
ctx.scriptRecorderRef?.current?.recordInput(pastedLine);
}
}
}
}
};
let kittyCompositionPending = false;
let kittyCompositionClearTimer: number | undefined;
let imeTextInputDeferredKey: string | null = null;
let imeTextInputDeferredKittyEvent: KittyKeyboardEvent | null = null;
let win32InputModePendingEvent: {
event: KittyKeyboardEvent;
logicalData: string | null;
} | null = null;
const win32InputModeForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
const kittyForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
const broadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
const win32BroadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
const broadcastEncodedKeys = new Set<string>();
const broadcastLegacySuppressedKeys = new Set<string>();
const kittyKeyIdentity = (event: KeyboardEvent): string => event.code || event.key;
let handlingKittyBroadcast = false;
let suppressNextTerminalDataBroadcast = false;
let broadcastLegacyDataPending: string | null = null;
let broadcastLegacyDataClearTimer: number | undefined;
const clearImeTextInputDeferral = () => {
imeTextInputDeferredKey = null;
imeTextInputDeferredKittyEvent = null;
};
const clearBroadcastLegacyDataPending = () => {
broadcastLegacyDataPending = null;
suppressNextTerminalDataBroadcast = false;
if (broadcastLegacyDataClearTimer !== undefined) {
window.clearTimeout(broadcastLegacyDataClearTimer);
broadcastLegacyDataClearTimer = undefined;
}
};
const markBroadcastLegacyDataPending = (identity: string) => {
clearBroadcastLegacyDataPending();
broadcastLegacyDataPending = identity;
suppressNextTerminalDataBroadcast = true;
// xterm emits keyboard data synchronously from the keydown handler. Clear
// an unmatched key before a later paste or IME commit can be mistaken for it.
broadcastLegacyDataClearTimer = window.setTimeout(() => {
clearBroadcastLegacyDataPending();
}, 0);
};
const broadcastKittyInput = createKittyKeyboardBroadcastForwarder({
sourceSessionId: ctx.sessionId,
isHandlingBroadcast: () => handlingKittyBroadcast,
isBroadcastEnabled: () => ctx.isBroadcastEnabledRef.current,
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true,
getDispatcher: () => ctx.onBroadcastInputRef.current,
});
const commitImeTextInput = (text: string) => {
const deferredKey = imeTextInputDeferredKey;
const deferredKittyEvent = imeTextInputDeferredKittyEvent;
clearImeTextInputDeferral();
clearBroadcastLegacyDataPending();
// Deferred punctuation reaches the PTY via this manual commit instead of
// xterm's keydown → onUserInput pipeline, so SelectionService never clears.
// Match ordinary typed characters unless preserveSelectionOnInput is on.
if (
!ctx.terminalSettingsRef.current?.preserveSelectionOnInput &&
term.hasSelection()
) {
term.clearSelection();
}
// Unchanged ASCII must keep the Kitty press/release path. Composition
// encoding under report-all emits unidentified CSI 0 u (or associated
// text without a physical key), so ordinary punctuation breaks in TUIs.
if (
isUnchangedDeferredImeTextInput(deferredKey, text) &&
deferredKittyEvent &&
!term.modes.win32InputMode &&
kittyKeyboardProtocolEnabled
) {
const pressEvent: KittyKeyboardEvent = {
...deferredKittyEvent,
type: "keydown",
};
const sequence = encodeKittyKeyEvent(kittyKeyboardMode, pressEvent);
if (sequence) {
const identity = pressEvent.code || pressEvent.key;
upsertKittyKeyboardForwardedPress(
kittyForwardedKeys,
identity,
pressEvent,
[],
);
handleTerminalInputData(sequence, { source: "kitty" });
const forwarded = broadcastKittyInput({
kind: "key",
event: pressEvent,
fallbackToLegacy: true,
});
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
identity,
pressEvent,
forwarded.targetSessionIds,
);
}
return;
}
}
if (isUnchangedDeferredImeTextInput(deferredKey, text)) {
// Source may write the literal glyph (Kitty off / no sequence), but
// broadcast peers still need the physical key + paired release so
// report-all/event-type targets do not receive composition text.
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
suppressNextTerminalDataBroadcast = true;
}
handleTerminalInputData(text, { perCharacterWrites: shouldSplitImeTextInputForWire(text) });
if (deferredKittyEvent) {
const pressEvent: KittyKeyboardEvent = {
...deferredKittyEvent,
type: "keydown",
};
const identity = pressEvent.code || pressEvent.key;
// Event-type mode (flag 2) without report-all leaves printable presses
// on the text path, but still emits a paired :3 release on keyup.
// Mirror the ordinary keydown handler so that release is not dropped.
if (
!term.modes.win32InputMode &&
kittyKeyboardProtocolEnabled &&
shouldTrackKittyKeyRelease(kittyKeyboardMode, pressEvent)
) {
upsertKittyKeyboardForwardedPress(
kittyForwardedKeys,
identity,
pressEvent,
[],
);
}
const forwarded = broadcastKittyInput({
kind: "key",
event: pressEvent,
fallbackToLegacy: true,
});
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
identity,
pressEvent,
forwarded.targetSessionIds,
);
}
} else {
broadcastKittyInput({ kind: "text", text });
}
return;
}
// Actual IME remap — Kitty associated-text composition when negotiated;
// otherwise send the committed glyph (report-all alone cannot carry it).
// Sanitize before encoding so zero-width IME artifacts do not become
// Kitty escape sequences that bypass the handleTerminalInputData guard (#3138).
const sanitizedText = sanitizeTerminalInput(text);
if (!sanitizedText) return;
const encoded = term.modes.win32InputMode
? null
: encodeKittyCompositionText(kittyKeyboardMode, sanitizedText);
if (encoded) {
handleTerminalInputData(encoded, { source: "kitty" });
} else {
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
suppressNextTerminalDataBroadcast = true;
}
handleTerminalInputData(sanitizedText, {
perCharacterWrites: shouldSplitImeTextInputForWire(sanitizedText),
});
}
broadcastKittyInput({ kind: "text", text: sanitizedText });
};
const flushImeTextInputDeferral = () => {
const fallback = imeTextInputDeferredKey;
if (!fallback) return;
commitImeTextInput(fallback);
};
const armImeTextInputDeferral = (event: KeyboardEvent) => {
clearImeTextInputDeferral();
clearBroadcastLegacyDataPending();
// Wait for insertText (full-width) before keyup; keyup/blur flushes ASCII
// when the IME did not remap the key (English punctuation mode).
imeTextInputDeferredKey = event.key;
// Keep the physical key even when the source is not in Kitty mode so
// broadcast targets can still receive paired key events.
imeTextInputDeferredKittyEvent = toKittyKeyboardEvent(event);
};
/**
* Emit the paired release for a forwarded press, locally and to broadcast
* peers. Used by the keyup handler and by the stale-deferral recovery, where
* the IME dropped the release and one must be synthesized from the deferred
* physical key so the TUI does not see the key held until focus loss.
*/
const releaseForwardedKittyPress = (
event: Pick<KittyKeyboardEvent, "code" | "key"> & KittyKeyboardEvent,
): boolean => {
const identity = event.code || event.key;
const forwardedPress = broadcastForwardedKeys.get(identity);
if (forwardedPress) {
broadcastForwardedKeys.delete(identity);
broadcastKittyInput(
{ kind: "key", event },
true,
forwardedPress.targetSessionIds,
);
}
if (!kittyForwardedKeys.delete(identity)) return false;
if (term.modes.win32InputMode) return false;
const sequence = kittyKeyboardProtocolEnabled
? encodeKittyKeyEvent(kittyKeyboardMode, event)
: null;
if (sequence) {
handleTerminalInputData(sequence, { source: "kitty" });
return true;
}
return false;
};
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
// Preserve mouse selection across keystrokes when enabled. xterm.js
// unconditionally clears the selection on user input
// (SelectionService.ts: coreService.onUserInput → clearSelection).
// Capture the selection here, then re-apply it after xterm has
// processed the key + cleared. The microtask runs after both
// synchronous listeners, so by then either the selection is gone (and
// we restore) or it's still there (we no-op).
//
// Both keydown AND keypress must be hooked: xterm routes Space
// (keyCode 32 fails Keyboard.ts: `ev.keyCode >= 48`) and AZ
// (CoreBrowserTerminal.ts:_keyDown AZ IME HACK) through the
// `keypress` event, calling triggerDataEvent in _keyPress rather
// than _keyDown. For those keys, keydown's microtask drains before
// keypress fires, so hasSelection is still true → no-op. Attaching
// to keypress gives us a second microtask that drains after
// _keyPress clears the selection, so the restore runs.
if (
(e.type === "keydown" || e.type === "keypress") &&
ctx.terminalSettingsRef.current?.preserveSelectionOnInput &&
term.hasSelection()
) {
const sel = term.getSelectionPosition();
if (sel) {
const length =
(sel.end.y - sel.start.y) * term.cols + (sel.end.x - sel.start.x);
const savedStartX = sel.start.x;
const savedStartY = sel.start.y;
queueMicrotask(() => {
if (term.hasSelection()) return;
// Bail out if scrollback trim invalidated the row index.
if (savedStartY >= term.buffer.active.length) return;
const restoreFlag = ctx.isRestoringSelectionRef;
if (restoreFlag) restoreFlag.current = true;
try {
term.select(savedStartX, savedStartY, length);
} finally {
if (restoreFlag) restoreFlag.current = false;
}
});
}
}
if (e.type === "keyup") {
// insertText for this keystroke has already run when present; flush the
// deferred ASCII key when the IME did not remap it. Any real key release
// ends the deferral: Windows IMEs report Process/229 as the release of a
// key they consumed (or drop the keyup), and an exact key match left the
// deferral armed so every later keypress was swallowed (#3103).
let releaseEvent: KeyboardEvent = e;
if (
imeTextInputDeferredKey !== null &&
shouldFlushDeferredImeTextInputOnKeyUp(imeTextInputDeferredKey, e)
) {
const deferredKittyEvent = imeTextInputDeferredKittyEvent;
const releaseMode = resolveDeferredKeyupRelease(
imeTextInputDeferredKey,
deferredKittyEvent?.code ?? null,
e,
);
flushImeTextInputDeferral();
if (deferredKittyEvent && releaseMode === "deferred") {
// The release reported an IME sentinel (Process/229); pair the
// flushed press from the deferred physical key so the kitty
// sequence keeps its key identity.
releaseEvent = {
...deferredKittyEvent,
type: "keyup",
} as unknown as KeyboardEvent;
} else if (deferredKittyEvent && releaseMode === "unrelated") {
// Another held key was released first: this keyup only ends the
// stale deferral, so the flushed press needs its own synthesized
// release while the real keyup keeps its identity.
releaseForwardedKittyPress({ ...deferredKittyEvent, type: "keyup" });
}
}
const identity = kittyKeyIdentity(releaseEvent);
const hasForwardedWin32KeyDown = win32InputModeForwardedKeys.delete(identity);
if (broadcastLegacyDataPending === identity) clearBroadcastLegacyDataPending();
if (term.modes.win32InputMode) {
// Broadcast peers may still need a paired Kitty release for a keydown
// consumed by a Netcatty action (notably the urgent Ctrl+C path).
releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent));
kittyForwardedKeys.delete(identity);
// Only let xterm emit a Win32 key-up when its matching keydown was
// previously handed to xterm. Netcatty shortcuts, sudo controls and
// autocomplete consume their keydown and must not leak an orphaned
// native release into the PTY.
if (!hasForwardedWin32KeyDown) {
win32InputModePendingEvent = null;
return false;
}
win32InputModePendingEvent = {
event: toKittyKeyboardEvent(releaseEvent),
logicalData: null,
};
return true;
}
if (releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent))) {
e.preventDefault();
e.stopPropagation();
return false;
}
return true;
}
// Block keypress so xterm cannot re-emit the half-width ASCII char after we
// deferred the matching keydown for IME insertText (#2833). Scoped to the
// deferred key so a stale deferral cannot swallow unrelated keystrokes.
if (
shouldBlockKeyPressForImeTextInput(
imeTextInputDeferredKey,
e,
imeTextInputDeferredKittyEvent?.keyCode ?? null,
)
) {
return false;
}
if (e.type === "keypress" && broadcastForwardedKeys.has(kittyKeyIdentity(e))) {
// Space and uppercase letters can emit xterm data from keypress, after
// keydown's zero-delay cleanup. Rejoin that physical broadcast instead
// of sending the same character a second time as unpaired raw text.
markBroadcastLegacyDataPending(kittyKeyIdentity(e));
}
if (e.type !== "keydown") {
return true;
}
if (handlingKittyBroadcast) return true;
// A deferred punctuation keystroke that outlived its own release means the
// IME swallowed the keyup (Windows reports Process/229). Flush it before
// the next keystroke so the pending ASCII key still reaches the PTY
// instead of wedging the deferral (#3103). A modified keydown is a command
// rather than a continuation, so the lost keystroke is dropped there
// instead of being injected in front of the interrupt or shortcut.
if (imeTextInputDeferredKey !== null) {
if (shouldFlushStaleDeferredImeTextInput(imeTextInputDeferredKey, e)) {
const deferredKittyEvent = imeTextInputDeferredKittyEvent;
flushImeTextInputDeferral();
if (deferredKittyEvent) {
// The flush emitted the deferred press, but the release it waits for
// is the one the IME dropped — synthesize it so the TUI does not see
// the key held until focus loss.
releaseForwardedKittyPress({ ...deferredKittyEvent, type: "keyup" });
}
} else if (
shouldDiscardStaleDeferredImeTextInput(imeTextInputDeferredKey, e)
) {
clearImeTextInputDeferral();
}
}
if (e.keyCode === 229) {
markKittyCompositionPending(true);
}
const previewSelection = getHistoryPreviewSelectionFromRoot(ctx.container);
const hasCopyableSelection = term.hasSelection() || Boolean(previewSelection);
const forcedHistoryScrollPages = forcedHistoryScrollPagesForKey(e);
if (forcedHistoryScrollPages !== null) {
e.preventDefault();
e.stopPropagation();
const lines = forcedHistoryScrollPageToLines(forcedHistoryScrollPages, term.rows);
if (showAlternateScreenHistoryPreview(lines)) {
return false;
}
hideHistoryPreview();
term.scrollPages(forcedHistoryScrollPages);
return false;
}
const previewKeepAliveScheme = ctx.hotkeySchemeRef.current;
const previewKeepAliveIsMac =
previewKeepAliveScheme === "mac"
|| (previewKeepAliveScheme === "disabled" && isMacPlatform());
const previewKeepAliveBindings = ctx.keyBindingsRef.current;
const previewKeepAliveAction =
previewKeepAliveScheme !== "disabled" && previewKeepAliveBindings.length > 0
? checkAppShortcut(e, previewKeepAliveBindings, previewKeepAliveIsMac)?.action
: undefined;
if (
!shouldKeepHistoryPreviewOnKey(e, {
action: previewKeepAliveAction,
hasPreviewSelection: Boolean(previewSelection),
overlayVisible: Boolean(historyPreviewOverlay),
})
) {
hideHistoryPreview();
}
// Password prompt assist (sudo/su): while pending, Enter confirms the
// selected/host password; arrows move the picker; Esc soft-dismisses (keeps
// arm so the list can re-open). Checked before autocomplete so Enter pastes
// the password instead of submitting an empty line.
// Paste is handled in handleTerminalInputData (dismissOnUserContentInput)
// because clipboard paste does not go through this key handler (#2198).
const sudoAutofill = ctx.sudoAutofillRef?.current;
if (sudoAutofill?.isPromptPending()) {
if (shouldSendShiftEnterText(e, ctx.terminalSettingsRef.current)) {
sudoAutofill.cancelHint();
// fall through: Shift+Enter sends the configured terminal text
} else if (
sudoAutofill.isPickerPending()
&& e.key === "ArrowDown"
&& !e.altKey
&& !e.ctrlKey
&& !e.metaKey
) {
e.preventDefault();
sudoAutofill.moveSelection(1);
return false;
} else if (
sudoAutofill.isPickerPending()
&& e.key === "ArrowUp"
&& !e.altKey
&& !e.ctrlKey
&& !e.metaKey
) {
e.preventDefault();
sudoAutofill.moveSelection(-1);
return false;
} else if (
e.key === "Enter" &&
!e.altKey &&
!e.ctrlKey &&
!e.metaKey
) {
e.preventDefault();
sudoAutofill.confirmFill();
return false;
}
if (e.key === "Escape" || e.key === "Backspace") {
e.preventDefault();
sudoAutofill.cancelHint();
return false; // dismiss without forwarding the byte to the no-echo prompt
}
// Printable keys soft-dismiss so the user can type a password manually.
// Keep soft-dismiss for AltGr/Option-produced characters (they report
// Ctrl/Alt modifiers on Windows/macOS). Only plain Ctrl+C skips this —
// the interrupt path hard-aborts instead (#2191).
if (
e.key.length === 1
&& !shouldUseUrgentTerminalInterrupt(e, { hasSelection: hasCopyableSelection })
) {
sudoAutofill.cancelHint();
// fall through: key becomes the first char of the manually typed password
}
} else if (
sudoAutofill?.canReshowAssist()
&& !e.altKey
&& !e.ctrlKey
&& !e.metaKey
&& (e.key === "Escape" || e.key === "ArrowDown" || e.key === "ArrowUp")
) {
// Soft-dismissed but still on Password: — Esc/arrows re-open the assist.
e.preventDefault();
if (sudoAutofill.tryReshowAssist()) {
if (e.key === "ArrowDown") sudoAutofill.moveSelection(1);
if (e.key === "ArrowUp") sudoAutofill.moveSelection(-1);
}
return false;
}
// Autocomplete key handler (must be checked before other handlers)
if (ctx.onAutocompleteKeyEvent && !isKittyKeyboardModeActive(kittyKeyboardMode)) {
const consumed = ctx.onAutocompleteKeyEvent(e);
if (!consumed) return false; // Event was consumed by autocomplete
}
const kittySequenceForKeyDown =
!term.modes.win32InputMode &&
kittyKeyboardProtocolEnabled
? encodeKittyKeyEvent(kittyKeyboardMode, toKittyKeyboardEvent(e))
: null;
if (
(!kittySequenceForKeyDown || kittySequenceForKeyDown === "\x03") &&
shouldUseUrgentTerminalInterrupt(e, { hasSelection: hasCopyableSelection })
) {
const id = ctx.sessionRef.current;
if (id && ctx.statusRef.current === "connected") {
const rendererKeyAt = Date.now();
e.preventDefault();
e.stopPropagation();
// Abort password assist: user is cancelling the remote command, not
// soft-dismissing the UI. A later su must re-arm cleanly (#2191).
sudoAutofill?.abort();
const priority = prioritizeTerminalInput(
term,
id,
getFlowControllerForTerm(term),
ctx.terminalBackend,
{
reason: "interrupt",
drainStaleOutput: shouldArmTerminalInterruptDisplayGateForProtocol(ctx.host.protocol),
},
);
const interruptTrace = createTerminalInterruptTrace({
sessionId: id,
rendererKeyAt,
status: ctx.statusRef.current,
hasSelection: false,
priority,
});
logTerminalInterruptTrace("renderer-keydown-send", interruptTrace, {
priority,
});
clearTerminalInputStateForInterrupt({
commandBufferRef: ctx.commandBufferRef,
serialLineBufferRef: ctx.serialLineBufferRef,
onAutocompleteInput: ctx.onAutocompleteInput,
});
if (ctx.passwordPromptActiveRef) {
ctx.passwordPromptActiveRef.current = false;
}
if (isPluginHostProtocol(ctx.host.protocol) && ctx.terminalBackend.signalPluginConnection) {
void ctx.terminalBackend.signalPluginConnection(id, "interrupt").catch(() => {
if (ctx.terminalBackend.interruptSession) {
ctx.terminalBackend.interruptSession(id, interruptTrace);
} else {
ctx.terminalBackend.writeToSession(id, "\x03");
}
});
} else if (ctx.terminalBackend.interruptSession) {
ctx.terminalBackend.interruptSession(id, interruptTrace);
} else {
ctx.terminalBackend.writeToSession(id, "\x03");
}
const kittyEvent = toKittyKeyboardEvent(e);
const identity = kittyKeyIdentity(e);
if (
!term.modes.win32InputMode &&
kittyKeyboardProtocolEnabled &&
shouldTrackKittyKeyRelease(kittyKeyboardMode, kittyEvent)
) {
upsertKittyKeyboardForwardedPress(
kittyForwardedKeys,
identity,
kittyEvent,
[],
);
}
const forwarded = broadcastKittyInput({ kind: "key", event: kittyEvent });
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
identity,
kittyEvent,
forwarded.targetSessionIds,
);
broadcastKittyInput({
kind: "legacy",
data: "\x03",
keyIdentity: identity,
urgentInterrupt: true,
});
}
scrollToBottomAfterInput("\x03");
return false;
}
}
const currentScheme = ctx.hotkeySchemeRef.current;
// Use shared utility for platform detection when hotkey scheme is disabled
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
// Check snippet shortcuts first (even if hotkeys are disabled)
const snippets = ctx.snippetsRef?.current;
if (snippets && snippets.length > 0) {
for (const snippet of snippets) {
if (snippet.shortkey && matchesKeyBinding(e, snippet.shortkey, isMac)) {
const id = ctx.sessionRef.current;
if (id && ctx.statusRef.current === "connected") {
e.preventDefault();
e.stopPropagation();
const runSnippet = ctx.onSnippetShortkeyRef?.current;
if (runSnippet) {
void runSnippet(snippet);
}
return false;
}
return true;
}
}
}
const currentBindings = ctx.keyBindingsRef.current;
if (currentScheme !== "disabled" && currentBindings.length > 0) {
const matched = checkAppShortcut(e, currentBindings, isMac);
if (matched) {
const { action } = matched;
if (appLevelActions.has(action)) {
return true; // Let app-level handler process it
}
if (terminalActions.has(action)) {
if (
isTerminalFontSizeAction(action)
&& !shouldHandleTerminalFontSizeAction(action, ctx.disableTerminalFontZoomRef.current)
) {
return true;
}
// No xterm selection: pass Ctrl+C through for SIGINT, and Cmd+C for
// Kitty Super+C (nested TUIs). Other copy chords stay a safe no-op.
const shouldForwardCopyToTerminal =
shouldPassThroughCopyShortcut(
action,
hasCopyableSelection,
e,
);
if (shouldForwardCopyToTerminal && !kittySequenceForKeyDown) return true;
if (!shouldForwardCopyToTerminal) {
e.preventDefault();
e.stopPropagation();
switch (action) {
case "copy": {
const selection = previewSelection || getTerminalSelectionForClipboard(
term,
ctx.terminalSettingsRef.current?.normalizeTextOnCopy ?? true,
);
if (selection) navigator.clipboard.writeText(selection);
break;
}
case "paste": {
// Always share the context-menu paste path so local image-only
// clipboard can forward Ctrl+V, and remote auto-upload stays
// gated inside handleTerminalClipboardPaste.
void ctx.terminalContextActionsRef?.current?.onPaste?.();
break;
}
case "pasteSelection": {
const selection = previewSelection || getTerminalSelectionForClipboard(
term,
ctx.terminalSettingsRef.current?.normalizeTextOnCopy ?? true,
);
const id = ctx.sessionRef.current;
if (selection && id) {
hideHistoryPreview();
pasteTextIntoTerminal(term, selection, {
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
onPasteData: broadcastUserPasteData,
});
}
break;
}
case "selectAll": {
pulseCopyOnSelectUserCommand(term);
if (historyPreviewOverlay && selectHistoryPreviewAll(historyPreviewOverlay)) {
break;
}
term.selectAll();
break;
}
case "clearBuffer": {
clearTerminalViewportAndSyncPty(term, {
wipeScrollback: ctx.terminalSettingsRef.current?.clearWipesScrollback ?? true,
syncPty: () => {
const clearId = ctx.sessionRef.current;
if (clearId) {
ctx.terminalBackend.clearSessionPtyBuffer?.(clearId);
}
},
});
break;
}
case "searchTerminal": {
ctx.requestSearchFocus();
break;
}
case "increaseTerminalFontSize":
case "decreaseTerminalFontSize":
case "resetTerminalFontSize": {
applyTerminalFontSize(
nextTerminalFontSizeForAction(
action,
currentTerminalFontSize(),
ctx.disableTerminalFontZoomRef.current,
),
);
break;
}
}
return false;
}
}
}
}
// Sogou/macOS CJK punctuation: keydown still reports ASCII "," while the
// full-width "" arrives on insertText. Returning false (without
// preventDefault) stops xterm/Kitty from sending the half-width key so the
// input listener can commit the real glyph (#2833).
if (shouldDeferKeyDownForImeTextInput(e)) {
armImeTextInputDeferral(e);
return false;
}
// ConPTY's negotiated Win32 input mode keeps the native modifier and owns
// this event. Otherwise, before Kitty encoding that collapses Shift+Enter
// to bare CR/LF (flags=0, or non-preserving sets like alternate-key /
// associated-text alone), keep the configured send-text fallback. Only
// negotiated preserving modes may emit CSI-u; alternate-screen activation
// is not a capability signal.
if (
shouldSendShiftEnterText(e, ctx.terminalSettingsRef.current) &&
!term.modes.win32InputMode &&
!doesKittyEncodingPreserveShiftEnter(kittySequenceForKeyDown)
) {
const id = ctx.sessionRef.current;
if (id) {
e.preventDefault();
e.stopPropagation();
const kittyEvent = toKittyKeyboardEvent(e);
const shiftEnterText = resolveShiftEnterText(
ctx.terminalSettingsRef.current,
);
if (shiftEnterText) {
// Skip string broadcast: peers resolve Shift+Enter from their own
// negotiated keyboard mode via the key chord below.
handleTerminalInputData(shiftEnterText, {
source: "shift-enter",
skipBroadcast: true,
});
const forwarded = broadcastKittyInput({
kind: "key",
event: kittyEvent,
fallbackToLegacy: true,
});
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
kittyKeyIdentity(e),
kittyEvent,
forwarded.targetSessionIds,
);
}
}
return false;
}
}
if (kittySequenceForKeyDown) {
e.preventDefault();
e.stopPropagation();
const kittyEvent = toKittyKeyboardEvent(e);
upsertKittyKeyboardForwardedPress(
kittyForwardedKeys,
kittyKeyIdentity(e),
kittyEvent,
[],
);
handleTerminalInputData(kittySequenceForKeyDown, { source: "kitty" });
const forwarded = broadcastKittyInput({
kind: "key",
event: kittyEvent,
fallbackToLegacy: true,
urgentInterrupt: shouldUseUrgentTerminalInterrupt(e, {
hasSelection: hasCopyableSelection,
}),
});
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
kittyKeyIdentity(e),
kittyEvent,
forwarded.targetSessionIds,
);
}
return false;
}
// macOS Option+←/→ → Meta-b / Meta-f so the shell jumps by word (discussion
// #826). After kitty mode so apps using the kitty protocol keep their own
// arrow encoding; read live so the toggle applies without reconnecting.
const wordJumpSequence = isKittyKeyboardModeActive(kittyKeyboardMode)
? null
: optionArrowWordJumpSequence(
e,
ctx.terminalSettingsRef.current?.optionArrowWordJump ?? false,
isMacPlatform(),
);
if (wordJumpSequence) {
const id = ctx.sessionRef.current;
if (id) {
e.preventDefault();
e.stopPropagation();
handleTerminalInputData(wordJumpSequence);
scrollToBottomAfterInput(wordJumpSequence);
return false;
}
}
// macOS Option+. / Option+_ → ESC+. / ESC+_ so readline yank-last-arg and
// zsh insert-last-word work without enabling Option-as-Meta (issue #2364).
const yankLastArgSequence = isKittyKeyboardModeActive(kittyKeyboardMode)
? null
: optionYankLastArgSequence(e, isMacPlatform());
if (yankLastArgSequence) {
const id = ctx.sessionRef.current;
if (id) {
e.preventDefault();
e.stopPropagation();
handleTerminalInputData(yankLastArgSequence);
scrollToBottomAfterInput(yankLastArgSequence);
return false;
}
}
const normalizedKittyEvent = toKittyKeyboardEvent(e);
if (term.modes.win32InputMode) {
// The following xterm onData callback carries the lossless record.
// Keep its normalized event alongside it for non-Win32 broadcast peers
// and for local command/autocomplete bookkeeping.
win32InputModePendingEvent = {
event: normalizedKittyEvent,
logicalData: resolveWin32InputLogicalData(
normalizedKittyEvent,
term.modes.applicationCursorKeysMode,
),
};
return true;
}
if (!shouldDeferKittyKeyEvent(normalizedKittyEvent)) {
const identity = kittyKeyIdentity(e);
if (
kittyKeyboardProtocolEnabled &&
shouldTrackKittyKeyRelease(kittyKeyboardMode, normalizedKittyEvent)
) {
upsertKittyKeyboardForwardedPress(
kittyForwardedKeys,
identity,
normalizedKittyEvent,
[],
);
}
const forwarded = broadcastKittyInput({
kind: "key",
event: normalizedKittyEvent,
fallbackToLegacy: true,
});
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
identity,
normalizedKittyEvent,
forwarded.targetSessionIds,
);
}
if (
shouldExpectLegacyKeyboardData(normalizedKittyEvent) &&
ctx.isBroadcastEnabledRef.current &&
ctx.onBroadcastInputRef.current
) {
markBroadcastLegacyDataPending(identity);
}
}
return true;
});
const handleMiddleClick = (e: MouseEvent) => {
if (e.button !== 1) return;
e.preventDefault();
const behavior = resolveMiddleClickBehavior(ctx.terminalSettingsRef.current);
if (behavior === "disabled") return;
if (behavior === "paste") {
void ctx.terminalContextActionsRef?.current?.onPaste?.();
return;
}
const contextMenuEvent = markMiddleClickContextMenuEvent(new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: e.clientX,
clientY: e.clientY,
screenX: e.screenX,
screenY: e.screenY,
button: 2,
buttons: 0,
view: window,
}));
ctx.container.dispatchEvent(contextMenuEvent);
};
const handleHistoryPreviewMouseDown = (event: MouseEvent) => {
if (isHistoryPreviewPointerTarget(event.target, historyPreviewOverlay)) {
if (event.button === 0) {
historyPreviewPointerDown = { clientX: event.clientX, clientY: event.clientY };
}
return;
}
if (!shouldHideHistoryPreviewOnMouseDown(event.target, historyPreviewOverlay)) return;
hideHistoryPreview();
};
const handleHistoryPreviewMouseUp = (event: MouseEvent) => {
const down = historyPreviewPointerDown;
historyPreviewPointerDown = null;
if (!down || !isHistoryPreviewPointerTarget(event.target, historyPreviewOverlay)) return;
if (isHistoryPreviewDismissClick(down, event)) {
hideHistoryPreview();
return;
}
copyHistoryPreviewSelectionIfEnabled();
};
ctx.container.addEventListener("mousedown", captureMiddleClickTerminalMouseEvent, true);
ctx.container.addEventListener("mouseup", captureMiddleClickTerminalMouseEvent, true);
ctx.container.addEventListener("mousedown", handleHistoryPreviewMouseDown, true);
ctx.container.addEventListener("mouseup", handleHistoryPreviewMouseUp, true);
ctx.container.addEventListener("auxclick", handleMiddleClick);
fitAddon.fit();
term.focus();
const markKittyCompositionPending = (autoClear = false) => {
clearBroadcastLegacyDataPending();
if (kittyCompositionClearTimer !== undefined) {
window.clearTimeout(kittyCompositionClearTimer);
kittyCompositionClearTimer = undefined;
}
if (
(!term.modes.win32InputMode && shouldEncodeKittyCompositionText(kittyKeyboardMode)) ||
(ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current)
) {
kittyCompositionPending = true;
if (autoClear) {
kittyCompositionClearTimer = window.setTimeout(() => {
kittyCompositionPending = false;
kittyCompositionClearTimer = undefined;
}, 0);
}
}
};
const finishKittyComposition = () => {
markKittyCompositionPending();
kittyCompositionClearTimer = window.setTimeout(() => {
kittyCompositionPending = false;
kittyCompositionClearTimer = undefined;
}, 0);
};
const writeWin32InputModeEvent = (
event: KittyKeyboardEvent,
logicalData: string | null,
) => {
const pending = { event, logicalData };
win32InputModePendingEvent = pending;
dispatchWin32InputModeEvent(term, event);
if (win32InputModePendingEvent === pending) {
win32InputModePendingEvent = null;
}
};
const clearKittyConnectionInputState = () => {
// Drop deferred IME punctuation on session reset; do not write into the
// next connection. Focus loss uses clearKittyTransientInputState instead.
clearImeTextInputDeferral();
kittyCompositionPending = false;
win32InputModePendingEvent = null;
win32InputModeForwardedKeys.clear();
kittyForwardedKeys.clear();
clearKittyKeyboardBroadcastPairingState(
broadcastEncodedKeys,
broadcastLegacySuppressedKeys,
);
win32BroadcastForwardedKeys.clear();
clearBroadcastLegacyDataPending();
if (kittyCompositionClearTimer !== undefined) {
window.clearTimeout(kittyCompositionClearTimer);
kittyCompositionClearTimer = undefined;
}
};
const clearKittyTransientInputState = () => {
flushImeTextInputDeferral();
if (term.modes.win32InputMode) {
// Browsers do not deliver key-up after focus leaves the terminal. Release
// every Win32 key that xterm actually handed to ConPTY so native TUI key
// state cannot remain stuck after switching tabs or windows.
flushKittyKeyboardBroadcastReleases(
win32InputModeForwardedKeys,
(input) => {
if (input.kind === "key") {
writeWin32InputModeEvent(input.event, null);
}
},
kittyKeyboardLockState,
);
kittyForwardedKeys.clear();
win32InputModePendingEvent = null;
} else {
win32InputModeForwardedKeys.clear();
flushKittyKeyboardBroadcastReleases(
kittyForwardedKeys,
(input) => {
if (input.kind !== "key" || !kittyKeyboardProtocolEnabled) return;
const sequence = encodeKittyKeyEvent(kittyKeyboardMode, input.event);
if (sequence) handleTerminalInputData(sequence, { source: "kitty" });
},
kittyKeyboardLockState,
);
}
flushKittyKeyboardBroadcastReleases(
broadcastForwardedKeys,
broadcastKittyInput,
kittyKeyboardLockState,
);
flushKittyKeyboardBroadcastReleases(
win32BroadcastForwardedKeys,
broadcastKittyInput,
kittyKeyboardLockState,
);
clearKittyConnectionInputState();
};
const textarea = term.textarea;
const startKittyComposition = () => markKittyCompositionPending();
const markKittyTextInput = (event: InputEvent) => {
if (shouldCommitDeferredImeTextInput(imeTextInputDeferredKey, event)) {
commitImeTextInput(event.data);
// Keep the helper textarea empty so the next composition does not treat
// the deferred punctuation as pre-existing suffix text.
try {
if (textarea) textarea.value = "";
} catch {
// ignore
}
return;
}
if (shouldMarkKittyTextInputEvent(event)) markKittyCompositionPending(true);
};
textarea?.addEventListener("compositionstart", startKittyComposition);
textarea?.addEventListener("compositionend", finishKittyComposition);
// Capture on the ancestor so this runs before xterm's target listener, which
// can synchronously emit standalone emoji, speech, or mobile insertText data.
ctx.container.addEventListener("input", markKittyTextInput, true);
textarea?.addEventListener("blur", clearKittyTransientInputState);
term.onData((data) => {
const win32Input = win32InputModePendingEvent;
if (
win32Input &&
term.modes.win32InputMode &&
data.startsWith("\u001b[") &&
/^\[\d+;\d+;\d+;[01];\d+;\d+_$/.test(data.slice(1))
) {
win32InputModePendingEvent = null;
if (win32Input.event.type === "keydown") {
upsertKittyKeyboardForwardedPress(
win32InputModeForwardedKeys,
win32Input.event.code || win32Input.event.key,
win32Input.event,
[],
);
}
const identity = win32Input.event.code || win32Input.event.key;
const broadcastInput: KittyKeyboardBroadcastInput = {
kind: "win32",
data,
event: win32Input.event,
fallbackToLegacy: true,
};
if (win32Input.event.type === "keyup") {
const forwardedPress = win32BroadcastForwardedKeys.get(identity);
win32BroadcastForwardedKeys.delete(identity);
if (forwardedPress) {
broadcastKittyInput(
broadcastInput,
true,
forwardedPress.targetSessionIds,
);
}
} else {
const forwarded = broadcastKittyInput(broadcastInput);
if (forwarded) {
upsertKittyKeyboardForwardedPress(
win32BroadcastForwardedKeys,
identity,
win32Input.event,
forwarded.targetSessionIds,
);
}
}
handleTerminalInputData(data, {
logicalData: win32Input.logicalData,
skipBroadcast: true,
});
return;
}
// insertText can follow an already-delivered keypress. Its fallback marker
// must not classify the next physical key as a second composition commit.
// Actual composition/input handlers clear the physical-key pairing first.
if (broadcastLegacyDataPending) kittyCompositionPending = false;
if (kittyCompositionPending && !data.startsWith("\u001b")) {
kittyCompositionPending = false;
if (kittyCompositionClearTimer !== undefined) {
window.clearTimeout(kittyCompositionClearTimer);
kittyCompositionClearTimer = undefined;
}
// Sanitize before encoding so zero-width IME artifacts do not become
// Kitty escape sequences that bypass the handleTerminalInputData guard (#3138).
const sanitizedData = sanitizeTerminalInput(data);
if (!sanitizedData) return;
const encoded = term.modes.win32InputMode
? null
: encodeKittyCompositionText(kittyKeyboardMode, sanitizedData);
if (encoded) {
handleTerminalInputData(encoded, { source: "kitty" });
} else {
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
suppressNextTerminalDataBroadcast = true;
}
// Committed composition text, not a negotiated Kitty sequence.
handleTerminalInputData(sanitizedData, {
perCharacterWrites: shouldSplitImeTextInputForWire(sanitizedData),
});
}
broadcastKittyInput({ kind: "text", text: sanitizedData });
return;
}
if (broadcastLegacyDataPending) {
const keyIdentity = broadcastLegacyDataPending;
if (broadcastLegacyDataClearTimer !== undefined) {
window.clearTimeout(broadcastLegacyDataClearTimer);
broadcastLegacyDataClearTimer = undefined;
}
broadcastKittyInput({
kind: "legacy",
data,
keyIdentity,
});
broadcastLegacyDataPending = null;
}
// Raw multi-character onData is an unbracketed paste (or a committed
// composition): short plain text must reach strict bastions per character.
// Decide on the sanitized payload so removed zero-width characters cannot
// keep a qualifying paste above the split cap (#3138).
const sanitizedRawData = sanitizeTerminalInput(data);
handleTerminalInputData(sanitizedRawData, {
perCharacterWrites: shouldSplitRawPasteInputForWire(sanitizedRawData),
});
});
const handleKittyKeyboardBroadcast = createKittyKeyboardBroadcastHandler({
resolveOptions: () => ({
kittyProtocolEnabled: kittyKeyboardProtocolEnabled,
kittyMode: kittyKeyboardMode,
applicationCursorMode: term.modes.applicationCursorKeysMode,
encodedKeys: broadcastEncodedKeys,
legacySuppressedKeys: broadcastLegacySuppressedKeys,
win32InputMode: term.modes.win32InputMode,
shiftEnterSettings: ctx.terminalSettingsRef.current,
}),
getSessionId: () => ctx.sessionRef.current,
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true,
isConnected: () => ctx.statusRef.current === "connected",
isRuntimeDisposed: () => runtimeDisposed,
interruptSession: ctx.terminalBackend.interruptSession
? (id) => ctx.terminalBackend.interruptSession?.(id)
: undefined,
writeDisposed: (id, data) => ctx.terminalBackend.writeToSession(
id,
mapTerminalBackspaceInput(data, ctx.host.backspaceBehavior),
{ sensitive: ctx.passwordPromptActiveRef?.current === true },
),
writeActive: (data, logicalData) => handleTerminalInputData(data, {
source: "kitty",
logicalData,
}),
writeWin32Event: (event, logicalData) => {
writeWin32InputModeEvent(event, logicalData);
},
});
registerKittyKeyboardBroadcastHandler(
ctx.sessionId,
(input) => {
handlingKittyBroadcast = true;
try {
handleKittyKeyboardBroadcast(input);
} finally {
handlingKittyBroadcast = false;
}
},
);
// Track current working directory via OSC 7 escape sequences
// OSC 7 format: \x1b]7;file://hostname/path\x07 or \x1b]7;file://hostname/path\x1b\\
let currentCwd: string | undefined = undefined;
// Track DEC 2026 synchronized-output blocks so CSI 2 J can erase in place for
// Codex/Claude Code TUIs instead of pushing visible rows into scrollback.
let inDec2026SyncBlock = false;
const dec2026SyncStartDisposable = term.parser.registerCsiHandler(
{ prefix: "?", final: "h", params: [2026] },
() => {
inDec2026SyncBlock = true;
return false;
},
);
const dec2026SyncEndDisposable = term.parser.registerCsiHandler(
{ prefix: "?", final: "l", params: [2026] },
() => {
inDec2026SyncBlock = false;
return false;
},
);
const eraseScrollbackDisposable = installEraseInDisplayHandlers(term, {
getClearWipesScrollback: () => ctx.terminalSettingsRef.current?.clearWipesScrollback ?? true,
isInDec2026SyncBlock: () => inDec2026SyncBlock,
});
const markCursorPositionReportRequest = (params: readonly (number | number[])[]): boolean => {
if (csiParamsInclude(params, 6)) {
markExpectedTerminalCursorPositionReport(term);
}
return false;
};
const cursorPositionReportRequestDisposables = [
term.parser.registerCsiHandler({ final: "n" }, markCursorPositionReportRequest),
term.parser.registerCsiHandler({ prefix: "?", final: "n" }, markCursorPositionReportRequest),
];
const writeKittyKeyboardReply = (payload: string) => {
const id = ctx.sessionRef.current;
if (!id) return;
ctx.terminalBackend.writeToSession(id, payload);
};
const setKittyKeyboardProtocolEnabled = (enabled: boolean) => {
if (kittyKeyboardProtocolEnabled === enabled) return;
kittyKeyboardDisposable?.dispose();
kittyKeyboardDisposable = undefined;
kittyKeyboardProtocolEnabled = enabled;
kittyKeyboardDisposable = installKittyKeyboardProtocolHandlersIfEnabled(
enabled,
term.parser,
kittyKeyboardMode,
writeKittyKeyboardReply,
);
};
kittyKeyboardDisposable = installKittyKeyboardProtocolHandlersIfEnabled(
kittyKeyboardProtocolEnabled,
term.parser,
kittyKeyboardMode,
writeKittyKeyboardReply,
);
// Register OSC 7 handler using xterm.js parser
// OSC 7 is the standard way for shells to report the current working directory
const osc7Disposable = term.parser.registerOscHandler(7, (data) => {
try {
// data is the content after "7;" - typically "file://hostname/path"
if (data.startsWith('file://')) {
// Extract path from file:// URL
const url = new URL(data);
const path = decodeURIComponent(url.pathname);
if (path && path.length > 0) {
currentCwd = path;
ctx.onCwdChange?.(path);
logger.debug('[XTerm] OSC 7 CWD update:', path);
}
} else if (data.startsWith('/')) {
// Some shells send just the path without file:// prefix
currentCwd = data;
ctx.onCwdChange?.(data);
logger.debug('[XTerm] OSC 7 CWD update (raw path):', data);
}
} catch (err) {
logger.warn('[XTerm] Failed to parse OSC 7:', err);
}
return true; // Indicate we handled the sequence
});
const osc133Disposable = term.parser.registerOscHandler(133, (data) => {
if (consumeOsc133CommandCompletion(data, ctx.promptLineBreakStateRef?.current)) {
ctx.onCommandCompleted?.();
}
return true;
});
// OSC 9 / 777 / 99 — desktop notifications (Codex, iTerm2, rxvt, kitty)
const osc99Assembler = new Osc99Assembler();
const emitOscNotification = (notification: OscNotification | null) => {
if (!notification) return;
ctx.onOscNotification?.(notification);
};
const osc9Disposable = term.parser.registerOscHandler(9, (data) => {
emitOscNotification(parseOsc9Payload(data));
return true;
});
const osc777Disposable = term.parser.registerOscHandler(777, (data) => {
emitOscNotification(parseOsc777Payload(data));
return true;
});
const osc99Disposable = term.parser.registerOscHandler(99, (data) => {
emitOscNotification(osc99Assembler.consume(data));
return true;
});
// OSC 52 — clipboard integration
// Format: 52;<target>;<base64-data> (write) or 52;<target>;? (query/read)
// <target> is typically "c" (clipboard) or "p" (primary selection)
// Controlled by terminalSettings.osc52Clipboard: 'off' | 'write-only' | 'read-write'
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
const settings = ctx.terminalSettingsRef.current;
const mode = settings?.osc52Clipboard ?? 'write-only';
if (mode === 'off') return true;
try {
const semi = data.indexOf(';');
if (semi < 0) return true;
const target = data.substring(0, semi);
// Only handle clipboard target ('c'); reject unsupported targets like 'p' (PRIMARY)
if (target !== 'c' && target !== '') return true;
const payload = data.substring(semi + 1);
if (payload === '?') {
// Read request — allowed in read-write mode, or prompt user in prompt mode
if (mode !== 'read-write' && mode !== 'prompt') {
logger.debug('[XTerm] OSC 52 read request ignored (mode:', mode, ')');
return true;
}
const sessionId = ctx.sessionRef.current;
if (!sessionId) return true;
// Use Electron bridge as primary, fall back to navigator.clipboard
const readClipboard = async (): Promise<string> => {
try {
const bridge = netcattyBridge.get();
if (bridge?.readClipboardText) return await bridge.readClipboardText();
} catch { /* fall through to navigator.clipboard */ }
return navigator.clipboard.readText();
};
const doRead = async () => {
// In prompt mode, ask user first
if (mode === 'prompt') {
const allowed = ctx.onOsc52ReadRequest ? await ctx.onOsc52ReadRequest() : false;
if (!allowed) {
logger.debug('[XTerm] OSC 52 read denied by user');
return;
}
}
const text = await readClipboard();
// Chunked base64 encoding to avoid stack overflow on large payloads
const bytes = new TextEncoder().encode(text);
let binary = '';
for (let i = 0; i < bytes.length; i += 8192) {
binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
}
const b64 = btoa(binary);
// This host-generated protocol reply contains clipboard contents. It
// must bypass plugin input interceptors just like password/OTP data;
// otherwise terminal interception would become an undeclared
// clipboard-read capability.
ctx.terminalBackend.writeToSession(
sessionId,
`\x1b]52;${target};${b64}\x07`,
{ sensitive: true },
);
};
doRead().catch((err) => {
logger.warn('[XTerm] OSC 52 clipboard read failed:', err);
});
return true;
}
// Write: payload is base64-encoded UTF-8 text
const binary = atob(payload);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
const text = new TextDecoder().decode(bytes);
navigator.clipboard.writeText(text).catch((err) => {
logger.warn('[XTerm] OSC 52 clipboard write failed:', err);
});
logger.debug('[XTerm] OSC 52 clipboard write', { length: text.length });
} catch (err) {
logger.warn('[XTerm] Failed to handle OSC 52:', err);
}
return true;
});
const cursorPreferenceDisposable = installUserCursorPreferenceGuard(term, ctx.terminalSettingsRef);
const titleChangeDisposable = term.onTitleChange((title) => {
const trimmed = title.trim();
ctx.onTitleChange?.(trimmed.length > 0 ? trimmed : null);
});
const bellDisposable = term.onBell(() => {
ctx.onBell?.();
});
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
const resizeScheduler = createTerminalResizeScheduler(
resizeDebounceMs,
({ sessionId, cols, rows }) => {
ctx.terminalBackend.resizeSession(sessionId, cols, rows);
ctx.onResize?.(cols, rows);
},
);
term.onResize(({ cols, rows }) => {
// An idle alternate-screen application may emit nothing after resizing.
// Update history now, using xterm's cursor after any normal-buffer reflow.
const buffer = term.buffer.active;
const cursorLine = buffer.getLine(buffer.baseY + buffer.cursorY);
ctx.terminalOutputHistory?.syncViewportAfterResize({
cols, rows,
cursorX: buffer.cursorX,
cursorY: buffer.cursorY,
cursorLine: cursorLine?.translateToString(true, 0, cols) ?? "",
isWrapped: cursorLine?.isWrapped ?? false,
});
// A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
// dimensions re-rasterize cleanly (issue #1049).
clearWebglTextureAtlas();
const id = ctx.sessionRef.current;
if (!id) return;
resizeScheduler.schedule({ sessionId: id, cols, rows });
});
let autocompleteRepositionFrame = 0;
const scheduleAutocompleteReposition = () => {
const run = () => ctx.onAutocompleteReposition?.();
if (typeof requestAnimationFrame !== "function") {
run();
return;
}
// onLineFeed fires per newline. Coalesce to one rAF so a burst cannot
// enqueue thousands of React updates (#3204).
if (autocompleteRepositionFrame) return;
autocompleteRepositionFrame = requestAnimationFrame(() => {
autocompleteRepositionFrame = 0;
run();
});
};
const cancelAutocompleteReposition = () => {
if (!autocompleteRepositionFrame) return;
cancelAnimationFrame(autocompleteRepositionFrame);
autocompleteRepositionFrame = 0;
};
// Soft-wrap at the bottom scrolls the buffer without a fit/resize. Keep the
// popup glued to the command start instead of a stale viewport cell (#3061).
const autocompleteScrollDisposable = term.onScroll(scheduleAutocompleteReposition);
const autocompleteLineFeedDisposable = term.onLineFeed?.(scheduleAutocompleteReposition);
const keywordHighlighter = new KeywordHighlighter(term, {
serializeAddon,
});
keywordHighlighter.setRules(keywordHighlightRules, keywordHighlightEnabled);
const cursorLineHighlighter = new CursorLineHighlighter(term);
cursorLineHighlighter.setBackgroundColor(
resolveCursorLineHighlightBackground(ctx.terminalTheme.colors),
);
cursorLineHighlighter.setEnabled(settings?.highlightCursorLine ?? false);
return {
term,
fitAddon,
serializeAddon,
searchAddon,
keywordHighlighter,
cursorLineHighlighter,
pluginProviderHost,
pluginLinkProviderHost,
clearTextureAtlas: clearWebglTextureAtlas,
ensureWebglRenderer: loadWebglRenderer,
suspendWebglRenderer,
hasInlineImages,
resetKittyConnectionInputState: clearKittyConnectionInputState,
flushKittyKeyboardReleases: clearKittyTransientInputState,
getKittyKeyboardModeState: () => snapshotKittyKeyboardModeState(kittyKeyboardMode),
restoreKittyKeyboardModeState: (state) => restoreKittyKeyboardModeState(
kittyKeyboardMode,
state,
),
getKittyKeyboardProtocolEnabled: () => kittyKeyboardProtocolEnabled,
setKittyKeyboardProtocolEnabled,
dispose: () => {
runtimeDisposed = true;
resizeScheduler.dispose();
webglController.dispose();
term.element?.removeEventListener("copy", handleNativeCopy, true);
ctx.container.removeEventListener("copy", handleNativeCopy, true);
ctx.container.removeEventListener(
"wheel",
handleForcedHistoryScrollWheel,
forcedHistoryScrollWheelListenerOptions,
);
ctx.container.removeEventListener(
"wheel",
handleFontSizeWheel,
terminalFontSizeWheelListenerOptions,
);
ctx.container.removeEventListener("auxclick", handleMiddleClick);
ctx.container.removeEventListener("mousedown", captureMiddleClickTerminalMouseEvent, true);
ctx.container.removeEventListener("mouseup", captureMiddleClickTerminalMouseEvent, true);
ctx.container.removeEventListener("mousedown", handleHistoryPreviewMouseDown, true);
ctx.container.removeEventListener("mouseup", handleHistoryPreviewMouseUp, true);
hideHistoryPreview();
historyPreviewBufferChangeDisposable.dispose();
stopDprWatch();
keywordHighlighter.dispose();
cursorLineHighlighter.dispose();
pluginLinkProviderHost?.dispose();
pluginProviderHost?.dispose();
eraseScrollbackDisposable.dispose();
dec2026SyncStartDisposable.dispose();
dec2026SyncEndDisposable.dispose();
for (const disposable of cursorPositionReportRequestDisposables) {
disposable.dispose();
}
kittyKeyboardDisposable?.dispose();
keyboardApi?.removeEventListener?.("layoutchange", refreshKittyKeyboardLayout);
textarea?.removeEventListener("compositionstart", startKittyComposition);
textarea?.removeEventListener("compositionend", finishKittyComposition);
ctx.container.removeEventListener("input", markKittyTextInput, true);
textarea?.removeEventListener("blur", clearKittyTransientInputState);
clearKittyTransientInputState();
osc7Disposable.dispose();
osc133Disposable.dispose();
osc9Disposable.dispose();
osc777Disposable.dispose();
osc99Disposable.dispose();
osc52Disposable.dispose();
titleChangeDisposable.dispose();
cancelAutocompleteReposition();
autocompleteScrollDisposable.dispose();
autocompleteLineFeedDisposable?.dispose();
bellDisposable.dispose();
cursorPreferenceDisposable?.dispose();
// Release decoded bitmaps and the image canvas layers before xterm tears the
// screen element down, otherwise the storage would outlive its terminal.
if (imageAddon) {
try {
imageAddon.dispose();
} catch (err) {
logger.warn("[XTerm] imageAddon dispose failed", err);
}
imageAddon = null;
}
try {
term.dispose();
} catch (err) {
logger.warn("[XTerm] dispose failed", err);
}
try {
fitAddon.dispose();
} catch (err) {
logger.warn("[XTerm] fitAddon dispose failed", err);
}
try {
serializeAddon.dispose();
} catch (err) {
logger.warn("[XTerm] serializeAddon dispose failed", err);
}
try {
searchAddon.dispose();
} catch (err) {
logger.warn("[XTerm] searchAddon dispose failed", err);
}
},
get currentCwd() {
return currentCwd;
},
};
};