fix(terminal): send Esc+Return for Shift+Enter (#2164)

Shift+Enter went through xterm.js as a plain CR, so Claude Code and similar TUIs submitted the prompt instead of starting a new line. Terminal sessions and embedded Run terminals now send ESC CR, the same as Alt+Enter, without needing /terminal-setup. In a plain shell Shift+Enter no longer runs the command; Enter is unchanged, and Shift+Enter still restarts an exited session.

Closes #1244

Co-authored-by: mansur-akhmadi <[email protected]>
Цей коміт міститься в:
Mansur Akhmadiтаmansur-akhmadi authored and GitHub committed 2026-11-09 00:44:24 +08:00
1 джерело 68782a7220
коміт 80d438098c
7 files changed
+124 -6

No files matched your search

+44
Переглянути файл
@@ -63,4 +63,48 @@ describe("terminal copy capture", () => {
dispose();
}
});
it.each([true, false])("sends Shift+Enter as Esc+Return with override shortcuts %s", (override) => {
mock.settings.terminal.overrideIdeShortcuts = override;
useKeymap.setState({ preset: "intellij-windows", overrides: {} });
const removeKeys = installTerminalKeys();
const removeDispatcher = installKeyboardDispatcher();
const textarea = document.createElement("textarea");
document.body.append(textarea);
const xterm = vi.fn();
textarea.addEventListener("keydown", xterm);
const press = (init: KeyboardEventInit, keyCode?: number) => {
const event = new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
bubbles: true,
cancelable: true,
...init,
});
if (keyCode !== undefined) Object.defineProperty(event, "keyCode", { value: keyCode });
textarea.dispatchEvent(event);
return event;
};
try {
expect(press({ shiftKey: true }).defaultPrevented).toBe(true);
expect(mock.session.send).toHaveBeenCalledExactlyOnceWith("\x1b\r");
expect(xterm).not.toHaveBeenCalled();
mock.session.send.mockClear();
// Plain Enter, other modifiers and an input method's Enter stay with xterm.
for (const init of [
{},
{ altKey: true },
{ ctrlKey: true, shiftKey: true },
{ shiftKey: true, isComposing: true },
])
press(init);
press({ shiftKey: true }, 229);
expect(mock.session.send).not.toHaveBeenCalled();
expect(xterm).toHaveBeenCalledTimes(5);
} finally {
textarea.remove();
removeDispatcher();
removeKeys();
}
});
});
+23
Переглянути файл
@@ -8,6 +8,7 @@ import { usePlatform } from "@/platform/platformInfo";
const terminal = vi.hoisted(() => ({
selection: "selected run output",
handler: (_e: KeyboardEvent): boolean => true,
input: vi.fn(),
}));
vi.mock("@xterm/xterm", () => ({
Terminal: class {
@@ -25,6 +26,9 @@ vi.mock("@xterm/xterm", () => ({
terminal.handler = handler;
}
loadAddon() {}
input(data: string) {
terminal.input(data);
}
open(host: HTMLElement) {
this.textarea = document.createElement("textarea");
host.append(this.textarea);
@@ -121,3 +125,22 @@ it("delivers input-method commits to the owner on macOS as the terminal sessions
expect(onData).toHaveBeenCalledWith("1");
term.dispose();
});
it("sends Shift+Enter in embedded Run terminals as Esc+Return", () => {
const host = document.createElement("div");
document.body.append(host);
const onData = vi.fn();
const term = createEmbeddedTerminal(host, { onData, onResize: vi.fn() });
try {
const shiftEnter = new KeyboardEvent("keydown", { key: "Enter", code: "Enter", shiftKey: true, cancelable: true });
expect(terminal.handler(shiftEnter)).toBe(false);
expect(shiftEnter.defaultPrevented).toBe(true);
expect(terminal.handler(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", shiftKey: true }))).toBe(false);
expect(terminal.input).toHaveBeenCalledExactlyOnceWith("\x1b\r");
for (const init of [{}, { altKey: true }, { ctrlKey: true, shiftKey: true }, { shiftKey: true, isComposing: true }])
expect(terminal.handler(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", ...init }))).toBe(true);
expect(terminal.input).toHaveBeenCalledOnce();
} finally {
term.dispose();
}
});
+9 -2
Переглянути файл
@@ -54,8 +54,15 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
theme: readTerminalTheme(),
});
term.attachCustomKeyEventHandler((e) => {
if (isImeKey(e) || terminalKeyAction(e, term.hasSelection()).kind !== "copy") return true;
if (e.type === "keydown") void navigator.clipboard?.writeText(term.getSelection()).catch(() => undefined);
if (isImeKey(e)) return true;
const action = terminalKeyAction(e, term.hasSelection());
if (action.kind === "send" && e.key === "Enter") {
if (e.type === "keydown") term.input(action.data);
} else if (action.kind === "copy") {
if (e.type === "keydown") void navigator.clipboard?.writeText(term.getSelection()).catch(() => undefined);
} else {
return true;
}
e.preventDefault();
return false;
});
+7 -3
Переглянути файл
@@ -1,9 +1,10 @@
/**
* Terminal keyboard handling that must run before the IDE keymap dispatcher
* (IDEA's "Override IDE shortcuts"): Ctrl+letter goes to the shell (Ctrl+R
* reverse search, Ctrl+D EOF…), plus the macOS conveniences ⌘C / ⌘V / ⌘K /
* ⌘W, ⌘F (find) and ⌘/⌥ arrow navigation, and Ctrl+Shift+C / V (copy / paste
* on Windows and Linux, where Ctrl+C also copies a selection and otherwise interrupts the shell).
* reverse search, Ctrl+D EOF…), Shift+Enter as Esc+Return, plus the macOS
* conveniences ⌘C / ⌘V / ⌘K / ⌘W, ⌘F (find) and ⌘/⌥ arrow navigation, and
* Ctrl+Shift+C / V (copy / paste on Windows and Linux, where Ctrl+C also
* copies a selection and otherwise interrupts the shell).
*
* With "Override IDE shortcuts" on, a Ctrl or Escape stroke still reaches the IDE when the
* keymap binds it to one of IDEA's terminal-allowed actions (Find Action, Go to
@@ -70,6 +71,9 @@ export function terminalKeyAction(e: KeyLike, hasSelection: boolean): TerminalKe
if (e.code === "Space") return { kind: "send", data: "\x00" };
return { kind: "pass" };
}
// Alt+Enter's Esc+Return, which Claude Code and similar TUIs take as a newline rather than
// submit; Claude Code's /terminal-setup binds the same in VS Code, Zed and Alacritty.
if (shift && !meta && !ctrl && !alt && e.key === "Enter") return { kind: "send", data: "\x1b\r" };
if (!meta && !ctrl && !alt && e.key === "Escape") {
// IDEA's Terminal.Escape: a selection is cleared first.
return hasSelection && !shift ? { kind: "clearSelection" } : { kind: "send", data: "\x1b" };
+22
Переглянути файл
@@ -156,6 +156,28 @@ describe("terminal start directory fallback", () => {
});
});
describe("terminal restart", () => {
beforeEach(() => vi.clearAllMocks());
it("restarts a session that failed to start on Shift+Enter's Esc+Return as on Enter", async () => {
ipc.commands.fsEntryKind.mockResolvedValue("dir");
ipc.commands.termSpawn.mockRejectedValueOnce(new Error("no shell")).mockRejectedValueOnce(new Error("no shell"));
const session = new TerminalSession({
key: "s-restart",
cwd: "/repo",
onChange: () => undefined,
onFocus: () => undefined,
});
await session.start();
expect(session.state.status).toBe("error");
session.send("\x1b");
expect(session.state.status).toBe("error");
session.send("\x1b\r");
await vi.waitFor(() => expect(ipc.commands.termSpawn).toHaveBeenCalledTimes(2));
session.dispose();
});
});
describe("terminal open", () => {
afterEach(() => vi.useRealTimers());
+2 -1
Переглянути файл
@@ -627,7 +627,8 @@ export class TerminalSession {
if (!data) return;
const status = this.snapshot.status;
if (status === "exited" || status === "error") {
if (data === "\r") this.restart();
// Shift+Enter sends Esc+Return.
if (data === "\r" || data === "\x1b\r") this.restart();
return;
}
if (this.ptyId === null) {
+17
Переглянути файл
@@ -195,6 +195,23 @@ describe("terminal keys", () => {
expect(terminalKeyAction(key({ key: "a" }), false)).toEqual({ kind: "pass" });
});
it("sends Shift+Enter as Esc+Return, a newline for Claude Code instead of submitting", () => {
expect(terminalKeyAction(key({ key: "Enter", code: "Enter", shiftKey: true }), false)).toEqual({
kind: "send",
data: "\x1b\r",
});
expect(terminalKeyAction(key({ key: "Enter", code: "NumpadEnter", shiftKey: true }), true)).toEqual({
kind: "send",
data: "\x1b\r",
});
expect(terminalKeyAction(key({ key: "Enter", code: "Enter" }), false)).toEqual({ kind: "pass" });
for (const mod of [{ ctrlKey: true }, { altKey: true }, { metaKey: true }]) {
expect(terminalKeyAction(key({ key: "Enter", code: "Enter", shiftKey: true, ...mod }), false)).toEqual({
kind: "pass",
});
}
});
it("clears a selection with Escape before sending it", () => {
expect(terminalKeyAction(key({ key: "Escape" }), true)).toEqual({ kind: "clearSelection" });
expect(terminalKeyAction(key({ key: "Escape", shiftKey: true }), true)).toEqual({ kind: "send", data: "\x1b" });