diff --git a/app/src-tauri/src/web_terminal/terminal.html b/app/src-tauri/src/web_terminal/terminal.html index 8e6259a..ba266ba 100644 --- a/app/src-tauri/src/web_terminal/terminal.html +++ b/app/src-tauri/src/web_terminal/terminal.html @@ -757,7 +757,13 @@ sessionType === 'claude' ) { sendTerminalInput('\x1b\r'); - return false; // xterm must not also send a bare CR, which submits + // `preventDefault()` is what stops the submit, not the `return false`. + // xterm's `_keyDown` returns before setting `_keyDownHandled`, so + // `_keyPress` still fires and emits a bare CR for Enter — inserting the + // newline and then submitting the prompt anyway. See the same comment + // in TerminalView.tsx. + e.preventDefault(); + return false; } return true; }); diff --git a/app/src/components/terminal/TerminalView.test.tsx b/app/src/components/terminal/TerminalView.test.tsx index 900e388..42284d0 100644 --- a/app/src/components/terminal/TerminalView.test.tsx +++ b/app/src/components/terminal/TerminalView.test.tsx @@ -137,19 +137,36 @@ afterEach(() => { }); describe("TerminalView — Shift+Enter", () => { - it("sends ESC+CR and nothing else in a Claude session", () => { + it("sends ESC+CR and cancels the keydown, so no bare CR follows", () => { + // **The cancel is the load-bearing half, and this test could not see it.** + // + // Returning `false` from xterm's custom key handler does not cancel the + // event: `_keyDown` returns before setting `_keyDownHandled`, so + // `_keyPress` still runs and emits a bare CR for Enter's charCode 13. In a + // real browser that submitted the prompt straight after inserting the + // newline. jsdom never synthesizes the follow-up keypress, so the old + // `expect(sent()).not.toContain("\r")` assertion below could not fail no + // matter what the code did — it was named for a behaviour it could not + // exercise. + // + // Asserting `defaultPrevented` pins the actual mechanism that stops the + // keypress, which is a property jsdom *can* observe. const { container } = mountSession("claude"); - fireEvent.keyDown(helperTextarea(container), { + const event = new KeyboardEvent("keydown", { key: "Enter", keyCode: 13, shiftKey: true, + bubbles: true, + cancelable: true, }); + helperTextarea(container).dispatchEvent(event); // The bytes `/terminal-setup` installs for every other editor. expect(sent()).toEqual(["\x1b\r"]); - // And specifically not the bare CR that would have submitted the prompt. expect(sent()).not.toContain("\r"); + // Without this, the browser fires keypress and xterm submits. + expect(event.defaultPrevented).toBe(true); }); it("leaves a plain Enter alone", () => { diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index d9d1011..72d6ae0 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -414,7 +414,19 @@ export default function TerminalView({ sessionId, active }: Props) { sessionTypeRef.current === "claude" ) { sendInput(sessionId, "\x1b\r"); - return false; // xterm must not also send a bare CR, which submits + // **`preventDefault()` is what stops the submit, not the `return false`.** + // + // xterm's `_keyDown` returns the instant a custom handler says `false` + // — *before* it sets `_keyDownHandled` and before it cancels the event. + // `_keyPress` then checks that same flag, finds it still false, and + // emits a bare CR for Enter's charCode 13. So returning `false` alone + // sent ESC+CR *and* a submit: the newline was inserted and the + // half-written prompt went to Claude with a stray blank line in it. + // Cancelling the keydown is what stops the browser firing keypress at + // all. Verified in Chromium; jsdom never synthesizes the follow-up + // keypress, which is why the unit test could not see this. + event.preventDefault(); + return false; } return true; });