mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
928 lines
34 KiB
TypeScript
928 lines
34 KiB
TypeScript
import { FitAddon } from "@xterm/addon-fit";
|
||
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 { Dispatch, RefObject, SetStateAction } from "react";
|
||
import {
|
||
checkAppShortcut,
|
||
getAppLevelActions,
|
||
getTerminalPassthroughActions,
|
||
} from "../../../application/state/useGlobalHotkeys";
|
||
import { fontStore } from "../../../application/state/fontStore";
|
||
import { KeywordHighlighter } from "../keywordHighlight";
|
||
import {
|
||
XTERM_PERFORMANCE_CONFIG,
|
||
type XTermPlatform,
|
||
resolveXTermPerformanceConfig,
|
||
} from "../../../infrastructure/config/xtermPerformance";
|
||
import {
|
||
shouldEnableNativeUserInputAutoScroll,
|
||
shouldScrollOnTerminalInput,
|
||
shouldScrollOnTerminalPaste,
|
||
} from "../../../domain/terminalScroll";
|
||
import {
|
||
resolveHostTerminalFontFamilyId,
|
||
resolveHostTerminalFontSize,
|
||
resolveHostTerminalFontWeight,
|
||
} from "../../../domain/terminalAppearance";
|
||
import { logger } from "../../../lib/logger";
|
||
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||
import {
|
||
clearTerminalViewport,
|
||
isEraseViewportSequence,
|
||
isEraseScrollbackSequence,
|
||
preserveTerminalViewportInScrollback,
|
||
} from "../clearTerminalViewport";
|
||
import {
|
||
createKittyKeyboardModeState,
|
||
encodeKittyControlKey,
|
||
} from "./kittyKeyboardProtocol";
|
||
import { installKittyKeyboardProtocolHandlers } from "./kittyKeyboardRuntime";
|
||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||
import type {
|
||
Host,
|
||
KeyBinding,
|
||
TerminalSession,
|
||
TerminalSettings,
|
||
TerminalTheme,
|
||
} from "../../../types";
|
||
import { matchesKeyBinding } from "../../../domain/models";
|
||
|
||
type TerminalBackendApi = {
|
||
openExternalAvailable: () => boolean;
|
||
openExternal: (url: string) => Promise<void>;
|
||
writeToSession: (sessionId: string, data: string) => void;
|
||
resizeSession: (sessionId: string, cols: number, rows: number) => void;
|
||
};
|
||
|
||
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;
|
||
};
|
||
|
||
export type CreateXTermRuntimeContext = {
|
||
container: HTMLDivElement;
|
||
host: Host;
|
||
fontFamilyId: string;
|
||
fontSize: number;
|
||
terminalTheme: TerminalTheme;
|
||
terminalSettingsRef: RefObject<TerminalSettings | undefined>;
|
||
terminalBackend: TerminalBackendApi;
|
||
sessionRef: RefObject<string | null>;
|
||
|
||
hotkeySchemeRef: RefObject<"disabled" | "mac" | "pc">;
|
||
keyBindingsRef: RefObject<KeyBinding[]>;
|
||
onHotkeyActionRef: RefObject<
|
||
((action: string, event: KeyboardEvent) => void) | undefined
|
||
>;
|
||
|
||
isBroadcastEnabledRef: RefObject<boolean | undefined>;
|
||
onBroadcastInputRef: RefObject<
|
||
((data: string, sourceSessionId: string) => void) | undefined
|
||
>;
|
||
|
||
// Snippets for shortkey support
|
||
snippetsRef?: RefObject<{ id: string; command: string; shortkey?: string }[]>;
|
||
|
||
sessionId: string;
|
||
statusRef: RefObject<TerminalSession["status"]>;
|
||
onCommandExecuted?: (
|
||
command: string,
|
||
hostId: string,
|
||
hostLabel: string,
|
||
sessionId: string,
|
||
) => void;
|
||
commandBufferRef: RefObject<string>;
|
||
setIsSearchOpen: Dispatch<SetStateAction<boolean>>;
|
||
|
||
// Serial-specific options
|
||
serialLocalEcho?: boolean;
|
||
serialLineMode?: boolean;
|
||
serialLineBufferRef?: RefObject<string>;
|
||
|
||
// Callback when shell reports CWD change via OSC 7
|
||
onCwdChange?: (cwd: string) => 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;
|
||
|
||
// Set to true while we're programmatically restoring a selection so that
|
||
// copy-on-select listeners can suppress redundant clipboard writes.
|
||
isRestoringSelectionRef?: RefObject<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";
|
||
};
|
||
|
||
/**
|
||
* Extract the primary font family from a CSS font-family string that may
|
||
* include fallback fonts. `document.fonts.check` returns `false` when *any*
|
||
* listed font is still loading, so passing the entire CJK fallback stack
|
||
* causes false negatives during early terminal creation – which in turn makes
|
||
* `fontWeightBold` fall back to the normal weight and renders bold text too
|
||
* thin.
|
||
*/
|
||
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 = resolveHostTerminalFontFamilyId(ctx.host, ctx.fontFamilyId) || "menlo";
|
||
// Use fontStore for font lookup - guarantees non-empty result
|
||
const fontObj = fontStore.getFontById(hostFontId);
|
||
const fontFamily = fontObj.family;
|
||
|
||
const effectiveFontSize = resolveHostTerminalFontSize(ctx.host, ctx.fontSize);
|
||
|
||
const cursorStyle = settings?.cursorShape ?? "block";
|
||
const cursorBlink = settings?.cursorBlink ?? true;
|
||
// xterm.js treats scrollback=0 as "no scrollback buffer", which breaks mouse
|
||
// wheel scrolling (events become arrow-key sequences). The UI uses 0 to mean
|
||
// "no limit", so map it to a large value instead.
|
||
const rawScrollback = settings?.scrollback ?? 10000;
|
||
const scrollback = rawScrollback === 0 ? 999999 : 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 ?? " ()[]{}'\"";
|
||
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
|
||
const keywordHighlightEnabled = settings?.keywordHighlightEnabled ?? false;
|
||
const kittyKeyboardMode = createKittyKeyboardModeState();
|
||
|
||
const resolvedFontWeightBold = (() => {
|
||
if (typeof document === "undefined" || !document.fonts?.check) {
|
||
return fontWeightBold;
|
||
}
|
||
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
|
||
return document.fonts.check(weightSpec) ? fontWeightBold : fontWeight;
|
||
})();
|
||
|
||
const term = new XTerm({
|
||
...performanceConfig.options,
|
||
...(windowsPty ? { windowsPty } : {}),
|
||
// 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,
|
||
// Decorations (keyword highlighting) use proposed APIs; enable globally so toggles work at runtime.
|
||
allowProposedApi: true,
|
||
drawBoldTextInBrightColors,
|
||
minimumContrastRatio,
|
||
smoothScrollDuration,
|
||
scrollOnUserInput,
|
||
macOptionClickForcesSelection: true,
|
||
altClickMovesCursor: !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
|
||
},
|
||
});
|
||
|
||
type MaybeRenderer = {
|
||
constructor?: { name?: string };
|
||
type?: string;
|
||
};
|
||
|
||
type IntrospectableTerminal = XTerm & {
|
||
_core?: {
|
||
_renderService?: {
|
||
_renderer?: MaybeRenderer;
|
||
};
|
||
};
|
||
options?: {
|
||
rendererType?: string;
|
||
};
|
||
};
|
||
|
||
const logRenderer = (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";
|
||
logger.info(`[XTerm] renderer=${normalized}`);
|
||
const scopedWindow = window as Window & { __xtermRenderer?: string };
|
||
scopedWindow.__xtermRenderer = normalized;
|
||
if (normalized === "unknown" && attempt < 3) {
|
||
setTimeout(() => logRenderer(attempt + 1), 150);
|
||
}
|
||
};
|
||
|
||
const fitAddon = new FitAddon();
|
||
term.loadAddon(fitAddon);
|
||
|
||
const serializeAddon = new SerializeAddon();
|
||
term.loadAddon(serializeAddon);
|
||
|
||
const searchAddon = new SearchAddon();
|
||
term.loadAddon(searchAddon);
|
||
|
||
term.open(ctx.container);
|
||
|
||
let webglAddon: WebglAddon | null = null;
|
||
let webglLoaded = false;
|
||
const scopedWindow = window as Window & {
|
||
__xtermWebGLLoaded?: boolean;
|
||
__xtermRendererPreference?: string;
|
||
};
|
||
|
||
if (performanceConfig.useWebGLAddon) {
|
||
try {
|
||
// WebglAddon constructor only accepts `preserveDrawingBuffer?: boolean`.
|
||
// Passing an object here (legacy API assumption) unintentionally enables
|
||
// preserveDrawingBuffer and can cause sporadic glyph artifacts/ghosting.
|
||
webglAddon = new WebglAddon();
|
||
webglAddon.onContextLoss(() => {
|
||
logger.warn("[XTerm] WebGL context loss detected, disposing addon");
|
||
webglAddon?.dispose();
|
||
});
|
||
term.loadAddon(webglAddon);
|
||
webglLoaded = true;
|
||
} catch (webglErr) {
|
||
logger.warn(
|
||
"[XTerm] WebGL addon failed, using DOM renderer. Error:",
|
||
webglErr instanceof Error ? webglErr.message : webglErr,
|
||
);
|
||
}
|
||
} else {
|
||
logger.info(
|
||
"[XTerm] Skipping WebGL addon (DOM preferred for low-memory devices)",
|
||
);
|
||
}
|
||
|
||
scopedWindow.__xtermWebGLLoaded = webglLoaded;
|
||
scopedWindow.__xtermRendererPreference = performanceConfig.preferDOMRenderer
|
||
? "dom"
|
||
: "webgl";
|
||
|
||
const webLinksAddon = new WebLinksAddon((event, uri) => {
|
||
const currentLinkModifier = ctx.terminalSettingsRef.current?.linkModifier ?? "none";
|
||
let shouldOpen = false;
|
||
switch (currentLinkModifier) {
|
||
case "none":
|
||
shouldOpen = true;
|
||
break;
|
||
case "ctrl":
|
||
shouldOpen = event.ctrlKey;
|
||
break;
|
||
case "alt":
|
||
shouldOpen = event.altKey;
|
||
break;
|
||
case "meta":
|
||
shouldOpen = event.metaKey;
|
||
break;
|
||
}
|
||
if (!shouldOpen) return;
|
||
|
||
if (ctx.terminalBackend.openExternalAvailable()) {
|
||
void ctx.terminalBackend.openExternal(uri);
|
||
} else {
|
||
const safeUri = String(uri || "");
|
||
if (/^https?:\/\//i.test(safeUri)) {
|
||
window.open(safeUri, "_blank", "noopener,noreferrer");
|
||
} else {
|
||
logger.warn("[XTerm] Refusing to open non-http(s) link:", safeUri);
|
||
}
|
||
}
|
||
});
|
||
term.loadAddon(webLinksAddon);
|
||
|
||
// Enable Unicode graphemes for accurate CJK / emoji / Nerd Font character width handling
|
||
const unicodeGraphemes = new UnicodeGraphemesAddon();
|
||
term.loadAddon(unicodeGraphemes);
|
||
term.unicode.activeVersion = '15-graphemes';
|
||
|
||
logRenderer();
|
||
|
||
const appLevelActions = getAppLevelActions();
|
||
const terminalActions = getTerminalPassthroughActions();
|
||
const scrollViewportToBottom = () => {
|
||
term.scrollToBottom();
|
||
if (typeof requestAnimationFrame === "function") {
|
||
requestAnimationFrame(() => {
|
||
term.scrollToBottom();
|
||
});
|
||
}
|
||
};
|
||
const scrollToBottomAfterPaste = () => {
|
||
if (shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current)) {
|
||
scrollViewportToBottom();
|
||
}
|
||
};
|
||
const scrollToBottomAfterInput = (data: string) => {
|
||
if (shouldScrollOnTerminalInput(ctx.terminalSettingsRef.current, data)) {
|
||
term.scrollToBottom();
|
||
}
|
||
};
|
||
|
||
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 !== "keydown") {
|
||
return true;
|
||
}
|
||
|
||
// Autocomplete key handler (must be checked before other handlers)
|
||
if (ctx.onAutocompleteKeyEvent) {
|
||
const consumed = ctx.onAutocompleteKeyEvent(e);
|
||
if (!consumed) return false; // Event was consumed by autocomplete
|
||
}
|
||
|
||
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();
|
||
// Send the snippet command to the terminal
|
||
let snippetData = normalizeLineEndings(snippet.command);
|
||
if (!snippet.noAutoRun) snippetData = `${snippetData}\r`;
|
||
// Broadcast the normalized (un-wrapped) data so each target
|
||
// session can apply its own bracket paste state
|
||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||
ctx.onBroadcastInputRef.current(snippetData, ctx.sessionId);
|
||
}
|
||
// Wrap for this terminal only, after broadcasting
|
||
const snippetIsMultiLine = snippetData.includes("\n");
|
||
if (snippetIsMultiLine && term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) snippetData = wrapBracketedPaste(snippetData);
|
||
// Notify autocomplete with the final (possibly bracket-wrapped)
|
||
// bytes so its keystroke buffer can tell literal multi-line
|
||
// paste ("\x1b[200~...\x1b[201~") from the non-bracketed path
|
||
// where each \n executes an intermediate command (#814 P2).
|
||
ctx.onAutocompleteInput?.(snippetData);
|
||
ctx.terminalBackend.writeToSession(id, snippetData);
|
||
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
|
||
const cmd = snippet.command.trim();
|
||
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||
ctx.commandBufferRef.current = "";
|
||
}
|
||
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)) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
switch (action) {
|
||
case "copy": {
|
||
const selection = term.getSelection();
|
||
if (selection) navigator.clipboard.writeText(selection);
|
||
break;
|
||
}
|
||
case "paste": {
|
||
navigator.clipboard.readText().then((text) => {
|
||
const id = ctx.sessionRef.current;
|
||
if (id) {
|
||
const rawData = normalizeLineEndings(text);
|
||
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
|
||
? wrapBracketedPaste(rawData)
|
||
: rawData;
|
||
// Notify autocomplete with the final bytes so bracketed
|
||
// pastes preserve their inner newlines as literal input.
|
||
ctx.onAutocompleteInput?.(data);
|
||
ctx.terminalBackend.writeToSession(id, data);
|
||
scrollToBottomAfterPaste();
|
||
}
|
||
});
|
||
break;
|
||
}
|
||
case "pasteSelection": {
|
||
const selection = term.getSelection();
|
||
const id = ctx.sessionRef.current;
|
||
if (selection && id) {
|
||
const rawData = normalizeLineEndings(selection);
|
||
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
|
||
? wrapBracketedPaste(rawData)
|
||
: rawData;
|
||
ctx.onAutocompleteInput?.(data);
|
||
ctx.terminalBackend.writeToSession(id, data);
|
||
scrollToBottomAfterPaste();
|
||
}
|
||
break;
|
||
}
|
||
case "selectAll": {
|
||
term.selectAll();
|
||
break;
|
||
}
|
||
case "clearBuffer": {
|
||
clearTerminalViewport(term);
|
||
break;
|
||
}
|
||
case "searchTerminal": {
|
||
ctx.setIsSearchOpen(true);
|
||
break;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
const kittyControlSequence = encodeKittyControlKey(kittyKeyboardMode, e);
|
||
if (kittyControlSequence) {
|
||
const id = ctx.sessionRef.current;
|
||
if (id) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
ctx.onAutocompleteInput?.(kittyControlSequence);
|
||
ctx.terminalBackend.writeToSession(id, kittyControlSequence);
|
||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||
ctx.onBroadcastInputRef.current(kittyControlSequence, ctx.sessionId);
|
||
}
|
||
scrollToBottomAfterInput(kittyControlSequence);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
});
|
||
|
||
let cleanupMiddleClick: (() => void) | null = null;
|
||
const middleClickPaste = settings?.middleClickPaste ?? true;
|
||
if (middleClickPaste) {
|
||
const handleMiddleClick = async (e: MouseEvent) => {
|
||
if (e.button !== 1) return;
|
||
e.preventDefault();
|
||
try {
|
||
const text = await navigator.clipboard.readText();
|
||
if (text && ctx.sessionRef.current) {
|
||
const rawData = normalizeLineEndings(text);
|
||
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
|
||
? wrapBracketedPaste(rawData)
|
||
: rawData;
|
||
ctx.onAutocompleteInput?.(data);
|
||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
|
||
scrollToBottomAfterPaste();
|
||
}
|
||
} catch (err) {
|
||
logger.warn("[Terminal] Failed to paste from clipboard:", err);
|
||
}
|
||
};
|
||
|
||
ctx.container.addEventListener("auxclick", handleMiddleClick);
|
||
cleanupMiddleClick = () =>
|
||
ctx.container.removeEventListener("auxclick", handleMiddleClick);
|
||
}
|
||
|
||
fitAddon.fit();
|
||
term.focus();
|
||
|
||
term.onData((data) => {
|
||
const id = ctx.sessionRef.current;
|
||
if (id) {
|
||
// Serial line mode: buffer input and send on Enter
|
||
if (ctx.host.protocol === "serial" && ctx.serialLineMode && ctx.serialLineBufferRef) {
|
||
if (data === "\r") {
|
||
// Enter key: send buffered line + CR
|
||
const line = ctx.serialLineBufferRef.current + "\r";
|
||
ctx.terminalBackend.writeToSession(id, line);
|
||
ctx.serialLineBufferRef.current = "";
|
||
// Local echo newline if enabled
|
||
if (ctx.serialLocalEcho) {
|
||
term.write("\r\n");
|
||
}
|
||
} else if (data === "\x7f" || data === "\b") {
|
||
// Backspace: remove last character from buffer
|
||
if (ctx.serialLineBufferRef.current.length > 0) {
|
||
ctx.serialLineBufferRef.current = ctx.serialLineBufferRef.current.slice(0, -1);
|
||
if (ctx.serialLocalEcho) {
|
||
term.write("\b \b");
|
||
}
|
||
}
|
||
} else if (data === "\x03") {
|
||
// Ctrl+C: clear buffer and send Ctrl+C
|
||
ctx.serialLineBufferRef.current = "";
|
||
ctx.terminalBackend.writeToSession(id, data);
|
||
if (ctx.serialLocalEcho) {
|
||
term.write("^C\r\n");
|
||
}
|
||
} else if (data === "\x15") {
|
||
// Ctrl+U: clear line buffer
|
||
if (ctx.serialLocalEcho && ctx.serialLineBufferRef.current.length > 0) {
|
||
// Erase the displayed line
|
||
const len = ctx.serialLineBufferRef.current.length;
|
||
term.write("\b \b".repeat(len));
|
||
}
|
||
ctx.serialLineBufferRef.current = "";
|
||
} else if (data.charCodeAt(0) >= 32 || data.length > 1) {
|
||
// Regular characters: add to buffer
|
||
ctx.serialLineBufferRef.current += data;
|
||
if (ctx.serialLocalEcho) {
|
||
term.write(data);
|
||
}
|
||
}
|
||
} else {
|
||
// Character mode (default): send immediately
|
||
// When backspaceBehavior is configured, remap the Backspace key output
|
||
let outData = data;
|
||
if (data === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") {
|
||
outData = "\x08";
|
||
}
|
||
ctx.terminalBackend.writeToSession(id, outData);
|
||
|
||
// Local echo for serial connections only when explicitly enabled
|
||
if (ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
|
||
if (data === "\r") {
|
||
term.write("\r\n");
|
||
} else if (data === "\x7f" || data === "\b") {
|
||
term.write("\b \b");
|
||
} else if (data === "\x03") {
|
||
term.write("^C");
|
||
} else if (data.charCodeAt(0) >= 32 || data.length > 1) {
|
||
term.write(data);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||
// Use remapped data so broadcast peers also receive the correct byte
|
||
const broadcastData = (data === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") ? "\x08" : data;
|
||
ctx.onBroadcastInputRef.current(broadcastData, ctx.sessionId);
|
||
}
|
||
|
||
scrollToBottomAfterInput(data);
|
||
|
||
// Notify autocomplete of input
|
||
ctx.onAutocompleteInput?.(data);
|
||
|
||
if (ctx.statusRef.current === "connected" && ctx.onCommandExecuted) {
|
||
if (data === "\r" || data === "\n") {
|
||
const cmd = ctx.commandBufferRef.current.trim();
|
||
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||
ctx.commandBufferRef.current = "";
|
||
} else if (data === "\x7f" || data === "\b") {
|
||
ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1);
|
||
} else if (data === "\x03") {
|
||
ctx.commandBufferRef.current = "";
|
||
} else if (data === "\x15") {
|
||
ctx.commandBufferRef.current = "";
|
||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||
ctx.commandBufferRef.current += data;
|
||
} else if (data.length > 1 && !data.startsWith("\x1b")) {
|
||
ctx.commandBufferRef.current += data;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Track current working directory via OSC 7 escape sequences
|
||
// OSC 7 format: \x1b]7;file://hostname/path\x07 or \x1b]7;file://hostname/path\x1b\\
|
||
let currentCwd: string | undefined = undefined;
|
||
|
||
const eraseScrollbackDisposable = term.parser.registerCsiHandler({ final: "J" }, (params) => {
|
||
if (isEraseViewportSequence(params)) {
|
||
preserveTerminalViewportInScrollback(term);
|
||
return false;
|
||
}
|
||
if (!isEraseScrollbackSequence(params)) {
|
||
return false;
|
||
}
|
||
// CSI 3 J — POSIX/ncurses default `clear` emits this to wipe scrollback.
|
||
// Honor it unless the user opts into the legacy "preserve history" behavior.
|
||
const wipeAllowed = ctx.terminalSettingsRef.current?.clearWipesScrollback ?? true;
|
||
return !wipeAllowed;
|
||
});
|
||
|
||
const writeKittyKeyboardReply = (payload: string) => {
|
||
const id = ctx.sessionRef.current;
|
||
if (!id) return;
|
||
ctx.terminalBackend.writeToSession(id, payload);
|
||
};
|
||
|
||
const kittyKeyboardDisposable = installKittyKeyboardProtocolHandlers(
|
||
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
|
||
});
|
||
|
||
// 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);
|
||
ctx.terminalBackend.writeToSession(sessionId, `\x1b]52;${target};${b64}\x07`);
|
||
};
|
||
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);
|
||
|
||
let resizeTimeout: NodeJS.Timeout | null = null;
|
||
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
||
term.onResize(({ cols, rows }) => {
|
||
const id = ctx.sessionRef.current;
|
||
if (!id) return;
|
||
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||
resizeTimeout = setTimeout(() => {
|
||
ctx.terminalBackend.resizeSession(id, cols, rows);
|
||
resizeTimeout = null;
|
||
}, resizeDebounceMs);
|
||
});
|
||
|
||
const keywordHighlighter = new KeywordHighlighter(term);
|
||
keywordHighlighter.setRules(keywordHighlightRules, keywordHighlightEnabled);
|
||
|
||
return {
|
||
term,
|
||
fitAddon,
|
||
serializeAddon,
|
||
searchAddon,
|
||
keywordHighlighter,
|
||
dispose: () => {
|
||
cleanupMiddleClick?.();
|
||
keywordHighlighter.dispose();
|
||
eraseScrollbackDisposable.dispose();
|
||
kittyKeyboardDisposable.dispose();
|
||
osc7Disposable.dispose();
|
||
osc52Disposable.dispose();
|
||
cursorPreferenceDisposable?.dispose();
|
||
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);
|
||
}
|
||
try {
|
||
webglAddon?.dispose();
|
||
} catch (err) {
|
||
logger.warn("[XTerm] webglAddon dispose failed", err);
|
||
}
|
||
},
|
||
get currentCwd() {
|
||
return currentCwd;
|
||
},
|
||
};
|
||
};
|