From 80d438098cbcb7bd66ffd0f58f9cf1a75db452bf Mon Sep 17 00:00:00 2001 From: Mansur Akhmadi Date: Sun, 8 Nov 2026 23:44:24 +0700 Subject: [PATCH] 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 <10739460+mansur-akhmadi@users.noreply.github.com> --- src/features/terminal/copyKeys.test.ts | 44 ++++++++++++++++++++++++++ src/features/terminal/embedded.test.ts | 23 ++++++++++++++ src/features/terminal/embedded.ts | 11 +++++-- src/features/terminal/keys.ts | 10 ++++-- src/features/terminal/session.test.ts | 22 +++++++++++++ src/features/terminal/session.ts | 3 +- src/features/terminal/terminal.test.ts | 17 ++++++++++ 7 files changed, 124 insertions(+), 6 deletions(-) diff --git a/src/features/terminal/copyKeys.test.ts b/src/features/terminal/copyKeys.test.ts index 3a21b1116..cb48d8c93 100644 --- a/src/features/terminal/copyKeys.test.ts +++ b/src/features/terminal/copyKeys.test.ts @@ -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(); + } + }); }); diff --git a/src/features/terminal/embedded.test.ts b/src/features/terminal/embedded.test.ts index 44e4fbc58..bfc16a5df 100644 --- a/src/features/terminal/embedded.test.ts +++ b/src/features/terminal/embedded.test.ts @@ -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(); + } +}); diff --git a/src/features/terminal/embedded.ts b/src/features/terminal/embedded.ts index ee81ef5ea..59ad09c94 100644 --- a/src/features/terminal/embedded.ts +++ b/src/features/terminal/embedded.ts @@ -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; }); diff --git a/src/features/terminal/keys.ts b/src/features/terminal/keys.ts index 3a6a0dbe3..340f4a7b4 100644 --- a/src/features/terminal/keys.ts +++ b/src/features/terminal/keys.ts @@ -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" }; diff --git a/src/features/terminal/session.test.ts b/src/features/terminal/session.test.ts index 9d010d97b..9bf9e1d3a 100644 --- a/src/features/terminal/session.test.ts +++ b/src/features/terminal/session.test.ts @@ -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()); diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 0a39013f3..6217babde 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -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) { diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 7bd17738b..bfb75e56a 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -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" });