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