Fix Shift+Enter in terminal input modes (#999)

* Fix Shift+Enter terminal input modes

* Advertise terminal input capabilities
This commit is contained in:
Mohamed Boudra
2026-05-14 17:48:22 +08:00
committed by GitHub
parent eb7fa02ac8
commit e1ab779fe0
16 changed files with 702 additions and 21 deletions
@@ -1,5 +1,6 @@
import { page } from "@vitest/browser/context";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
vi.mock("@xterm/addon-webgl", () => ({
@@ -15,6 +16,14 @@ interface TerminalSize {
cols: number;
}
interface TerminalKeyRecord {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}
type BrowserTerminal = TerminalSize & {
refresh: (start: number, end: number) => void;
reset: () => void;
@@ -26,6 +35,8 @@ interface MountedTerminal {
runtime: TerminalEmulatorRuntime;
inputs: string[];
sizes: TerminalSize[];
terminalKeys: TerminalKeyRecord[];
inputModeChanges: TerminalInputModeState[];
}
const mountedTerminals: MountedTerminal[] = [];
@@ -67,6 +78,8 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
const sizes: TerminalSize[] = [];
const inputs: string[] = [];
const terminalKeys: TerminalKeyRecord[] = [];
const inputModeChanges: TerminalInputModeState[] = [];
const runtime = new TerminalEmulatorRuntime();
runtime.setCallbacks({
callbacks: {
@@ -76,6 +89,12 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
onResize: (size) => {
sizes.push(size);
},
onTerminalKey: (key) => {
terminalKeys.push(key);
},
onInputModeChange: (state) => {
inputModeChanges.push(state);
},
},
});
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);
return mounted;
}
@@ -110,6 +129,32 @@ function getBrowserTerminal(): BrowserTerminal {
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(() => {
for (const mounted of mountedTerminals.splice(0)) {
mounted.runtime.unmount();
@@ -162,6 +207,73 @@ describe("terminal emulator runtime in a real browser", () => {
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([
{ name: "DA1", bytes: "\x1b[c" },
{ name: "DA1-zero", bytes: "\x1b[0c" },
@@ -176,6 +176,38 @@ describe("terminal-emulator-runtime", () => {
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", () => {
const { runtime, writeCallbacks } = createRuntimeWithTerminal();
const committed: string[] = [];
@@ -8,6 +8,11 @@ import { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
import { Terminal, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import {
type TerminalInputModeState,
TerminalInputModeTracker,
terminalInputModeStatesEqual,
} from "@server/shared/terminal-input-mode";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
@@ -37,6 +42,7 @@ export interface TerminalEmulatorRuntimeCallbacks {
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onOpenExternalUrl?: (url: string) => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
}
interface TerminalEmulatorRuntimeDisposables {
@@ -125,6 +131,8 @@ export class TerminalEmulatorRuntime {
private inFlightOutputOperation: TerminalOutputOperation | null = null;
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
private suppressInput = false;
private readonly inputModeTracker = new TerminalInputModeTracker();
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
private handleVisibilityRestore = (): void => {
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
@@ -152,6 +160,8 @@ export class TerminalEmulatorRuntime {
input.host.innerHTML = "";
this.lastSize = null;
this.inputModeTracker.reset();
this.emitInputModeChange();
const terminal = new Terminal({
allowProposedApi: true,
@@ -353,6 +363,7 @@ export class TerminalEmulatorRuntime {
altKey: event.altKey,
metaKey: event.metaKey,
pendingModifiers: this.pendingModifiers,
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
})
) {
return true;
@@ -606,6 +617,8 @@ export class TerminalEmulatorRuntime {
this.fitAndEmitResize = null;
this.lastSize = null;
this.suppressInput = false;
this.inputModeTracker.reset();
this.emitInputModeChange();
}
private processOutputQueue(): void {
@@ -640,12 +653,16 @@ export class TerminalEmulatorRuntime {
};
if (operation.type === "clear") {
this.inputModeTracker.reset();
this.emitInputModeChange();
terminal.reset();
finalizeOperation(operation);
return;
}
if (operation.type === "snapshot") {
this.inputModeTracker.reset();
this.emitInputModeChange();
try {
if (
typeof operation.cols === "number" &&
@@ -661,6 +678,12 @@ export class TerminalEmulatorRuntime {
}
const text = operation.text;
if (operation.type === "write") {
const result = this.inputModeTracker.feed(text);
if (result.changed) {
this.emitInputModeChange();
}
}
this.inFlightOutputOperationTimeout = setTimeout(() => {
finalizeOperation(operation);
}, OUTPUT_OPERATION_TIMEOUT_MS);
@@ -682,6 +705,15 @@ export class TerminalEmulatorRuntime {
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 {
const documentElement = document.documentElement;
const body = document.body;