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