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 type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@server/shared/messages";
|
||||
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
@@ -129,6 +130,7 @@ interface TerminalEmulatorProps {
|
||||
meta: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
|
||||
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
@@ -194,6 +196,7 @@ export default function TerminalEmulator({
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
onRendererReadyChange,
|
||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||
focusRequestToken = 0,
|
||||
@@ -219,12 +222,14 @@ export default function TerminalEmulator({
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
});
|
||||
mountCallbacksRef.current = {
|
||||
onInput,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
};
|
||||
const initialSnapshotRef = useRef(initialSnapshot);
|
||||
initialSnapshotRef.current = initialSnapshot;
|
||||
@@ -444,10 +449,11 @@ export default function TerminalEmulator({
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
onOpenExternalUrl: openExternalUrl,
|
||||
},
|
||||
});
|
||||
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
||||
}, [onInput, onInputModeChange, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
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 { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
||||
@@ -188,6 +189,10 @@ export function TerminalPane({
|
||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
|
||||
const terminalIdRef = useRef<string>(terminalId);
|
||||
const inputModeRef = useRef<TerminalInputModeState>({
|
||||
kittyKeyboardFlags: 0,
|
||||
win32InputMode: false,
|
||||
});
|
||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
||||
@@ -195,6 +200,10 @@ export function TerminalPane({
|
||||
|
||||
useEffect(() => {
|
||||
terminalIdRef.current = terminalId;
|
||||
inputModeRef.current = {
|
||||
kittyKeyboardFlags: 0,
|
||||
win32InputMode: false,
|
||||
};
|
||||
}, [terminalId]);
|
||||
|
||||
const requestTerminalFocus = useCallback(() => {
|
||||
@@ -413,7 +422,9 @@ export function TerminalPane({
|
||||
return true;
|
||||
}
|
||||
|
||||
const encoded = encodeTerminalKeyInput(entry.input);
|
||||
const encoded = encodeTerminalKeyInput(entry.input, {
|
||||
inputMode: inputModeRef.current,
|
||||
});
|
||||
if (encoded.length === 0) {
|
||||
return true;
|
||||
}
|
||||
@@ -595,6 +606,10 @@ export function TerminalPane({
|
||||
clearPendingModifiers();
|
||||
}, [clearPendingModifiers]);
|
||||
|
||||
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
|
||||
inputModeRef.current = state;
|
||||
}, []);
|
||||
|
||||
const toggleModifier = useCallback(
|
||||
(modifier: keyof ModifierState) => {
|
||||
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
||||
@@ -674,6 +689,7 @@ export function TerminalPane({
|
||||
onInput={handleTerminalData}
|
||||
onResize={handleTerminalResize}
|
||||
onTerminalKey={handleTerminalKey}
|
||||
onInputModeChange={handleInputModeChange}
|
||||
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
||||
pendingModifiers={modifiers}
|
||||
focusRequestToken={focusRequestToken}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("terminal key helpers", () => {
|
||||
).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(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "Enter",
|
||||
@@ -105,10 +105,10 @@ describe("terminal key helpers", () => {
|
||||
metaKey: 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(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "Enter",
|
||||
@@ -117,6 +117,7 @@ describe("terminal key helpers", () => {
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
enhancedInputActive: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
@@ -127,6 +128,7 @@ describe("terminal key helpers", () => {
|
||||
altKey: true,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
enhancedInputActive: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
@@ -137,6 +139,7 @@ describe("terminal key helpers", () => {
|
||||
altKey: false,
|
||||
metaKey: true,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
enhancedInputActive: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -89,15 +89,13 @@ export function shouldInterceptDomTerminalKey(args: {
|
||||
altKey: boolean;
|
||||
metaKey: boolean;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
enhancedInputActive?: boolean;
|
||||
}): boolean {
|
||||
if (hasPendingTerminalModifiers(args.pendingModifiers)) {
|
||||
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)) {
|
||||
return true;
|
||||
return Boolean(args.enhancedInputActive);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user