дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
1 джерело
68782a7220
коміт
80d438098c
7 files changed
+124
-6
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in new issue
Заблокувати користувача