Files
Netcatty/components/terminal/runtime/createXTermRuntime.ts
T
cd0fa39f49 feat(terminal): click archive names to fill extraction command (#3269) (#3787)
* feat(terminal): click archive names to fill extraction command

Paste an absolute tar or unzip command when a Linux session
Ctrl/Cmd-clicks a printed archive name. Cwd comes from OSC 7
markers only, so a later directory change cannot retarget an
old relative name.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): anchor wrapped archive links on the continuation row

A token that starts on the next physical row was mapped to the previous
row's exclusive end cell. Inclusive offsets now move to the next segment,
while an end-exclusive offset stays on the row where the match stops.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): separate archive fill from text after the cursor

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): separate archive fill across a wrapped draft

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 16:39:44 +08:00

3587 lines
147 KiB
TypeScript
Raw 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 { clearTerminalBroadcastUserInput, captureTerminalBroadcastInput, isTerminalBroadcastInputCurrent, markTerminalBroadcastUserInput, type TerminalPacedBroadcast } from "./terminalPacedBroadcast";
import { stringCellWidth } from "../autocomplete/terminalStringCellWidth";
import type { TerminalBroadcastInputOptions } from "../terminalHelpers";
import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image";
import { LigaturesAddon } from "@xterm/addon-ligatures";
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 { supportsTerminalArchiveCommands } from "../../../domain/terminalArchives";
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks";
import { createTerminalCwdMarkers } from "./terminalCwdMarkers";
import { isTerminalReadyForCommandInjection } from "./terminalCommandInjectionReadyRegistry";
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 { normalizeCursorBarWidth } from "../../../domain/models/terminal";
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 { commandArrowLineJumpSequence } from "./commandArrowLineJump";
import { optionYankLastArgSequence } from "./optionYankLastArg";
import { watchDevicePixelRatio } from "./rendererDprWatch";
import { dispatchWin32InputModeEvent } from "./win32InputMode";
import { shouldDeferWebglUntilVisible } from "./webglRendererPolicy";
import {
createTerminalLigatureController,
terminalFontLigaturesEnabled,
} from "./terminalFontLigatures";
import { createWebglRendererController } from "./webglRendererController";
import {
captureMiddleClickTerminalMouseEvent,
markMiddleClickContextMenuEvent,
resolveMiddleClickBehavior,
} from "./middleClickBehavior";
import { handleSerialLineModeInput } from "./serialLineInput";
import { isTerminalReportSequence } from "./terminalReportSequence";
import {
getShiftEnterSubmittedInput,
nativeKittyKeyboardPreservesShiftEnter,
readNativeKittyKeyboardFlags,
resolveShiftEnterText,
shouldClaimShiftEnterForText,
shouldSendShiftEnterText,
} from "./shiftEnterText";
import {
isUnchangedDeferredImeTextInput,
shouldBlockKeyPressForImeTextInput,
shouldCommitDeferredImeTextInput,
shouldDeferKeyDownForImeTextInput,
resolveDeferredKeyupRelease,
shouldDiscardStaleDeferredImeTextInput,
shouldFlushDeferredImeTextInputOnKeyUp,
shouldFlushStaleDeferredImeTextInput,
} from "./terminalImeTextInput";
import {
keepImeCommittedTextThroughModifierKeyDowns,
keepLateImeTextInsert,
markImeDeferralConsumedInput,
} from "./imeModifierKeyDownSeenGuard";
import { keepLiveImeTranscriptionSingle } from "./imeCompositionRewrite";
import { formatSerialLocalEcho } from "./serialLocalEcho";
import { getLastChar, removeLastChar, isPrintableInput } from "../../../domain/serialCharMetrics";
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 {
isMacCommandPeriodInterruptChord,
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,
registerTerminalLinePasteHandler,
shouldBroadcastTerminalUserInput,
shouldOverrideTerminalUserPasteSensitivity,
shouldSuppressTerminalInputScrollForUserPaste,
} from "./terminalUserPaste";
import { pasteTextWithMultilineConfirm } from "../terminalClipboardPaste";
import { requestMultilinePasteConfirm } from "../../../application/state/multilinePasteConfirmStore";
import { requestHostBrowserPanel } from "../../../application/state/hostBrowserPanelRequests";
import {
consumeOsc133CommandCompletion,
type PromptLineBreakState,
} from "./promptLineBreak";
import { isSensitiveTerminalCommandInput, 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: NetcattyBridge["writeToSession"];
notifyUserInput?: (sessionId: string) => void;
interruptSession?: NetcattyBridge["interruptSession"];
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;
/** Track the pending final line of a serial snippet left for editing. */
recordSerialSnippetInput: (data: string) => void;
invalidatePendingPasteDraft: () => 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;
/**
* Turn programming ligatures on or off. An active WebGL renderer is rebuilt
* because its glyph atlas captures font-feature-settings only at creation.
*/
syncFontLigatures: (enabled: boolean) => 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;
/** Host OS facts can arrive after the runtime is created. */
getHost?: () => 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?: TerminalBroadcastInputOptions,
) => 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>;
captureSubmittedLineRecorder?: () => ((line: string, options?: { sensitive?: boolean; includePendingInput?: boolean; consumePendingInput?: boolean }) => Promise<void>) | undefined;
} | undefined>;
passwordPromptActiveRef?: RefObject<boolean>;
/**
* Opt-in "broadcast without password protection" (#3488). When true, an
* active password prompt no longer pauses raw-string broadcast fan-out.
*/
broadcastPasswordBypassRef?: 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;
/** Opt-in for devices whose line editor deletes bytes instead of characters. */
serialByteOrientedBackspace?: 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 cursorWidth = normalizeCursorBarWidth(settings?.cursorBarWidth);
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,
useInternal: () => ctx.terminalSettingsRef.current?.linkBrowser !== 'system',
openInternal: ['ssh', 'mosh', 'local'].includes(ctx.host.protocol ?? 'ssh')
? (url) => requestHostBrowserPanel({ host: ctx.host, sessionId: ctx.sessionId, url })
: undefined,
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.
// kittyKeyboard lets an app opt into xterm's CSI u. Netcatty's own
// handlers, when that setting is on, register later and consume those
// sequences first, so the two parsers do not both apply the protocol.
vtExtensions: {
win32InputMode: windowsPty?.backend === "conpty",
kittyKeyboard: true,
},
// 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,
cursorWidth,
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);
// Sogou on Windows commits pending preedit text when Shift toggles
// Chinese/English mode; a pure modifier keydown must not arm xterm's
// insertText dedupe guard or the committed text is dropped (#3441).
keepImeCommittedTextThroughModifierKeyDowns(term);
// Sogou Chinese mode also commits latin text on Enter via a late
// insertText after keyCode 229, with no composition events. The listener
// has to be armed before open() so it runs before xterm's input handler
// (#3185).
keepLateImeTextInsert(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);
// CompositionHelper is created in open(). Live transcription rewrites the
// hypothesis instead of appending; commit that utterance once (#3421).
keepLiveImeTranscriptionSingle(term);
// 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;
const reloadWebglRenderer = () => {
if (!webglLoaded) return;
suspendWebglRenderer();
loadWebglRenderer();
};
// The ligatures addon sets font-feature-settings on the terminal element.
// WebGL's glyph atlas inherits that only when the atlas is created, so this
// has to run before the first WebGL load. Later toggles rebuild WebGL.
const ligatures = createTerminalLigatureController({
createAddon: () => new LigaturesAddon(),
loadAddon: (addon) => term.loadAddon(addon),
isWebglActive: () => webglLoaded,
recreateWebgl: reloadWebglRenderer,
repaint: repaintTerminal,
warn: (message, error) => {
if (error === undefined) logger.warn(message);
else logger.warn(message, error);
},
});
ligatures.apply(terminalFontLigaturesEnabled(settings));
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 cwdMarkers = createTerminalCwdMarkers(term);
const archiveLinks = registerTerminalArchiveLinks({
term,
cwdMarkers,
isConnected: () => ctx.statusRef.current === "connected",
isBroadcasting: () => ctx.isBroadcastEnabledRef.current === true,
isHostEligible: () => supportsTerminalArchiveCommands(ctx.getHost?.() ?? ctx.host, platform),
isInputReady: () => isTerminalReadyForCommandInjection(ctx.sessionId, { allowDraft: true }),
linkModifier: () => ctx.terminalSettingsRef.current?.linkModifier ?? "none",
});
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,
options?: TerminalBroadcastInputOptions,
) => {
// Password bypass (#3488): the opt-in lifts the password-prompt pause.
// Payloads dispatched from an active prompt stay marked sourceSensitive so
// peer writes keep skipping input interceptors.
const dispatchingFromPasswordPrompt = ctx.passwordPromptActiveRef?.current === true;
// A paste confirmed at a sensitive prompt keeps its pre-dialog snapshot
// (#3491): the dialog await can clear the live prompt ref, so the saved
// classification carried in the paste options must still tag the fan-out.
const sourceSensitive = dispatchingFromPasswordPrompt || options?.sensitive === true;
if (
(!dispatchingFromPasswordPrompt
|| ctx.broadcastPasswordBypassRef?.current === true)
&& ctx.isBroadcastEnabledRef.current
&& ctx.onBroadcastInputRef.current
) {
ctx.onBroadcastInputRef.current(data, ctx.sessionId, sourceSensitive
? { ...options, sourceSensitive: true }
: options);
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);
};
// Tracks whether the most recent non-backspace input was a printable
// character (not an escape sequence or control char). Backspace byte
// expansion is only safe when the cursor is at the end of the typed
// buffer — i.e. the last input advanced the cursor, not moved it.
//
// Starts true when the buffer is empty (new session: cursor is at the
// beginning of an empty buffer). Starts false when the buffer is
// nonempty (hibernation wake: the pre-hibernation cursor may have been
// moved away from the tail, so we conservatively assume it is not).
let lastInputWasPrintable = !ctx.commandBufferRef?.current;
let commandBufferRevision = 0;
const invalidatePendingPasteDraft = () => {
commandBufferRevision += 1;
markTerminalBroadcastUserInput(ctx.sessionId);
};
const restoreSerialTailForEmptyInput = (data: string) => {
// Apply the same rule to typed text, pasted text, and editable snippets.
// Preserve uncertainty when inserting into an existing edited line.
if (!ctx.commandBufferRef.current &&
(isPrintableInput(data) || getSingleBracketedPasteLine(data))) {
lastInputWasPrintable = true;
}
};
const recordSerialTextInput = (data: string) => {
const text = data.startsWith("\x1b[200~") && data.endsWith("\x1b[201~")
? data.slice(6, -6)
: data;
const lastLineBreak = Math.max(text.lastIndexOf("\r"), text.lastIndexOf("\n"));
if (lastLineBreak >= 0) {
commandBufferRevision += 1;
ctx.commandBufferRef.current = "";
lastInputWasPrintable = true;
}
const pendingText = text.slice(lastLineBreak + 1);
restoreSerialTailForEmptyInput(pendingText);
ctx.commandBufferRef.current += pendingText;
return text;
};
const recordSerialSnippetInput = (data: string) => {
const text = recordSerialTextInput(data);
if (ctx.serialLocalEcho) writeLocalTerminalData(formatSerialLocalEcho(text));
};
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;
/** Confirmed paste: preserve classification and backend pacing. */
sensitive?: boolean;
/**
* The sensitive marker came from a source-sensitive broadcast peer
* (#3488), not from this session's own prompt detector. Semantic input
* consumers such as autocomplete must not see the secret payload: their
* gate only checks this peer's own prompt state (#3491).
*/
sourceSensitiveBroadcast?: boolean;
lineDelayMs?: number;
pasteRequestId?: string;
/**
* 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);
}
if (logicalData !== null) restoreSerialTailForEmptyInput(logicalData);
const inputSource = options?.source ?? "terminal";
const id = ctx.sessionRef.current;
const dataToWrite = data;
// A programmatic paste can carry a sensitivity snapshot taken before a
// confirm-dialog await (terminalUserPaste.ts): remote output or a
// reconnect may have cleared passwordPromptActiveRef while the dialog was
// open, so the live ref alone would downgrade the secret to nonsensitive.
// The override is consumed only for the flagged paste data.
const sensitive = ctx.passwordPromptActiveRef?.current === true
|| options?.sensitive === true
|| shouldOverrideTerminalUserPasteSensitivity(term, logicalData ?? data);
if (!options?.lineDelayMs && logicalData !== null
&& !isPrintableInput(logicalData) && !isTerminalReportSequence(logicalData)) {
commandBufferRevision += 1;
}
if (!options?.lineDelayMs && logicalData !== null && !isTerminalReportSequence(logicalData)) {
markTerminalBroadcastUserInput(ctx.sessionId);
}
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.
// The password bypass (#3488) only lifts the live password-prompt pause.
// Explicitly classified sensitive payloads (confirmed pastes, preloaded
// sudo credentials) stay non-broadcastable regardless of the opt-in.
const canBroadcastInput = (!sensitive
|| (ctx.broadcastPasswordBypassRef?.current === true && options?.sensitive !== true)) &&
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" && options?.lineDelayMs && logicalData) {
// Keep the draft until the backend confirms a write. A rejected or
// canceled batch must not discard input already present at the prompt.
if (ctx.passwordPromptActiveRef) ctx.passwordPromptActiveRef.current = false;
handledSubmittedInput = true;
} else 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,
);
}
}
}
// Both Enter and a submitted paste start a new, empty input line.
if (handledSubmittedInput) lastInputWasPrintable = true;
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
) {
// Local line editing sends no transport write, so cancel queued paste
// work explicitly. Only actual protocol replies are exempt; keyboard
// escape sequences also edit the local buffer.
if (!options?.lineDelayMs && !isTerminalReportSequence(dataToWrite)
&& (dataToWrite === "\b" || dataToWrite === "\x15"
|| dataToWrite.charCodeAt(0) >= 32 || dataToWrite.length > 1)) {
ctx.terminalBackend.interruptSession?.(id, undefined, { cancelPendingWritesOnly: true });
}
// Line mode never reaches writeToSession until Enter, so buffered
// keystrokes are invisible to the main-process auto-login detector.
// The first buffered keystroke means the user is taking control:
// cancel the detector exactly like character-mode input would.
// Submit (\r/\n) and Ctrl+C already write to the session, which
// cancels it on that path. Automatic terminal-report replies (DA1,
// CPR, ...) also surface through onData, but they originate from the
// device negotiating with xterm, not from the user — the main process
// excludes them from its write-path cancellation via
// isTerminalReportSequence, so apply the same classification here.
const isSessionWrite = dataToWrite === "\r" || dataToWrite === "\n" || dataToWrite === "\x03";
if (!isSessionWrite && !isTerminalReportSequence(dataToWrite)) {
ctx.terminalBackend.notifyUserInput?.(id);
}
const pacedWrites: string[] = [];
handleSerialLineModeInput(dataToWrite, {
bufferRef: ctx.serialLineBufferRef,
localEcho: ctx.serialLocalEcho,
writeToSession: (nextData) => {
ctx.onOutputTriggerUserInputRef?.current?.(nextData);
if (options?.lineDelayMs) pacedWrites.push(nextData);
else ctx.terminalBackend.writeToSession(id, nextData, { sensitive });
},
writeToTerminal: writeLocalTerminalData,
term,
});
// Submit one batch so the backend spaces the lines apart. Separate
// calls would each start at delay zero and lose the paste pacing.
if (pacedWrites.length > 0) {
ctx.terminalBackend.writeToSession(id, pacedWrites.join(""), {
automated: false,
sensitive,
lineDelayMs: options?.lineDelayMs,
pasteRequestId: options?.pasteRequestId,
});
}
} else {
// Character mode sends input immediately. Byte-oriented devices opt in
// to expansion in the backend, using the session's actual wire encoder.
const isBackspace = dataToWrite === "\x7f" || dataToWrite === "\b";
const outData = mapTerminalBackspaceInput(dataToWrite, ctx.host.backspaceBehavior);
let serialEraseChar: string | undefined;
let backspaceCells = 1;
if (
isBackspace &&
ctx.host.protocol === "serial" &&
ctx.commandBufferRef &&
ctx.commandBufferRef.current.length > 0 &&
lastInputWasPrintable
) {
const lastChar = getLastChar(ctx.commandBufferRef.current);
// Always compute display width for local echo, independently of
// whether wire-level byte expansion is enabled. A wide CJK or
// emoji grapheme needs its full cell count erased even when
// byteOrientedBackspace is disabled (character-aware endpoint).
backspaceCells = stringCellWidth(lastChar, term);
if (ctx.serialByteOrientedBackspace === true) {
serialEraseChar = lastChar;
}
}
ctx.onOutputTriggerUserInputRef?.current?.(outData);
for (const chunk of getTextInputWireChunks(outData, options?.perCharacterWrites === true)) {
ctx.terminalBackend.writeToSession(id, chunk, {
sensitive,
serialEraseChar,
...(options?.lineDelayMs ? {
automated: false,
lineDelayMs: options.lineDelayMs,
pasteRequestId: options.pasteRequestId,
} : {}),
});
}
// Local echo for serial connections only when explicitly enabled
if (inputSource !== "kitty" && ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
const localEcho = formatSerialLocalEcho(dataToWrite, isBackspace ? backspaceCells : undefined);
if (localEcho) writeLocalTerminalData(localEcho);
}
if (inputSource !== "kitty" && ctx.host.protocol === "telnet" && ctx.telnetLocalEchoRef?.current) {
const localEcho = formatTelnetLocalEcho(dataToWrite);
if (localEcho) writeLocalTerminalData(localEcho);
}
// Update printable tracking. Backspace is not printable.
// Escape sequences (cursor movements, special keys) and control
// chars set lastInputWasPrintable to false — the cursor position
// is now unknown. Printable characters keep the flag as-is:
// typing at the end advances the cursor to the end (stays true),
// but typing after a cursor movement inserts at the cursor
// position, not the buffer end (stays false).
//
// Bracketed paste (\x1b[200~...\x1b[201~) is special: xterm wraps
// pasted text in these markers, so the data starts with ESC and
// isPrintableInput returns false. But the pasted content is
// printable. However, the cursor may not be at the buffer tail
// (e.g. after a left-arrow movement), so we keep the flag as-is:
// paste at the end keeps confidence, paste after a cursor movement
// keeps it unknown.
if (!isBackspace) {
const isBracketedPaste =
dataToWrite.startsWith("\x1b[200~") && dataToWrite.endsWith("\x1b[201~");
if (!isBracketedPaste && !isPrintableInput(dataToWrite) && !handledSubmittedInput) {
// Escape sequence or control char: cursor position unknown.
// Skip when handledSubmittedInput is true: the submission handler
// already set lastInputWasPrintable = true (buffer cleared, cursor
// at next prompt), and the same \r/\n should not reset it.
lastInputWasPrintable = false;
}
// If printable (non-backspace, non-escape, non-bracketed-paste),
// keep flag as-is: typing at the end keeps cursor at end; typing
// after a cursor movement keeps cursor not at end.
// If bracketed paste, also keep flag as-is (see above).
}
}
// 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) {
// If the bypass (#3488) let a password-prompt payload through, keep the
// sensitive marker on peer writes so input interceptors stay skipped.
onBroadcastInput?.(broadcastData, ctx.sessionId, sensitive ? { sourceSensitive: true } : undefined);
}
if (
logicalData !== null &&
!shouldSuppressTerminalInputScrollForUserPaste(term, logicalData)
) {
scrollToBottomAfterInput(logicalData);
}
// Notify autocomplete of input. Payloads whose sensitivity arrived from
// a source-sensitive broadcast peer (#3488) stay out of autocomplete:
// this peer's own prompt gate has not latched, so the secret could reach
// enabled plugin completion providers (#3491).
if (logicalData !== null && !options?.sourceSensitiveBroadcast) 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 = removeLastChar(ctx.commandBufferRef.current);
ctx.scriptRecorderRef?.current?.recordBackspace();
} else if (logicalData === "\x03") {
ctx.commandBufferRef.current = "";
ctx.scriptRecorderRef?.current?.recordClearLine();
lastInputWasPrintable = true; // buffer cleared, cursor at start
// 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();
lastInputWasPrintable = true;
} else if (ctx.host.protocol === "serial" && (
isPrintableInput(logicalData) || /[\r\n]/.test(logicalData) ||
(logicalData.startsWith("\x1b[200~") && logicalData.endsWith("\x1b[201~"))
)) {
recordSerialTextInput(logicalData);
ctx.scriptRecorderRef?.current?.recordInput(logicalData);
} 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;
/** Forced sensitive marker from a bypassed password-prompt source (#3488). */
sensitive?: boolean;
} | 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>();
// Command+Period interrupt presses are recorded under their normalized Ctrl+C identity
// (KeyC) so broadcast legacy pairing stays matched (#3408), but under a
// dedicated map key so they cannot clobber an outstanding physical KeyC
// press; map the physical chord identity (Period) to it so the later keyup
// can pair the release.
const kittyNormalizedPressAliases = new Map<string, string>();
const kittyNormalizedPressIdentity = (identity: string): string =>
`${identity}\u0000mac-period-interrupt`;
const broadcastEncodedKeys = new Set<string>();
const broadcastLegacySuppressedKeys = new Set<string>();
const broadcastWin32ShiftEnterTextKeys = 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,
// The #3488 bypass lifts the password-prompt pause for Kitty key fan-out too.
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true
&& ctx.broadcastPasswordBypassRef?.current !== true,
// Dispatching from an active prompt under the bypass must tag the payload
// so peer writes keep input interceptors skipped.
isSensitivePromptSource: () => 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,
identityOverride?: string,
): boolean => {
// The Command+Period interrupt press is keyed independently from its normalized
// Ctrl+C event identity, so its release must delete the entry it was
// stored under rather than the physical key's (#3408).
const identity = identityOverride ?? (event.code || event.key);
const forwardedPress = broadcastForwardedKeys.get(identity);
if (forwardedPress) {
broadcastForwardedKeys.delete(identity);
broadcastKittyInput(
// Carry the identity the press was recorded under so peers pair this
// release with that press instead of the event's physical code - the
// Command+Period interrupt press lives under a dedicated normalized key (#3409).
{ kind: "key", event, keyIdentity: identity },
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;
};
/**
* Resolve a forwarded press whose recorded identity is not the physical
* key identity: the Command+Period interrupt press was recorded as the normalized
* Ctrl+C event (KeyC), but the browser delivers the physical release as
* Period. Pair that release from the stored event so Kitty consumers do
* not see Ctrl+C held until focus loss (#3408).
*/
const resolveKittyNormalizedPressRelease = (
physicalEvent: KeyboardEvent,
): { event: KittyKeyboardEvent; identity: string } | null => {
const physicalIdentity = kittyKeyIdentity(physicalEvent);
const normalizedIdentity = kittyNormalizedPressAliases.get(physicalIdentity);
if (!normalizedIdentity) return null;
const forwardedPress =
broadcastForwardedKeys.get(normalizedIdentity)
?? kittyForwardedKeys.get(normalizedIdentity);
// Consume the alias on every path once it has been paired (or
// invalidated): a later keyup for the same physical key while another
// KeyC press is outstanding must not be rewritten as the normalized
// release (#3408).
kittyNormalizedPressAliases.delete(physicalIdentity);
if (!forwardedPress) return null;
return { event: forwardedPress.event, identity: normalizedIdentity };
};
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 A–Z
// (CoreBrowserTerminal.ts:_keyDown A–Z 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" });
}
}
// Release the normalized interrupt separately: the same physical key
// may already have a forwarded press from before Command was held.
// Keep the saved layout-independent event rather than translating KeyC
// through the current keyboard layout again.
const aliasedRelease = resolveKittyNormalizedPressRelease(e);
const releasedInterrupt = aliasedRelease !== null && releaseForwardedKittyPress(
{ ...aliasedRelease.event, type: "keyup" },
aliasedRelease.identity,
);
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)) || releasedInterrupt) {
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;
}
// Resolve the local shell chord before autocomplete can consume directory
// arrows, but apply it only after app/snippet shortcuts below.
const lineJumpSequence =
term.buffer.active.type === "normal" &&
ctx.passwordPromptActiveRef?.current !== true &&
!sudoAutofill?.isPromptPending() &&
!term.modes.win32InputMode &&
!isKittyKeyboardModeActive(kittyKeyboardMode)
? commandArrowLineJumpSequence(e, isMacPlatform())
: null;
// Autocomplete key handler (must be checked before other handlers)
if (ctx.onAutocompleteKeyEvent && !lineJumpSequence && !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;
const urgentInterrupt =
(!kittySequenceForKeyDown || kittySequenceForKeyDown === "\x03") &&
shouldUseUrgentTerminalInterrupt(e, { hasSelection: hasCopyableSelection });
const currentScheme = ctx.hotkeySchemeRef.current;
// Use shared utility for platform detection when hotkey scheme is disabled
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
// macOS Terminal convention: Command+Period interrupts the running command like
// Ctrl+C (#3408), including while text is selected. A
// user-assigned snippet or configured shortcut on this chord keeps
// precedence: the editors accept Command+Period (their conflict check only covers
// configured bindings), so the hard-coded interrupt must not silently
// swallow a chord the user actually assigned (#3409).
const macCommandPeriodInterrupt =
isMacPlatform()
&& isMacCommandPeriodInterruptChord(e)
&& !(ctx.snippetsRef?.current ?? []).some((snippet) => (
snippet.shortkey && matchesKeyBinding(e, snippet.shortkey, isMac)
))
&& !(currentScheme !== "disabled"
&& checkAppShortcut(e, ctx.keyBindingsRef.current, isMac) !== null);
if (urgentInterrupt || macCommandPeriodInterrupt) {
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,
});
lastInputWasPrintable = true;
invalidatePendingPasteDraft();
ctx.scriptRecorderRef?.current?.recordClearLine();
if (ctx.passwordPromptActiveRef) {
ctx.passwordPromptActiveRef.current = false;
}
if (isPluginHostProtocol(ctx.host.protocol) && ctx.terminalBackend.signalPluginConnection) {
ctx.terminalBackend.interruptSession?.(id, interruptTrace, { cancelPendingWritesOnly: true });
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");
}
// Report the interrupt to Kitty as Ctrl+C even when it came from the
// Command+Period chord: the broadcast legacy \x03 is keyed by this identity, so
// forwarding Super+Period would leave peers with an unmatched
// Super+Period press and suppress the interrupt instead (#3408).
const interruptEventForKitty: KeyboardEvent = macCommandPeriodInterrupt
? {
type: e.type,
key: "c",
code: "KeyC",
location: e.location,
repeat: e.repeat,
isComposing: e.isComposing,
keyCode: 67,
shiftKey: false,
altKey: false,
ctrlKey: true,
metaKey: false,
getModifierState: (key: string) => key === "Control" && e.getModifierState("Control"),
} as unknown as KeyboardEvent
: e;
const kittyEvent = toKittyKeyboardEvent(interruptEventForKitty);
if (macCommandPeriodInterrupt) {
// The synthesized event's code ("KeyC") is the physical QWERTY
// position of the chord key, so toKittyKeyboardEvent()'s layout
// lookup returns that position's character on non-QWERTY layouts
// (e.g. "n" under Dvorak) and getUnicodeKeyCode() prioritizes it,
// encoding the interrupt as the wrong key instead of Ctrl+C's 99
// - a broadcast peer would then suppress the legacy \x03 fallback
// and never be interrupted. Force the layout-independent identity.
kittyEvent.unshiftedKey = "c";
}
const identity = kittyKeyIdentity(interruptEventForKitty);
// The normalized press shares the Ctrl+C event identity (KeyC) with a
// possibly outstanding physical KeyC press; record it under a dedicated
// map key even when the layout maps period to physical KeyC. Its keyup
// releases (and deletes) only the
// interrupt press instead of the held physical key's entry (#3409).
const pressIdentity =
macCommandPeriodInterrupt
? kittyNormalizedPressIdentity(identity)
: identity;
if (pressIdentity !== identity) {
// The physical release arrives under its original layout key while
// the press was recorded as the normalized Ctrl+C event; pair them
// at keyup so the interrupt release is not lost (#3408).
kittyNormalizedPressAliases.set(kittyKeyIdentity(e), pressIdentity);
}
if (
!term.modes.win32InputMode &&
kittyKeyboardProtocolEnabled &&
shouldTrackKittyKeyRelease(kittyKeyboardMode, kittyEvent)
) {
upsertKittyKeyboardForwardedPress(
kittyForwardedKeys,
pressIdentity,
kittyEvent,
[],
);
}
// The dedicated press identity must cross the broadcast boundary:
// peers key their pairing state from it, so broadcasting only the
// normalized event would collapse the interrupt with an outstanding
// physical KeyC press on every peer (#3409).
const forwarded = broadcastKittyInput({
kind: "key",
event: kittyEvent,
keyIdentity: pressIdentity,
});
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
pressIdentity,
kittyEvent,
forwarded.targetSessionIds,
);
broadcastKittyInput({
kind: "legacy",
data: "\x03",
keyIdentity: pressIdentity,
urgentInterrupt: true,
});
}
scrollToBottomAfterInput("\x03");
return false;
}
}
// 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();
// Route through the multi-line paste confirmation gate
// (#3398) so a selected multi-line region cannot be sent to
// the session (and broadcast peers) without review, just
// like the clipboard paste path.
void pasteTextWithMultilineConfirm(selection, {
confirmMultilinePaste: {
enabled: ctx.terminalSettingsRef.current?.confirmBeforeMultilinePaste === true,
minLines: ctx.terminalSettingsRef.current?.multilinePasteConfirmMinLines,
requestConfirm: requestMultilinePasteConfirm,
},
getCurrentSessionId: () => ctx.sessionRef.current,
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true,
broadcastPasswordBypass: () => ctx.broadcastPasswordBypassRef?.current === true,
onPasteData: broadcastUserPasteData,
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
scrollToBottomAfterProgrammaticInput: scrollToBottomAfterInput,
sessionId: id,
terminalBackend: ctx.terminalBackend,
term,
});
}
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.
//
// shiftEnterForceText opts out of the Win32 hand-off for runtimes that
// cannot consume INPUT_RECORD modifiers (Node/Bun via libuv, e.g. Claude
// Code and CodeBuddy): they collapse Shift+Enter to a bare CR anyway, so
// sending the configured sequence is the only distinguishable input. The
// opt-out is scoped to Win32 input mode: elsewhere a negotiated Kitty
// encoding that preserves Shift+Enter must still win.
if (
shouldClaimShiftEnterForText(e, ctx.terminalSettingsRef.current, {
win32InputMode: term.modes.win32InputMode,
kittySequenceForKeyDown,
nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled
&& nativeKittyKeyboardPreservesShiftEnter(readNativeKittyKeyboardFlags(term)),
})
) {
const id = ctx.sessionRef.current;
if (id) {
const kittyEvent = toKittyKeyboardEvent(e);
const shiftEnterText = resolveShiftEnterText(
ctx.terminalSettingsRef.current,
);
// Only claim the keydown when there is text to send. An empty resolved
// text (e.g. a persisted empty setting) must fall through to the Win32
// and Kitty paths below so a normal Enter is still produced; consuming
// the event there would silently swallow the press with no visible
// effect, indistinguishable from the feature being dead.
if (shiftEnterText) {
e.preventDefault();
e.stopPropagation();
// Snapshot source-prompt sensitivity before the local write (#3491):
// the default bare-newline send-text reaches the PTY as a
// submission that clears passwordPromptActiveRef, so the live
// prompt-source check in broadcastKittyInput would report this
// Shift+Enter as nonsensitive and a peer would commit its buffered
// source-sensitive password characters as ordinary input.
const sourceSensitivePrompt = ctx.passwordPromptActiveRef?.current === true;
// 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,
}, false, undefined, sourceSensitivePrompt ? { sourceSensitive: true } : undefined);
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,
[],
);
// Snapshot source-prompt sensitivity before the local write (#3491):
// a Kitty-encoded Enter that reaches the PTY as bare CR submits here
// and clears passwordPromptActiveRef, so the live prompt-source check
// in broadcastKittyInput would report this Enter as nonsensitive and
// a peer would commit its buffered source-sensitive password
// characters as ordinary command/script bookkeeping.
const sourceSensitivePrompt = ctx.passwordPromptActiveRef?.current === true;
handleTerminalInputData(kittySequenceForKeyDown, { source: "kitty" });
const forwarded = broadcastKittyInput({
kind: "key",
event: kittyEvent,
fallbackToLegacy: true,
urgentInterrupt: shouldUseUrgentTerminalInterrupt(e, {
hasSelection: hasCopyableSelection,
}),
}, false, undefined, sourceSensitivePrompt ? { sourceSensitive: true } : undefined);
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
kittyKeyIdentity(e),
kittyEvent,
forwarded.targetSessionIds,
);
}
return false;
}
// Keep app/snippet shortcuts and negotiated keyboard protocols ahead of
// the shell-only macOS line-editing fallback (issue #3139).
if (lineJumpSequence && ctx.sessionRef.current) {
e.preventDefault();
e.stopPropagation();
// This is a local shell editing shortcut. Peers may be running a TUI
// or another keyboard protocol, so do not fan out the mapped Ctrl key.
handleTerminalInputData(lineJumpSequence, { skipBroadcast: true });
scrollToBottomAfterInput(lineJumpSequence);
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,
writeOptions?: { sensitive?: boolean },
) => {
const pending = {
event,
logicalData,
// A bypassed source-prompt fan-out (#3488) forces the sensitive marker so
// this peer's Win32 write keeps input interceptors skipped.
...(writeOptions?.sensitive === true ? { sensitive: true } : {}),
};
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();
// broadcastForwardedKeys is retained so pending peer releases still pair
// after a reconnect; keep the aliases whose normalized press is still
// owed a broadcast release, otherwise the physical keyup can no longer
// find the dedicated identity and peers keep the key logically pressed
// until blur (#3409).
for (const [physicalIdentity, normalizedIdentity] of kittyNormalizedPressAliases) {
if (!broadcastForwardedKeys.has(normalizedIdentity)) {
kittyNormalizedPressAliases.delete(physicalIdentity);
}
}
clearKittyKeyboardBroadcastPairingState(
broadcastEncodedKeys,
broadcastLegacySuppressedKeys,
broadcastWin32ShiftEnterTextKeys,
);
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);
// This insert was already sent. Leave xterm's dedupe flag armed.
markImeDeferralConsumedInput(event);
// 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);
const pendingLinePastes = new Map<string, {
sessionId: string;
commands: string[];
firstPastedLine: string;
pendingInput: string;
serialPendingInput?: string;
sourceInput: ReturnType<typeof captureTerminalBroadcastInput>;
broadcast?: TerminalPacedBroadcast;
inputRevision: number;
sensitive: boolean;
recorded: Set<number>;
recordLine?: (line: string, options?: { sensitive?: boolean; includePendingInput?: boolean; consumePendingInput?: boolean }) => Promise<void>;
}>();
const disposePasteWriteReceipts = netcattyBridge.get()?.onTerminalPasteWrite?.((receipt) => {
const pending = pendingLinePastes.get(receipt.requestId);
if (!pending || pending.sessionId !== receipt.sessionId) return;
if (ctx.sessionRef.current !== pending.sessionId) {
pendingLinePastes.delete(receipt.requestId);
return;
}
const index = receipt.index;
if (index !== undefined && Number.isInteger(index) && index >= 0
&& index < pending.commands.length && !pending.recorded.has(index)) {
if (index === 0 && pending.inputRevision === commandBufferRevision
&& ctx.commandBufferRef.current.startsWith(pending.pendingInput)) {
ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(pending.pendingInput.length);
if (pending.serialPendingInput !== undefined && ctx.serialLineBufferRef?.current.startsWith(pending.serialPendingInput)) {
ctx.serialLineBufferRef.current = ctx.serialLineBufferRef.current.slice(pending.serialPendingInput.length);
}
commandBufferRevision += 1;
}
pending.recorded.add(index);
const command = pending.commands[index];
const sensitive = isSensitiveTerminalCommandInput(
term, pending.sensitive || ctx.passwordPromptActiveRef?.current === true,
);
// Receipts can arrive after the user starts typing another command.
// Never consume that live input buffer or reconcile with a newer screen.
recordTerminalCommandExecution(command, { ...ctx, commandBufferRef: { current: "" } }, term, {
sensitive,
allowHostStyleGreaterThanPrompt: ctx.allowHostStyleGreaterThanPrompt,
useProvidedCommand: true,
acknowledgedWrite: true,
});
if (pending.broadcast && isTerminalBroadcastInputCurrent(pending.sourceInput)
&& ctx.isBroadcastEnabledRef.current
// The #3488 bypass keeps the paced fan-out alive when this line was
// confirmed at (or lands on) a sensitive prompt.
&& (!sensitive || ctx.broadcastPasswordBypassRef?.current === true)) {
ctx.onBroadcastInputRef.current?.(`${index === 0 ? pending.firstPastedLine : command}\r`, ctx.sessionId, {
pacedBroadcast: pending.broadcast,
// Bypassed sensitive fan-out (#3488): tag the line so peer writes
// keep input interceptors skipped, like raw-string broadcast does.
...(sensitive ? { sourceSensitive: true } : {}),
});
}
void pending.recordLine?.(index === 0 ? pending.firstPastedLine : command, {
sensitive, includePendingInput: index === 0, consumePendingInput: index === 0,
}).catch((error) => {
logger.warn("Failed to record confirmed paste write", error);
});
}
if (receipt.done) pendingLinePastes.delete(receipt.requestId);
});
const disposeLinePasteHandler = registerTerminalLinePasteHandler(term, (data, options) => {
const sessionId = ctx.sessionRef.current;
if (!sessionId) return;
markTerminalBroadcastUserInput(ctx.sessionId);
const sourceInput = captureTerminalBroadcastInput(ctx.sessionId);
// The #3488 bypass lets a paste confirmed at a sensitive prompt keep the
// paced fan-out: each acknowledged line is re-guarded on its receipt and
// the dispatcher keeps peer writes sensitive via the source marker.
const broadcast: TerminalPacedBroadcast | undefined = options.broadcast
&& (!options.sensitive || ctx.broadcastPasswordBypassRef?.current === true)
&& ctx.isBroadcastEnabledRef.current ? {} : undefined;
if (broadcast) ctx.onBroadcastInputRef.current?.("", ctx.sessionId, { pacedBroadcast: broadcast, preparePacedBroadcast: true });
const requestId = crypto.randomUUID();
const commands = data.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
if (commands.at(-1) === "") commands.pop();
const firstPastedLine = commands[0] ?? "";
if (commands.length) {
commands[0] = `${ctx.commandBufferRef.current}${commands[0]}`;
}
const serialPendingInput = ctx.host.protocol === "serial" && ctx.serialLineMode
? ctx.serialLineBufferRef?.current : undefined;
const recorded = new Set<number>();
pendingLinePastes.set(requestId, {
sessionId, sourceInput, broadcast, commands, firstPastedLine, sensitive: options.sensitive, recorded, serialPendingInput,
pendingInput: ctx.commandBufferRef.current, inputRevision: commandBufferRevision,
recordLine: ctx.scriptRecorderRef?.current?.captureSubmittedLineRecorder?.(),
});
handleTerminalInputData(data, { ...options, pasteRequestId: requestId, skipBroadcast: true });
if (recorded.size === 0 && serialPendingInput !== undefined && ctx.serialLineBufferRef) {
ctx.serialLineBufferRef.current = serialPendingInput;
}
});
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,
// A bypassed source-prompt fan-out (#3488) forces the sensitive marker
// so this peer's write keeps input interceptors skipped.
...(win32Input.sensitive === true
? { sensitive: true, sourceSensitiveBroadcast: 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;
// A rewritten IME hypothesis first emits standalone deletions. Route those
// as backspaces and keep the composition marker for the replacement text.
if (kittyCompositionPending && data !== "\x7f" && !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,
nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled
&& nativeKittyKeyboardPreservesShiftEnter(readNativeKittyKeyboardFlags(term)),
win32ShiftEnterTextKeys: broadcastWin32ShiftEnterTextKeys,
}),
getSessionId: () => ctx.sessionRef.current,
// Receiving side: the #3488 bypass lets Kitty broadcast land at password prompts too.
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true
&& ctx.broadcastPasswordBypassRef?.current !== true,
isConnected: () => ctx.statusRef.current === "connected",
isRuntimeDisposed: () => runtimeDisposed,
interruptSession: ctx.terminalBackend.interruptSession
? (id) => ctx.terminalBackend.interruptSession?.(id)
: undefined,
writeDisposed: (id, data, writeOptions) => ctx.terminalBackend.writeToSession(
id,
mapTerminalBackspaceInput(data, ctx.host.backspaceBehavior),
{
sensitive: ctx.passwordPromptActiveRef?.current === true
|| writeOptions?.sensitive === true,
},
),
writeActive: (data, logicalData, writeOptions) => handleTerminalInputData(data, {
source: "kitty",
logicalData,
// A bypassed source-prompt fan-out (#3488) forces the sensitive marker so
// this peer's write keeps input interceptors skipped.
...(writeOptions?.sensitive === true
? { sensitive: true, sourceSensitiveBroadcast: true }
: {}),
}),
writeWin32Event: (event, logicalData, writeOptions) => {
writeWin32InputModeEvent(event, logicalData, writeOptions);
},
});
registerKittyKeyboardBroadcastHandler(
ctx.sessionId,
// Forward the dispatch options (#3488): source-prompt fan-outs tag their
// payload with sourceSensitive / beforeUrgentInterrupt, and dropping the
// second argument here would downgrade bypassed password keystrokes to
// ordinary peer input (leaving input interceptors / autocomplete exposed).
(input, dispatchOptions) => {
handlingKittyBroadcast = true;
try {
handleKittyKeyboardBroadcast(input, dispatchOptions);
} 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;
const eraseScrollbackDisposable = installEraseInDisplayHandlers(term, {
getClearWipesScrollback: () => ctx.terminalSettingsRef.current?.clearWipesScrollback ?? true,
});
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);
cwdMarkers.note({ cwd: path, host: url.hostname });
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);
cwdMarkers.note({ cwd: data, host: '' });
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,
syncFontLigatures: (enabled: boolean) => {
ligatures.apply(enabled);
},
hasInlineImages,
resetKittyConnectionInputState: clearKittyConnectionInputState,
flushKittyKeyboardReleases: clearKittyTransientInputState,
getKittyKeyboardModeState: () => snapshotKittyKeyboardModeState(kittyKeyboardMode),
restoreKittyKeyboardModeState: (state) => restoreKittyKeyboardModeState(
kittyKeyboardMode,
state,
),
getKittyKeyboardProtocolEnabled: () => kittyKeyboardProtocolEnabled,
setKittyKeyboardProtocolEnabled,
recordSerialSnippetInput,
invalidatePendingPasteDraft,
dispose: () => {
runtimeDisposed = true;
disposeLinePasteHandler?.();
disposePasteWriteReceipts?.();
pendingLinePastes.clear();
clearTerminalBroadcastUserInput(ctx.sessionId);
resizeScheduler.dispose();
ligatures.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();
archiveLinks.dispose();
cwdMarkers.dispose();
pluginProviderHost?.dispose();
eraseScrollbackDisposable.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;
},
};
};