From d086a7f198085dd8307e7e1deadef342f40290d7 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 6 Nov 2026 15:06:09 +0700 Subject: [PATCH] feat(terminal): Shift+Enter adds a new line in claude/codex prompts xterm sends a plain CR for Shift+Enter, so the prompt was submitted. Send Ctrl+J (LF) instead: both prompts take it as a new line, a shell still runs the line. --- client/src/components/run/TerminalView.tsx | 9 +++++++++ .../run/__tests__/TerminalView.test.tsx | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 15634a94..89e0cc1b 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -634,6 +634,15 @@ export const TerminalView = memo(function TerminalView({ term.input(altArrow, true); return false; } + // Shift+Enter: xterm sends a plain CR, so claude/codex SUBMIT the + // prompt. Send Ctrl+J (LF) instead — both prompts take it as a new + // line (Option+Enter is the other way, awkward on a Mac), while a + // shell still runs the line, as Enter did. + if (ev.key === "Enter" && ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey) { + ev.preventDefault(); + term.input("\n", true); + return false; + } if (ev.key === "Tab" && tabMovesRef.current) return false; if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault(); return true; diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index 0962ba47..5e5c371b 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -634,6 +634,21 @@ describe("TerminalView", () => { ).toBe(true); }); + it("sends Shift+Enter as Ctrl+J, a new line in claude's prompt, not a submit", () => { + inputMock.mockClear(); + render(); + const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean; + const ev = new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, cancelable: true }); + expect(handler(ev)).toBe(false); + expect(ev.defaultPrevented).toBe(true); + expect(inputMock).toHaveBeenCalledWith("\n", true); + inputMock.mockClear(); + // Plain Enter and Alt+Enter stay xterm's own. + expect(handler(new KeyboardEvent("keydown", { key: "Enter" }))).toBe(true); + expect(handler(new KeyboardEvent("keydown", { key: "Enter", altKey: true }))).toBe(true); + expect(inputMock).not.toHaveBeenCalled(); + }); + describe("size reporting (tmux must never be wider than the terminal)", () => { // A tmux pane wider than xterm wraps each over-long row's tail onto the // next row's left margin, and tmux never repaints a row it believes