Fix Shift+Enter in terminal input modes (#999)
* Fix Shift+Enter terminal input modes * Advertise terminal input capabilities
This commit is contained in:
@@ -17,6 +17,7 @@ import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import type { ITheme } from "@xterm/xterm";
|
import type { ITheme } from "@xterm/xterm";
|
||||||
import type { TerminalState } from "@server/shared/messages";
|
import type { TerminalState } from "@server/shared/messages";
|
||||||
|
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
|
||||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||||
@@ -129,6 +130,7 @@ interface TerminalEmulatorProps {
|
|||||||
meta: boolean;
|
meta: boolean;
|
||||||
}) => Promise<void> | void;
|
}) => Promise<void> | void;
|
||||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||||
|
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
|
||||||
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
||||||
pendingModifiers?: PendingTerminalModifiers;
|
pendingModifiers?: PendingTerminalModifiers;
|
||||||
focusRequestToken?: number;
|
focusRequestToken?: number;
|
||||||
@@ -194,6 +196,7 @@ export default function TerminalEmulator({
|
|||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
|
onInputModeChange,
|
||||||
onRendererReadyChange,
|
onRendererReadyChange,
|
||||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||||
focusRequestToken = 0,
|
focusRequestToken = 0,
|
||||||
@@ -219,12 +222,14 @@ export default function TerminalEmulator({
|
|||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
|
onInputModeChange,
|
||||||
});
|
});
|
||||||
mountCallbacksRef.current = {
|
mountCallbacksRef.current = {
|
||||||
onInput,
|
onInput,
|
||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
|
onInputModeChange,
|
||||||
};
|
};
|
||||||
const initialSnapshotRef = useRef(initialSnapshot);
|
const initialSnapshotRef = useRef(initialSnapshot);
|
||||||
initialSnapshotRef.current = initialSnapshot;
|
initialSnapshotRef.current = initialSnapshot;
|
||||||
@@ -444,10 +449,11 @@ export default function TerminalEmulator({
|
|||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
|
onInputModeChange,
|
||||||
onOpenExternalUrl: openExternalUrl,
|
onOpenExternalUrl: openExternalUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
}, [onInput, onInputModeChange, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
||||||
|
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
import { useAppVisible } from "@/hooks/use-app-visible";
|
||||||
@@ -188,6 +189,10 @@ export function TerminalPane({
|
|||||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||||
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
|
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
|
||||||
const terminalIdRef = useRef<string>(terminalId);
|
const terminalIdRef = useRef<string>(terminalId);
|
||||||
|
const inputModeRef = useRef<TerminalInputModeState>({
|
||||||
|
kittyKeyboardFlags: 0,
|
||||||
|
win32InputMode: false,
|
||||||
|
});
|
||||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||||
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
||||||
@@ -195,6 +200,10 @@ export function TerminalPane({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
terminalIdRef.current = terminalId;
|
terminalIdRef.current = terminalId;
|
||||||
|
inputModeRef.current = {
|
||||||
|
kittyKeyboardFlags: 0,
|
||||||
|
win32InputMode: false,
|
||||||
|
};
|
||||||
}, [terminalId]);
|
}, [terminalId]);
|
||||||
|
|
||||||
const requestTerminalFocus = useCallback(() => {
|
const requestTerminalFocus = useCallback(() => {
|
||||||
@@ -413,7 +422,9 @@ export function TerminalPane({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const encoded = encodeTerminalKeyInput(entry.input);
|
const encoded = encodeTerminalKeyInput(entry.input, {
|
||||||
|
inputMode: inputModeRef.current,
|
||||||
|
});
|
||||||
if (encoded.length === 0) {
|
if (encoded.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -595,6 +606,10 @@ export function TerminalPane({
|
|||||||
clearPendingModifiers();
|
clearPendingModifiers();
|
||||||
}, [clearPendingModifiers]);
|
}, [clearPendingModifiers]);
|
||||||
|
|
||||||
|
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
|
||||||
|
inputModeRef.current = state;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleModifier = useCallback(
|
const toggleModifier = useCallback(
|
||||||
(modifier: keyof ModifierState) => {
|
(modifier: keyof ModifierState) => {
|
||||||
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
||||||
@@ -674,6 +689,7 @@ export function TerminalPane({
|
|||||||
onInput={handleTerminalData}
|
onInput={handleTerminalData}
|
||||||
onResize={handleTerminalResize}
|
onResize={handleTerminalResize}
|
||||||
onTerminalKey={handleTerminalKey}
|
onTerminalKey={handleTerminalKey}
|
||||||
|
onInputModeChange={handleInputModeChange}
|
||||||
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
||||||
pendingModifiers={modifiers}
|
pendingModifiers={modifiers}
|
||||||
focusRequestToken={focusRequestToken}
|
focusRequestToken={focusRequestToken}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { page } from "@vitest/browser/context";
|
import { page } from "@vitest/browser/context";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
|
||||||
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||||
|
|
||||||
vi.mock("@xterm/addon-webgl", () => ({
|
vi.mock("@xterm/addon-webgl", () => ({
|
||||||
@@ -15,6 +16,14 @@ interface TerminalSize {
|
|||||||
cols: number;
|
cols: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TerminalKeyRecord {
|
||||||
|
key: string;
|
||||||
|
ctrl: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
meta: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type BrowserTerminal = TerminalSize & {
|
type BrowserTerminal = TerminalSize & {
|
||||||
refresh: (start: number, end: number) => void;
|
refresh: (start: number, end: number) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
@@ -26,6 +35,8 @@ interface MountedTerminal {
|
|||||||
runtime: TerminalEmulatorRuntime;
|
runtime: TerminalEmulatorRuntime;
|
||||||
inputs: string[];
|
inputs: string[];
|
||||||
sizes: TerminalSize[];
|
sizes: TerminalSize[];
|
||||||
|
terminalKeys: TerminalKeyRecord[];
|
||||||
|
inputModeChanges: TerminalInputModeState[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const mountedTerminals: MountedTerminal[] = [];
|
const mountedTerminals: MountedTerminal[] = [];
|
||||||
@@ -67,6 +78,8 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
|
|||||||
|
|
||||||
const sizes: TerminalSize[] = [];
|
const sizes: TerminalSize[] = [];
|
||||||
const inputs: string[] = [];
|
const inputs: string[] = [];
|
||||||
|
const terminalKeys: TerminalKeyRecord[] = [];
|
||||||
|
const inputModeChanges: TerminalInputModeState[] = [];
|
||||||
const runtime = new TerminalEmulatorRuntime();
|
const runtime = new TerminalEmulatorRuntime();
|
||||||
runtime.setCallbacks({
|
runtime.setCallbacks({
|
||||||
callbacks: {
|
callbacks: {
|
||||||
@@ -76,6 +89,12 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
|
|||||||
onResize: (size) => {
|
onResize: (size) => {
|
||||||
sizes.push(size);
|
sizes.push(size);
|
||||||
},
|
},
|
||||||
|
onTerminalKey: (key) => {
|
||||||
|
terminalKeys.push(key);
|
||||||
|
},
|
||||||
|
onInputModeChange: (state) => {
|
||||||
|
inputModeChanges.push(state);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
runtime.mount({
|
runtime.mount({
|
||||||
@@ -89,7 +108,7 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mounted = { host, root, runtime, inputs, sizes };
|
const mounted = { host, root, runtime, inputs, sizes, terminalKeys, inputModeChanges };
|
||||||
mountedTerminals.push(mounted);
|
mountedTerminals.push(mounted);
|
||||||
return mounted;
|
return mounted;
|
||||||
}
|
}
|
||||||
@@ -110,6 +129,32 @@ function getBrowserTerminal(): BrowserTerminal {
|
|||||||
return terminal;
|
return terminal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchTerminalKey(input: {
|
||||||
|
host: HTMLElement;
|
||||||
|
key: string;
|
||||||
|
shiftKey?: boolean;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
altKey?: boolean;
|
||||||
|
metaKey?: boolean;
|
||||||
|
}): boolean {
|
||||||
|
const textarea = input.host.querySelector<HTMLTextAreaElement>("textarea");
|
||||||
|
if (!textarea) {
|
||||||
|
throw new Error("Expected xterm textarea to be mounted");
|
||||||
|
}
|
||||||
|
textarea.focus();
|
||||||
|
return textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: input.key,
|
||||||
|
shiftKey: input.shiftKey ?? false,
|
||||||
|
ctrlKey: input.ctrlKey ?? false,
|
||||||
|
altKey: input.altKey ?? false,
|
||||||
|
metaKey: input.metaKey ?? false,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const mounted of mountedTerminals.splice(0)) {
|
for (const mounted of mountedTerminals.splice(0)) {
|
||||||
mounted.runtime.unmount();
|
mounted.runtime.unmount();
|
||||||
@@ -162,6 +207,73 @@ describe("terminal emulator runtime in a real browser", () => {
|
|||||||
expect(refreshCalls.at(-1)).toEqual([0, terminal.rows - 1]);
|
expect(refreshCalls.at(-1)).toEqual([0, terminal.rows - 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("intercepts Shift+Enter only after enhanced terminal input mode is active", async () => {
|
||||||
|
await page.viewport(900, 600);
|
||||||
|
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||||
|
|
||||||
|
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||||
|
|
||||||
|
dispatchTerminalKey({
|
||||||
|
host: mounted.host,
|
||||||
|
key: "Enter",
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
await nextFrame();
|
||||||
|
|
||||||
|
expect(mounted.terminalKeys).toEqual([]);
|
||||||
|
|
||||||
|
mounted.runtime.write({ text: "\x1b[>7u" });
|
||||||
|
await waitFor({
|
||||||
|
predicate: () =>
|
||||||
|
mounted.inputModeChanges.some(
|
||||||
|
(state) => state.kittyKeyboardFlags === 7 && !state.win32InputMode,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchTerminalKey({
|
||||||
|
host: mounted.host,
|
||||||
|
key: "Enter",
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
await nextFrame();
|
||||||
|
|
||||||
|
expect(mounted.terminalKeys).toEqual([
|
||||||
|
{
|
||||||
|
key: "Enter",
|
||||||
|
ctrl: false,
|
||||||
|
shift: true,
|
||||||
|
alt: false,
|
||||||
|
meta: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mounted.terminalKeys.length = 0;
|
||||||
|
mounted.runtime.write({ text: "\x1b[=0;0u\x1b[?9001h" });
|
||||||
|
await waitFor({
|
||||||
|
predicate: () =>
|
||||||
|
mounted.inputModeChanges.some(
|
||||||
|
(state) => state.kittyKeyboardFlags === 0 && state.win32InputMode,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchTerminalKey({
|
||||||
|
host: mounted.host,
|
||||||
|
key: "Enter",
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
await nextFrame();
|
||||||
|
|
||||||
|
expect(mounted.terminalKeys).toEqual([
|
||||||
|
{
|
||||||
|
key: "Enter",
|
||||||
|
ctrl: false,
|
||||||
|
shift: true,
|
||||||
|
alt: false,
|
||||||
|
meta: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ name: "DA1", bytes: "\x1b[c" },
|
{ name: "DA1", bytes: "\x1b[c" },
|
||||||
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
||||||
|
|||||||
@@ -176,6 +176,38 @@ describe("terminal-emulator-runtime", () => {
|
|||||||
expect(onCommitted).toHaveBeenCalledTimes(1);
|
expect(onCommitted).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports input mode changes from terminal output and resets them on snapshots", () => {
|
||||||
|
const { runtime, writeCallbacks } = createRuntimeWithTerminal();
|
||||||
|
const inputModeChanges: Array<{ kittyKeyboardFlags: number; win32InputMode: boolean }> = [];
|
||||||
|
runtime.setCallbacks({
|
||||||
|
callbacks: {
|
||||||
|
onInputModeChange: (state) => {
|
||||||
|
inputModeChanges.push(state);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.write({ text: "\x1b[>7u" });
|
||||||
|
runtime.renderSnapshot({
|
||||||
|
state: {
|
||||||
|
rows: 2,
|
||||||
|
cols: 8,
|
||||||
|
scrollback: [],
|
||||||
|
grid: [[{ char: "$" }, { char: " " }]],
|
||||||
|
cursor: {
|
||||||
|
row: 0,
|
||||||
|
col: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeCallbacks[0]?.();
|
||||||
|
|
||||||
|
expect(inputModeChanges).toEqual([
|
||||||
|
{ kittyKeyboardFlags: 7, win32InputMode: false },
|
||||||
|
{ kittyKeyboardFlags: 0, win32InputMode: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores stale duplicate write callbacks from a previous operation", () => {
|
it("ignores stale duplicate write callbacks from a previous operation", () => {
|
||||||
const { runtime, writeCallbacks } = createRuntimeWithTerminal();
|
const { runtime, writeCallbacks } = createRuntimeWithTerminal();
|
||||||
const committed: string[] = [];
|
const committed: string[] = [];
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import { WebglAddon } from "@xterm/addon-webgl";
|
|||||||
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
|
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
|
||||||
import { Terminal, type ITheme } from "@xterm/xterm";
|
import { Terminal, type ITheme } from "@xterm/xterm";
|
||||||
import type { TerminalState } from "@server/shared/messages";
|
import type { TerminalState } from "@server/shared/messages";
|
||||||
|
import {
|
||||||
|
type TerminalInputModeState,
|
||||||
|
TerminalInputModeTracker,
|
||||||
|
terminalInputModeStatesEqual,
|
||||||
|
} from "@server/shared/terminal-input-mode";
|
||||||
import {
|
import {
|
||||||
type PendingTerminalModifiers,
|
type PendingTerminalModifiers,
|
||||||
isTerminalModifierDomKey,
|
isTerminalModifierDomKey,
|
||||||
@@ -37,6 +42,7 @@ export interface TerminalEmulatorRuntimeCallbacks {
|
|||||||
}) => Promise<void> | void;
|
}) => Promise<void> | void;
|
||||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||||
onOpenExternalUrl?: (url: string) => Promise<void> | void;
|
onOpenExternalUrl?: (url: string) => Promise<void> | void;
|
||||||
|
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TerminalEmulatorRuntimeDisposables {
|
interface TerminalEmulatorRuntimeDisposables {
|
||||||
@@ -125,6 +131,8 @@ export class TerminalEmulatorRuntime {
|
|||||||
private inFlightOutputOperation: TerminalOutputOperation | null = null;
|
private inFlightOutputOperation: TerminalOutputOperation | null = null;
|
||||||
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
|
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private suppressInput = false;
|
private suppressInput = false;
|
||||||
|
private readonly inputModeTracker = new TerminalInputModeTracker();
|
||||||
|
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
|
||||||
|
|
||||||
private handleVisibilityRestore = (): void => {
|
private handleVisibilityRestore = (): void => {
|
||||||
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
|
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
|
||||||
@@ -152,6 +160,8 @@ export class TerminalEmulatorRuntime {
|
|||||||
|
|
||||||
input.host.innerHTML = "";
|
input.host.innerHTML = "";
|
||||||
this.lastSize = null;
|
this.lastSize = null;
|
||||||
|
this.inputModeTracker.reset();
|
||||||
|
this.emitInputModeChange();
|
||||||
|
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
@@ -353,6 +363,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
altKey: event.altKey,
|
altKey: event.altKey,
|
||||||
metaKey: event.metaKey,
|
metaKey: event.metaKey,
|
||||||
pendingModifiers: this.pendingModifiers,
|
pendingModifiers: this.pendingModifiers,
|
||||||
|
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
@@ -606,6 +617,8 @@ export class TerminalEmulatorRuntime {
|
|||||||
this.fitAndEmitResize = null;
|
this.fitAndEmitResize = null;
|
||||||
this.lastSize = null;
|
this.lastSize = null;
|
||||||
this.suppressInput = false;
|
this.suppressInput = false;
|
||||||
|
this.inputModeTracker.reset();
|
||||||
|
this.emitInputModeChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
private processOutputQueue(): void {
|
private processOutputQueue(): void {
|
||||||
@@ -640,12 +653,16 @@ export class TerminalEmulatorRuntime {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (operation.type === "clear") {
|
if (operation.type === "clear") {
|
||||||
|
this.inputModeTracker.reset();
|
||||||
|
this.emitInputModeChange();
|
||||||
terminal.reset();
|
terminal.reset();
|
||||||
finalizeOperation(operation);
|
finalizeOperation(operation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operation.type === "snapshot") {
|
if (operation.type === "snapshot") {
|
||||||
|
this.inputModeTracker.reset();
|
||||||
|
this.emitInputModeChange();
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
typeof operation.cols === "number" &&
|
typeof operation.cols === "number" &&
|
||||||
@@ -661,6 +678,12 @@ export class TerminalEmulatorRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = operation.text;
|
const text = operation.text;
|
||||||
|
if (operation.type === "write") {
|
||||||
|
const result = this.inputModeTracker.feed(text);
|
||||||
|
if (result.changed) {
|
||||||
|
this.emitInputModeChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
this.inFlightOutputOperationTimeout = setTimeout(() => {
|
this.inFlightOutputOperationTimeout = setTimeout(() => {
|
||||||
finalizeOperation(operation);
|
finalizeOperation(operation);
|
||||||
}, OUTPUT_OPERATION_TIMEOUT_MS);
|
}, OUTPUT_OPERATION_TIMEOUT_MS);
|
||||||
@@ -682,6 +705,15 @@ export class TerminalEmulatorRuntime {
|
|||||||
this.inFlightOutputOperationTimeout = null;
|
this.inFlightOutputOperationTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitInputModeChange(): void {
|
||||||
|
const state = this.inputModeTracker.getState();
|
||||||
|
if (terminalInputModeStatesEqual(state, this.lastInputModeState)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastInputModeState = state;
|
||||||
|
this.callbacks.onInputModeChange?.(state);
|
||||||
|
}
|
||||||
|
|
||||||
private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void {
|
private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void {
|
||||||
const documentElement = document.documentElement;
|
const documentElement = document.documentElement;
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ describe("terminal key helpers", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("intercepts Enter with DOM shift modifier for CSI u encoding", () => {
|
it("does not intercept modified Enter before enhanced input mode is active", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldInterceptDomTerminalKey({
|
shouldInterceptDomTerminalKey({
|
||||||
key: "Enter",
|
key: "Enter",
|
||||||
@@ -105,10 +105,10 @@ describe("terminal key helpers", () => {
|
|||||||
metaKey: false,
|
metaKey: false,
|
||||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("intercepts Enter with any DOM modifier for CSI u encoding", () => {
|
it("intercepts Enter with any DOM modifier after enhanced input mode is active", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldInterceptDomTerminalKey({
|
shouldInterceptDomTerminalKey({
|
||||||
key: "Enter",
|
key: "Enter",
|
||||||
@@ -117,6 +117,7 @@ describe("terminal key helpers", () => {
|
|||||||
altKey: false,
|
altKey: false,
|
||||||
metaKey: false,
|
metaKey: false,
|
||||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
|
enhancedInputActive: true,
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
@@ -127,6 +128,7 @@ describe("terminal key helpers", () => {
|
|||||||
altKey: true,
|
altKey: true,
|
||||||
metaKey: false,
|
metaKey: false,
|
||||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
|
enhancedInputActive: true,
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
@@ -137,6 +139,7 @@ describe("terminal key helpers", () => {
|
|||||||
altKey: false,
|
altKey: false,
|
||||||
metaKey: true,
|
metaKey: true,
|
||||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
|
enhancedInputActive: true,
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,15 +89,13 @@ export function shouldInterceptDomTerminalKey(args: {
|
|||||||
altKey: boolean;
|
altKey: boolean;
|
||||||
metaKey: boolean;
|
metaKey: boolean;
|
||||||
pendingModifiers: PendingTerminalModifiers;
|
pendingModifiers: PendingTerminalModifiers;
|
||||||
|
enhancedInputActive?: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (hasPendingTerminalModifiers(args.pendingModifiers)) {
|
if (hasPendingTerminalModifiers(args.pendingModifiers)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// xterm.js sends plain \r for Enter regardless of modifiers.
|
|
||||||
// Intercept modified Enter so it gets CSI u encoding (Kitty keyboard protocol),
|
|
||||||
// which Claude Code and other TUI apps use for Shift+Enter newlines.
|
|
||||||
if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) {
|
if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) {
|
||||||
return true;
|
return Boolean(args.enhancedInputActive);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,6 +457,29 @@ function sendRawTerminalInput(ws: WebSocket, slot: number, data: string): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createInputModeProbeOptions(): { command: string; args: string[] } {
|
||||||
|
return {
|
||||||
|
command: process.execPath,
|
||||||
|
args: [
|
||||||
|
"-e",
|
||||||
|
`
|
||||||
|
process.stdin.setRawMode?.(true);
|
||||||
|
process.stdin.resume();
|
||||||
|
process.stdin.on("data", (chunk) => {
|
||||||
|
const text = chunk.toString("utf8");
|
||||||
|
if (text.includes("e")) {
|
||||||
|
process.stdout.write("\\x1b[>7uKITTY\\n");
|
||||||
|
}
|
||||||
|
if (text.includes("w")) {
|
||||||
|
process.stdout.write("\\x1b[?9001hWIN32\\n");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setInterval(() => {}, 1000);
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function repeatRawTerminalInput(input: {
|
async function repeatRawTerminalInput(input: {
|
||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
slot: number;
|
slot: number;
|
||||||
@@ -945,6 +968,84 @@ test("disconnect and reconnect both receive the current snapshot", async () => {
|
|||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
test("reconnected terminal streams replay active input modes after the snapshot", async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const created = await ctx.client.createTerminal(cwd, "input-mode-probe", undefined, {
|
||||||
|
...createInputModeProbeOptions(),
|
||||||
|
});
|
||||||
|
const terminalId = created.terminal!.id;
|
||||||
|
const firstWs = await connectRawWebSocket(ctx.daemon.port);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstSlot = await subscribeRawTerminal(firstWs, terminalId, "sub-input-mode-first");
|
||||||
|
await waitForRawBinaryFrame(
|
||||||
|
firstWs,
|
||||||
|
(frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Snapshot,
|
||||||
|
);
|
||||||
|
sendRawTerminalInput(firstWs, firstSlot, "e");
|
||||||
|
await waitForRawBinaryFrame(
|
||||||
|
firstWs,
|
||||||
|
(frame) =>
|
||||||
|
frame.slot === firstSlot &&
|
||||||
|
frame.opcode === TerminalStreamOpcode.Output &&
|
||||||
|
getFrameText(frame).includes("KITTY"),
|
||||||
|
);
|
||||||
|
sendRawTerminalInput(firstWs, firstSlot, "w");
|
||||||
|
await waitForRawBinaryFrame(
|
||||||
|
firstWs,
|
||||||
|
(frame) =>
|
||||||
|
frame.slot === firstSlot &&
|
||||||
|
frame.opcode === TerminalStreamOpcode.Output &&
|
||||||
|
getFrameText(frame).includes("WIN32"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await closeWebSocket(firstWs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondWs = await connectRawWebSocket(ctx.daemon.port);
|
||||||
|
try {
|
||||||
|
const secondSlot = await subscribeRawTerminal(secondWs, terminalId, "sub-input-mode-second");
|
||||||
|
await waitForRawBinaryFrame(
|
||||||
|
secondWs,
|
||||||
|
(frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Snapshot,
|
||||||
|
);
|
||||||
|
const replay = await waitForRawBinaryFrame(
|
||||||
|
secondWs,
|
||||||
|
(frame) =>
|
||||||
|
frame.slot === secondSlot &&
|
||||||
|
frame.opcode === TerminalStreamOpcode.Output &&
|
||||||
|
getFrameText(frame).includes("\x1b[=7;1u"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getFrameText(replay)).toContain("\x1b[=7;1u");
|
||||||
|
expect(getFrameText(replay)).toContain("\x1b[?9001h");
|
||||||
|
} finally {
|
||||||
|
await closeWebSocket(secondWs);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
test("reconnected terminal streams do not replay input modes before they are enabled", async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const created = await ctx.client.createTerminal(cwd, "input-mode-inactive-probe", undefined, {
|
||||||
|
...createInputModeProbeOptions(),
|
||||||
|
});
|
||||||
|
const terminalId = created.terminal!.id;
|
||||||
|
const ws = await connectRawWebSocket(ctx.daemon.port);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const slot = await subscribeRawTerminal(ws, terminalId, "sub-input-mode-inactive");
|
||||||
|
await waitForRawBinaryFrame(
|
||||||
|
ws,
|
||||||
|
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot,
|
||||||
|
);
|
||||||
|
await waitForNoRawBinaryFrame(ws, 300);
|
||||||
|
} finally {
|
||||||
|
await closeWebSocket(ws);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
test("fast output to a slow websocket client falls back to a snapshot", async () => {
|
test("fast output to a slow websocket client falls back to a snapshot", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const created = await ctx.client.createTerminal(cwd);
|
const created = await ctx.client.createTerminal(cwd);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { TerminalInputModeTracker } from "./terminal-input-mode.js";
|
||||||
|
|
||||||
|
describe("TerminalInputModeTracker", () => {
|
||||||
|
it("activates from a pushed Kitty keyboard mode and builds a replay preamble", () => {
|
||||||
|
const tracker = new TerminalInputModeTracker();
|
||||||
|
|
||||||
|
expect(tracker.feed("\x1b[>7u").changed).toBe(true);
|
||||||
|
|
||||||
|
expect(tracker.supportsModifiedEnter()).toBe(true);
|
||||||
|
expect(tracker.getKittyKeyboardFlags()).toBe(7);
|
||||||
|
expect(tracker.getPreamble()).toBe("\x1b[=7;1u");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks split terminal output chunks", () => {
|
||||||
|
const tracker = new TerminalInputModeTracker();
|
||||||
|
|
||||||
|
tracker.feed("\x1b[>");
|
||||||
|
const result = tracker.feed("1u");
|
||||||
|
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
expect(tracker.getKittyKeyboardFlags()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores pushed Kitty keyboard modes when the foreground program pops them", () => {
|
||||||
|
const tracker = new TerminalInputModeTracker();
|
||||||
|
|
||||||
|
tracker.feed("\x1b[>1u");
|
||||||
|
tracker.feed("\x1b[>7u");
|
||||||
|
expect(tracker.getKittyKeyboardFlags()).toBe(7);
|
||||||
|
|
||||||
|
expect(tracker.feed("\x1b[<u").changed).toBe(true);
|
||||||
|
expect(tracker.getKittyKeyboardFlags()).toBe(1);
|
||||||
|
|
||||||
|
expect(tracker.feed("\x1b[<u").changed).toBe(true);
|
||||||
|
expect(tracker.supportsModifiedEnter()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers Kitty keyboard mode queries with the current flags", () => {
|
||||||
|
const tracker = new TerminalInputModeTracker();
|
||||||
|
tracker.feed("\x1b[=3;1u");
|
||||||
|
|
||||||
|
expect(tracker.feed("\x1b[?u").responses).toEqual(["\x1b[?3u"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks ConPTY Win32 input mode and replays it after snapshots", () => {
|
||||||
|
const tracker = new TerminalInputModeTracker();
|
||||||
|
|
||||||
|
expect(tracker.feed("\x1b[?9001h").changed).toBe(true);
|
||||||
|
|
||||||
|
expect(tracker.getState()).toEqual({
|
||||||
|
kittyKeyboardFlags: 0,
|
||||||
|
win32InputMode: true,
|
||||||
|
});
|
||||||
|
expect(tracker.supportsModifiedEnter()).toBe(true);
|
||||||
|
expect(tracker.getPreamble()).toBe("\x1b[?9001h");
|
||||||
|
|
||||||
|
expect(tracker.feed("\x1b[?9001l").changed).toBe(true);
|
||||||
|
expect(tracker.supportsModifiedEnter()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Kitty and Win32 input modes independent", () => {
|
||||||
|
const tracker = new TerminalInputModeTracker();
|
||||||
|
|
||||||
|
tracker.feed("\x1b[>7u\x1b[?9001h");
|
||||||
|
|
||||||
|
expect(tracker.getState()).toEqual({
|
||||||
|
kittyKeyboardFlags: 7,
|
||||||
|
win32InputMode: true,
|
||||||
|
});
|
||||||
|
expect(tracker.getPreamble()).toBe("\x1b[=7;1u\x1b[?9001h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores encoded key input sequences", () => {
|
||||||
|
const tracker = new TerminalInputModeTracker();
|
||||||
|
|
||||||
|
tracker.feed("\x1b[13;2u");
|
||||||
|
|
||||||
|
expect(tracker.supportsModifiedEnter()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
export interface TerminalInputModeFeedResult {
|
||||||
|
changed: boolean;
|
||||||
|
responses: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalInputModeState {
|
||||||
|
kittyKeyboardFlags: number;
|
||||||
|
win32InputMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_TERMINAL_INPUT_MODE_STATE: TerminalInputModeState = {
|
||||||
|
kittyKeyboardFlags: 0,
|
||||||
|
win32InputMode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ESC = String.fromCharCode(0x1b);
|
||||||
|
const WIN32_INPUT_MODE = 9001;
|
||||||
|
const CSI_INPUT_MODE_SEQUENCE = new RegExp(
|
||||||
|
`${ESC}\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl]))`,
|
||||||
|
"g",
|
||||||
|
);
|
||||||
|
const INCOMPLETE_CSI_INPUT_MODE_SEQUENCE = new RegExp(`${ESC}\\[[<>=?]?[0-9;]*$`);
|
||||||
|
|
||||||
|
function parseFirstParam(params: string): number | null {
|
||||||
|
const first = params.split(";")[0];
|
||||||
|
if (!first || !/^\d+$/.test(first)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Number(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSecondParam(params: string): number | null {
|
||||||
|
const second = params.split(";")[1];
|
||||||
|
if (!second || !/^\d+$/.test(second)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Number(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrivateModeParams(params: string): Set<number> {
|
||||||
|
const modes = new Set<number>();
|
||||||
|
for (const param of params.split(";")) {
|
||||||
|
if (/^\d+$/.test(param)) {
|
||||||
|
modes.add(Number(param));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function terminalInputModeSupportsModifiedEnter(state: TerminalInputModeState): boolean {
|
||||||
|
return state.kittyKeyboardFlags > 0 || state.win32InputMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function terminalInputModeStatesEqual(
|
||||||
|
left: TerminalInputModeState,
|
||||||
|
right: TerminalInputModeState,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left.kittyKeyboardFlags === right.kittyKeyboardFlags &&
|
||||||
|
left.win32InputMode === right.win32InputMode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TerminalInputModeTracker {
|
||||||
|
private kittyKeyboardFlags = 0;
|
||||||
|
private win32InputMode = false;
|
||||||
|
private readonly kittyKeyboardStack: number[] = [];
|
||||||
|
private pending = "";
|
||||||
|
|
||||||
|
feed(data: string): TerminalInputModeFeedResult {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return { changed: false, responses: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = `${this.pending}${data}`;
|
||||||
|
this.pending = "";
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const responses: string[] = [];
|
||||||
|
let consumedUntil = 0;
|
||||||
|
|
||||||
|
CSI_INPUT_MODE_SEQUENCE.lastIndex = 0;
|
||||||
|
for (;;) {
|
||||||
|
const match = CSI_INPUT_MODE_SEQUENCE.exec(text);
|
||||||
|
if (!match) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
consumedUntil = CSI_INPUT_MODE_SEQUENCE.lastIndex;
|
||||||
|
|
||||||
|
if (match[4]) {
|
||||||
|
changed = this.applyPrivateModeSequence(match[3] ?? "", match[4]) || changed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.applyKittyKeyboardSequence(match[1] ?? "", match[2] ?? "");
|
||||||
|
changed = changed || result.changed;
|
||||||
|
responses.push(...result.responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tail = text.slice(consumedUntil);
|
||||||
|
const pendingStart = tail.lastIndexOf(`${ESC}[`);
|
||||||
|
if (pendingStart >= 0) {
|
||||||
|
const pending = tail.slice(pendingStart);
|
||||||
|
if (INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
|
||||||
|
this.pending = pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { changed, responses };
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.kittyKeyboardFlags = 0;
|
||||||
|
this.win32InputMode = false;
|
||||||
|
this.kittyKeyboardStack.length = 0;
|
||||||
|
this.pending = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): TerminalInputModeState {
|
||||||
|
return {
|
||||||
|
kittyKeyboardFlags: this.kittyKeyboardFlags,
|
||||||
|
win32InputMode: this.win32InputMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getKittyKeyboardFlags(): number {
|
||||||
|
return this.kittyKeyboardFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
supportsModifiedEnter(): boolean {
|
||||||
|
return terminalInputModeSupportsModifiedEnter(this.getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreamble(): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (this.kittyKeyboardFlags > 0) {
|
||||||
|
parts.push(`\x1b[=${this.kittyKeyboardFlags};1u`);
|
||||||
|
}
|
||||||
|
if (this.win32InputMode) {
|
||||||
|
parts.push("\x1b[?9001h");
|
||||||
|
}
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyKittyKeyboardSequence(
|
||||||
|
prefix: string,
|
||||||
|
params: string,
|
||||||
|
): { changed: boolean; responses: string[] } {
|
||||||
|
const previousFlags = this.kittyKeyboardFlags;
|
||||||
|
|
||||||
|
switch (prefix) {
|
||||||
|
case ">": {
|
||||||
|
this.kittyKeyboardStack.push(this.kittyKeyboardFlags);
|
||||||
|
this.kittyKeyboardFlags = parseFirstParam(params) ?? 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "=": {
|
||||||
|
const mode = parseSecondParam(params) ?? 1;
|
||||||
|
this.kittyKeyboardFlags = mode === 0 ? 0 : (parseFirstParam(params) ?? 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "<": {
|
||||||
|
const count = Math.max(1, parseFirstParam(params) ?? 1);
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
this.kittyKeyboardFlags = this.kittyKeyboardStack.pop() ?? 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "?":
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
responses: [`\x1b[?${this.kittyKeyboardFlags}u`],
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { changed: false, responses: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed: this.kittyKeyboardFlags !== previousFlags,
|
||||||
|
responses: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyPrivateModeSequence(params: string, final: string): boolean {
|
||||||
|
const modes = parsePrivateModeParams(params);
|
||||||
|
if (!modes.has(WIN32_INPUT_MODE)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = this.win32InputMode;
|
||||||
|
this.win32InputMode = final === "h";
|
||||||
|
return this.win32InputMode !== previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,15 +19,36 @@ describe("encodeTerminalKeyInput", () => {
|
|||||||
expect(encodeTerminalKeyInput({ key: "Backspace" })).toBe("\x7f");
|
expect(encodeTerminalKeyInput({ key: "Backspace" })).toBe("\x7f");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("encodes shift+enter as kitty keyboard protocol CSI u", () => {
|
it("keeps modified Enter as carriage return before enhanced input mode is active", () => {
|
||||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true })).toBe("\x1b[13;2u");
|
expect(encodeTerminalKeyInput({ key: "Enter", shift: true })).toBe("\r");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("encodes enter with modifiers using CSI u", () => {
|
it("encodes Enter with modifiers using CSI u after Kitty keyboard mode is active", () => {
|
||||||
expect(encodeTerminalKeyInput({ key: "Enter", ctrl: true })).toBe("\x1b[13;5u");
|
const options = { inputMode: { kittyKeyboardFlags: 7, win32InputMode: false } };
|
||||||
expect(encodeTerminalKeyInput({ key: "Enter", alt: true })).toBe("\x1b[13;3u");
|
|
||||||
expect(encodeTerminalKeyInput({ key: "Enter", meta: true })).toBe("\x1b[13;9u");
|
expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe("\x1b[13;2u");
|
||||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true, ctrl: true })).toBe("\x1b[13;6u");
|
expect(encodeTerminalKeyInput({ key: "Enter", ctrl: true }, options)).toBe("\x1b[13;5u");
|
||||||
|
expect(encodeTerminalKeyInput({ key: "Enter", alt: true }, options)).toBe("\x1b[13;3u");
|
||||||
|
expect(encodeTerminalKeyInput({ key: "Enter", meta: true }, options)).toBe("\x1b[13;9u");
|
||||||
|
expect(encodeTerminalKeyInput({ key: "Enter", shift: true, ctrl: true }, options)).toBe(
|
||||||
|
"\x1b[13;6u",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes Shift+Enter using Win32 input mode when ConPTY requests it", () => {
|
||||||
|
const options = { inputMode: { kittyKeyboardFlags: 0, win32InputMode: true } };
|
||||||
|
|
||||||
|
expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe(
|
||||||
|
"\x1b[13;28;13;1;16;1_",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers Win32 input mode over CSI u when both modes are active", () => {
|
||||||
|
const options = { inputMode: { kittyKeyboardFlags: 7, win32InputMode: true } };
|
||||||
|
|
||||||
|
expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe(
|
||||||
|
"\x1b[13;28;13;1;16;1_",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty string for unsupported keys", () => {
|
it("returns empty string for unsupported keys", () => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { TerminalInputModeState } from "./terminal-input-mode.js";
|
||||||
|
|
||||||
export interface TerminalKeyInput {
|
export interface TerminalKeyInput {
|
||||||
key: string;
|
key: string;
|
||||||
ctrl?: boolean;
|
ctrl?: boolean;
|
||||||
@@ -6,6 +8,14 @@ export interface TerminalKeyInput {
|
|||||||
meta?: boolean;
|
meta?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TerminalKeyInputEncodingOptions {
|
||||||
|
inputMode?: TerminalInputModeState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIN32_LEFT_ALT_PRESSED = 0x0002;
|
||||||
|
const WIN32_LEFT_CTRL_PRESSED = 0x0008;
|
||||||
|
const WIN32_SHIFT_PRESSED = 0x0010;
|
||||||
|
|
||||||
function modifierParam(input: TerminalKeyInput): number {
|
function modifierParam(input: TerminalKeyInput): number {
|
||||||
let value = 1;
|
let value = 1;
|
||||||
if (input.shift) value += 1;
|
if (input.shift) value += 1;
|
||||||
@@ -15,6 +25,40 @@ function modifierParam(input: TerminalKeyInput): number {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function win32ControlKeyState(input: TerminalKeyInput): number {
|
||||||
|
let value = 0;
|
||||||
|
if (input.shift) value += WIN32_SHIFT_PRESSED;
|
||||||
|
if (input.ctrl) value += WIN32_LEFT_CTRL_PRESSED;
|
||||||
|
if (input.alt) value += WIN32_LEFT_ALT_PRESSED;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseWin32InputMode(
|
||||||
|
input: TerminalKeyInput,
|
||||||
|
options: TerminalKeyInputEncodingOptions,
|
||||||
|
): boolean {
|
||||||
|
if (!options.inputMode?.win32InputMode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(input.shift || input.ctrl || input.alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseKittyKeyboardMode(
|
||||||
|
input: TerminalKeyInput,
|
||||||
|
options: TerminalKeyInputEncodingOptions,
|
||||||
|
): boolean {
|
||||||
|
if ((options.inputMode?.kittyKeyboardFlags ?? 0) <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(input.shift || input.ctrl || input.alt || input.meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeWin32EnterKeyInput(input: TerminalKeyInput): string {
|
||||||
|
const controlKeyState = win32ControlKeyState(input);
|
||||||
|
// ConPTY Win32 input mode: CSI Vk;Sc;Uc;Kd;Cs;Rc _
|
||||||
|
return `\x1b[13;28;13;1;${controlKeyState};1_`;
|
||||||
|
}
|
||||||
|
|
||||||
function applyAltLikePrefix(sequence: string, input: TerminalKeyInput): string {
|
function applyAltLikePrefix(sequence: string, input: TerminalKeyInput): string {
|
||||||
return input.alt ? `\x1b${sequence}` : sequence;
|
return input.alt ? `\x1b${sequence}` : sequence;
|
||||||
}
|
}
|
||||||
@@ -144,7 +188,10 @@ function encodeNavigationKey(key: string, input: TerminalKeyInput): string | nul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeTerminalKeyInput(input: TerminalKeyInput): string {
|
export function encodeTerminalKeyInput(
|
||||||
|
input: TerminalKeyInput,
|
||||||
|
options: TerminalKeyInputEncodingOptions = {},
|
||||||
|
): string {
|
||||||
const key = input.key;
|
const key = input.key;
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return "";
|
return "";
|
||||||
@@ -157,7 +204,10 @@ export function encodeTerminalKeyInput(input: TerminalKeyInput): string {
|
|||||||
switch (key) {
|
switch (key) {
|
||||||
case "Enter": {
|
case "Enter": {
|
||||||
const mod = modifierParam(input);
|
const mod = modifierParam(input);
|
||||||
if (mod > 1) {
|
if (mod > 1 && shouldUseWin32InputMode(input, options)) {
|
||||||
|
return encodeWin32EnterKeyInput(input);
|
||||||
|
}
|
||||||
|
if (mod > 1 && shouldUseKittyKeyboardMode(input, options)) {
|
||||||
return `\x1b[13;${mod}u`;
|
return `\x1b[13;${mod}u`;
|
||||||
}
|
}
|
||||||
return "\r";
|
return "\r";
|
||||||
|
|||||||
@@ -681,7 +681,13 @@ export class TerminalSessionController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.terminalManager?.getTerminal(activeStream.terminalId)) {
|
const terminalManager = this.terminalManager;
|
||||||
|
if (!terminalManager) {
|
||||||
|
this.detachStream(activeStream.terminalId, { emitExit: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const terminal = terminalManager.getTerminal(activeStream.terminalId);
|
||||||
|
if (!terminal) {
|
||||||
this.detachStream(activeStream.terminalId, { emitExit: true });
|
this.detachStream(activeStream.terminalId, { emitExit: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -689,7 +695,7 @@ export class TerminalSessionController {
|
|||||||
activeStream.outputCoalescer.flush();
|
activeStream.outputCoalescer.flush();
|
||||||
activeStream.snapshotInFlight = true;
|
activeStream.snapshotInFlight = true;
|
||||||
try {
|
try {
|
||||||
const snapshot = await this.terminalManager.getTerminalState(activeStream.terminalId);
|
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId);
|
||||||
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
|
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -706,6 +712,11 @@ export class TerminalSessionController {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const replayPreamble = terminal.getReplayPreamble();
|
||||||
|
if (replayPreamble.length > 0) {
|
||||||
|
activeStream.outputCoalescer.handle(replayPreamble);
|
||||||
|
}
|
||||||
|
|
||||||
const bufferedOutputs = activeStream.bufferedOutputs.splice(
|
const bufferedOutputs = activeStream.bufferedOutputs.splice(
|
||||||
0,
|
0,
|
||||||
activeStream.bufferedOutputs.length,
|
activeStream.bufferedOutputs.length,
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(resolvedEnv.TERM).toBe("xterm-256color");
|
expect(resolvedEnv.TERM).toBe("xterm-256color");
|
||||||
|
expect(resolvedEnv.TERM_PROGRAM).toBe("kitty");
|
||||||
expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir");
|
expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir");
|
||||||
expect(resolvedEnv.ZDOTDIR).not.toBe("/tmp/paseo-zdotdir");
|
expect(resolvedEnv.ZDOTDIR).not.toBe("/tmp/paseo-zdotdir");
|
||||||
expect(existsSync(join(resolvedEnv.ZDOTDIR, ".zshenv"))).toBe(true);
|
expect(existsSync(join(resolvedEnv.ZDOTDIR, ".zshenv"))).toBe(true);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||||
import { writePrivateFileAtomicSync } from "../server/private-files.js";
|
import { writePrivateFileAtomicSync } from "../server/private-files.js";
|
||||||
import type { TerminalCell, TerminalState } from "../shared/messages.js";
|
import type { TerminalCell, TerminalState } from "../shared/messages.js";
|
||||||
|
import { TerminalInputModeTracker } from "../shared/terminal-input-mode.js";
|
||||||
|
|
||||||
const { Terminal } = xterm;
|
const { Terminal } = xterm;
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
@@ -54,6 +55,7 @@ export interface TerminalSession {
|
|||||||
getSize(): { rows: number; cols: number };
|
getSize(): { rows: number; cols: number };
|
||||||
getState(): TerminalState;
|
getState(): TerminalState;
|
||||||
getStateSnapshot(): TerminalStateSnapshot;
|
getStateSnapshot(): TerminalStateSnapshot;
|
||||||
|
getReplayPreamble(): string;
|
||||||
getTitle(): string | undefined;
|
getTitle(): string | undefined;
|
||||||
getExitInfo(): TerminalExitInfo | null;
|
getExitInfo(): TerminalExitInfo | null;
|
||||||
kill(): void;
|
kill(): void;
|
||||||
@@ -219,6 +221,7 @@ export function buildTerminalEnvironment(
|
|||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const baseEnv: Record<string, string> = createExternalProcessEnv(process.env, input.env, {
|
const baseEnv: Record<string, string> = createExternalProcessEnv(process.env, input.env, {
|
||||||
TERM: "xterm-256color",
|
TERM: "xterm-256color",
|
||||||
|
TERM_PROGRAM: "kitty",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (basename(input.shell) !== "zsh") {
|
if (basename(input.shell) !== "zsh") {
|
||||||
@@ -551,6 +554,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
let pendingInput = "";
|
let pendingInput = "";
|
||||||
let inputFlushImmediate: ReturnType<typeof setImmediate> | null = null;
|
let inputFlushImmediate: ReturnType<typeof setImmediate> | null = null;
|
||||||
let stateRevision = 0;
|
let stateRevision = 0;
|
||||||
|
const inputModeTracker = new TerminalInputModeTracker();
|
||||||
|
|
||||||
// Create xterm.js headless terminal
|
// Create xterm.js headless terminal
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
@@ -703,6 +707,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
}
|
}
|
||||||
disposed = true;
|
disposed = true;
|
||||||
pendingInput = "";
|
pendingInput = "";
|
||||||
|
inputModeTracker.reset();
|
||||||
if (inputFlushImmediate) {
|
if (inputFlushImmediate) {
|
||||||
clearImmediate(inputFlushImmediate);
|
clearImmediate(inputFlushImmediate);
|
||||||
inputFlushImmediate = null;
|
inputFlushImmediate = null;
|
||||||
@@ -735,6 +740,10 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
// Pipe PTY output to terminal emulator
|
// Pipe PTY output to terminal emulator
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data) => {
|
||||||
if (killed) return;
|
if (killed) return;
|
||||||
|
const inputModeUpdate = inputModeTracker.feed(data);
|
||||||
|
for (const response of inputModeUpdate.responses) {
|
||||||
|
ptyProcess.write(response);
|
||||||
|
}
|
||||||
recentOutputText = `${recentOutputText}${data}`;
|
recentOutputText = `${recentOutputText}${data}`;
|
||||||
if (recentOutputText.length > TERMINAL_EXIT_OUTPUT_CHAR_LIMIT) {
|
if (recentOutputText.length > TERMINAL_EXIT_OUTPUT_CHAR_LIMIT) {
|
||||||
recentOutputText = recentOutputText.slice(-TERMINAL_EXIT_OUTPUT_CHAR_LIMIT);
|
recentOutputText = recentOutputText.slice(-TERMINAL_EXIT_OUTPUT_CHAR_LIMIT);
|
||||||
@@ -803,6 +812,10 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getReplayPreamble(): string {
|
||||||
|
return inputModeTracker.getPreamble();
|
||||||
|
}
|
||||||
|
|
||||||
function writeInputToPty(data: string): void {
|
function writeInputToPty(data: string): void {
|
||||||
ptyProcess.write(data);
|
ptyProcess.write(data);
|
||||||
}
|
}
|
||||||
@@ -1034,6 +1047,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
getSize,
|
getSize,
|
||||||
getState,
|
getState,
|
||||||
getStateSnapshot,
|
getStateSnapshot,
|
||||||
|
getReplayPreamble,
|
||||||
getTitle,
|
getTitle,
|
||||||
getExitInfo,
|
getExitInfo,
|
||||||
kill,
|
kill,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { fork } from "node:child_process";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { TerminalState } from "../shared/messages.js";
|
import type { TerminalState } from "../shared/messages.js";
|
||||||
|
import { TerminalInputModeTracker } from "../shared/terminal-input-mode.js";
|
||||||
import type {
|
import type {
|
||||||
ClientMessage,
|
ClientMessage,
|
||||||
ServerMessage,
|
ServerMessage,
|
||||||
@@ -43,6 +44,7 @@ interface PendingRequest {
|
|||||||
interface WorkerTerminalRecord {
|
interface WorkerTerminalRecord {
|
||||||
info: WorkerTerminalInfo;
|
info: WorkerTerminalInfo;
|
||||||
state: TerminalState;
|
state: TerminalState;
|
||||||
|
inputModeTracker: TerminalInputModeTracker;
|
||||||
exitInfo: TerminalExitInfo | null;
|
exitInfo: TerminalExitInfo | null;
|
||||||
messageListeners: Set<(msg: ServerMessage) => void>;
|
messageListeners: Set<(msg: ServerMessage) => void>;
|
||||||
exitListeners: Set<(info: TerminalExitInfo) => void>;
|
exitListeners: Set<(info: TerminalExitInfo) => void>;
|
||||||
@@ -169,6 +171,7 @@ export function createWorkerTerminalManager(
|
|||||||
const record: WorkerTerminalRecord = {
|
const record: WorkerTerminalRecord = {
|
||||||
info: cloneTerminalInfo(input.info),
|
info: cloneTerminalInfo(input.info),
|
||||||
state: input.state,
|
state: input.state,
|
||||||
|
inputModeTracker: new TerminalInputModeTracker(),
|
||||||
exitInfo: null,
|
exitInfo: null,
|
||||||
messageListeners: new Set(),
|
messageListeners: new Set(),
|
||||||
exitListeners: new Set(),
|
exitListeners: new Set(),
|
||||||
@@ -247,6 +250,9 @@ export function createWorkerTerminalManager(
|
|||||||
revision: 0,
|
revision: 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
getReplayPreamble(): string {
|
||||||
|
return record.inputModeTracker.getPreamble();
|
||||||
|
},
|
||||||
getTitle(): string | undefined {
|
getTitle(): string | undefined {
|
||||||
return record.info.title;
|
return record.info.title;
|
||||||
},
|
},
|
||||||
@@ -302,6 +308,9 @@ export function createWorkerTerminalManager(
|
|||||||
if (message.message.type === "snapshot") {
|
if (message.message.type === "snapshot") {
|
||||||
record.state = message.message.state;
|
record.state = message.message.state;
|
||||||
}
|
}
|
||||||
|
if (message.message.type === "output") {
|
||||||
|
record.inputModeTracker.feed(message.message.data);
|
||||||
|
}
|
||||||
for (const listener of Array.from(record.messageListeners)) {
|
for (const listener of Array.from(record.messageListeners)) {
|
||||||
listener(message.message);
|
listener(message.message);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user