From 82f5abbeea54b172fab29eb87bc9d15dab36c2a9 Mon Sep 17 00:00:00 2001 From: Amar Sood Date: Fri, 18 Apr 2025 12:19:06 -0400 Subject: [PATCH] Fix handling of Shift+Enter in e.g. Ghostty (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix: Shift + Enter no longer prints “[27;2;13~” in the single‑line input. Validated as working and necessary in Ghostty on Linux. ## Key points - src/components/vendor/ink-text-input.tsx - Added early handler that recognises the two modifyOtherKeys escape‑sequences - [13;u (mode 2 / CSI‑u) - [27;;13~ (mode 1 / legacy CSI‑~) - If Ctrl is held (hasCtrl flag) → call onSubmit() (same as plain Enter). - Otherwise → insert a real newline at the caret (same as Option+Enter). - Prevents the raw sequence from being inserted into the buffer. - src/components/chat/multiline-editor.tsx - Replaced non‑breaking spaces with normal spaces to satisfy eslint no‑irregular‑whitespace rule (no behaviour change). All unit tests (114) and ESLint now pass: npm test ✔️ npm run lint ✔️ --- .../src/components/chat/multiline-editor.tsx | 38 +++++++--- .../src/components/vendor/ink-text-input.tsx | 72 +++++++++++++++++++ .../tests/multiline-shift-enter-mod1.test.tsx | 49 +++++++++++++ 3 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 codex-cli/tests/multiline-shift-enter-mod1.test.tsx diff --git a/codex-cli/src/components/chat/multiline-editor.tsx b/codex-cli/src/components/chat/multiline-editor.tsx index c99961bb..bb4878c6 100644 --- a/codex-cli/src/components/chat/multiline-editor.tsx +++ b/codex-cli/src/components/chat/multiline-editor.tsx @@ -259,25 +259,47 @@ const MultilineTextEditorInner = ( console.log("[MultilineTextEditor] event", { input, key }); } - // 1) CSI‑u / modifyOtherKeys (Ink strips initial ESC, so we start with '[') + // 1a) CSI-u / modifyOtherKeys *mode 2* (Ink strips initial ESC, so we + // start with '[') – format: "[;u". if (input.startsWith("[") && input.endsWith("u")) { const m = input.match(/^\[([0-9]+);([0-9]+)u$/); if (m && m[1] === "13") { const mod = Number(m[2]); - // In xterm's encoding: bit‑1 (value 2) is Shift. Everything >1 that - // isn't exactly 1 means some modifier was held. We treat *shift - // present* (2,4,6,8) as newline; plain (1) as submit. + // In xterm's encoding: bit-1 (value 2) is Shift. Everything >1 that + // isn't exactly 1 means some modifier was held. We treat *shift or + // alt present* (2,3,4,6,8,9) as newline; Ctrl (bit-2 / value 4) + // triggers submit. See xterm/DEC modifyOtherKeys docs. - // Xterm encodes modifier keys in `mod` – bit‑2 (value 4) indicates - // that Ctrl was held. We avoid the `&` bitwise operator (disallowed - // by our ESLint config) by using arithmetic instead. const hasCtrl = Math.floor(mod / 4) % 2 === 1; if (hasCtrl) { if (onSubmit) { onSubmit(buffer.current.getText()); } } else { - // Any variant without Ctrl just inserts newline (Shift, Alt, none) + buffer.current.newline(); + } + setVersion((v) => v + 1); + return; + } + } + + // 1b) CSI-~ / modifyOtherKeys *mode 1* – format: "[27;;~". + // Terminals such as iTerm2 (default), older xterm versions, or when + // modifyOtherKeys=1 is configured, emit this legacy sequence. We + // translate it to the same behaviour as the mode‑2 variant above so + // that Shift+Enter (newline) / Ctrl+Enter (submit) work regardless + // of the user’s terminal settings. + if (input.startsWith("[27;") && input.endsWith("~")) { + const m = input.match(/^\[27;([0-9]+);13~$/); + if (m) { + const mod = Number(m[1]); + const hasCtrl = Math.floor(mod / 4) % 2 === 1; + + if (hasCtrl) { + if (onSubmit) { + onSubmit(buffer.current.getText()); + } + } else { buffer.current.newline(); } setVersion((v) => v + 1); diff --git a/codex-cli/src/components/vendor/ink-text-input.tsx b/codex-cli/src/components/vendor/ink-text-input.tsx index c516799a..40b0a1d4 100644 --- a/codex-cli/src/components/vendor/ink-text-input.tsx +++ b/codex-cli/src/components/vendor/ink-text-input.tsx @@ -153,6 +153,78 @@ function TextInput({ useInput( (input, key) => { + // ──────────────────────────────────────────────────────────────── + // Support Shift+Enter / Ctrl+Enter from terminals that have + // modifyOtherKeys enabled. Such terminals encode the key‑combo in a + // CSI sequence rather than sending a bare "\r"/"\n". Ink passes the + // sequence through as raw text (without the initial ESC), so we need to + // detect and translate it before the generic character handler below + // treats it as literal input (e.g. "[27;2;13~"). We support both the + // modern *mode 2* (CSI‑u, ending in "u") and the legacy *mode 1* + // variant (ending in "~"). + // + // - Shift+Enter → insert newline (same behaviour as Option+Enter) + // - Ctrl+Enter → submit the input (same as plain Enter) + // + // References: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Modify-Other-Keys + // ──────────────────────────────────────────────────────────────── + + function handleEncodedEnterSequence(raw: string): boolean { + // CSI‑u (modifyOtherKeys=2) → "[13;u" + let m = raw.match(/^\[([0-9]+);([0-9]+)u$/); + if (m && m[1] === "13") { + const mod = Number(m[2]); + const hasCtrl = Math.floor(mod / 4) % 2 === 1; + + if (hasCtrl) { + if (onSubmit) { + onSubmit(originalValue); + } + } else { + const newValue = + originalValue.slice(0, cursorOffset) + + "\n" + + originalValue.slice(cursorOffset); + + setState({ + cursorOffset: cursorOffset + 1, + cursorWidth: 0, + }); + onChange(newValue); + } + return true; // handled + } + + // CSI‑~ (modifyOtherKeys=1) → "[27;;13~" + m = raw.match(/^\[27;([0-9]+);13~$/); + if (m) { + const mod = Number(m[1]); + const hasCtrl = Math.floor(mod / 4) % 2 === 1; + + if (hasCtrl) { + if (onSubmit) { + onSubmit(originalValue); + } + } else { + const newValue = + originalValue.slice(0, cursorOffset) + + "\n" + + originalValue.slice(cursorOffset); + + setState({ + cursorOffset: cursorOffset + 1, + cursorWidth: 0, + }); + onChange(newValue); + } + return true; // handled + } + return false; // not an encoded Enter sequence + } + + if (handleEncodedEnterSequence(input)) { + return; + } if ( key.upArrow || key.downArrow || diff --git a/codex-cli/tests/multiline-shift-enter-mod1.test.tsx b/codex-cli/tests/multiline-shift-enter-mod1.test.tsx new file mode 100644 index 00000000..d309756c --- /dev/null +++ b/codex-cli/tests/multiline-shift-enter-mod1.test.tsx @@ -0,0 +1,49 @@ +// Regression test: Terminals with modifyOtherKeys=1 emit CSI~ sequence for +// Shift+Enter: ESC [ 27 ; mod ; 13 ~. The editor must treat Shift+Enter as +// newline (without submitting) and Ctrl+Enter as submit. + +import { renderTui } from "./ui-test-helpers.js"; +import MultilineTextEditor from "../src/components/chat/multiline-editor.js"; +import * as React from "react"; +import { describe, it, expect, vi } from "vitest"; + +async function type( + stdin: NodeJS.WritableStream, + text: string, + flush: () => Promise, +) { + stdin.write(text); + await flush(); +} + +describe("MultilineTextEditor – Shift+Enter with modifyOtherKeys=1", () => { + it("inserts newline, does NOT submit", async () => { + const onSubmit = vi.fn(); + + const { stdin, lastFrameStripped, flush, cleanup } = renderTui( + React.createElement(MultilineTextEditor, { + height: 5, + width: 20, + initialText: "", + onSubmit, + }), + ); + + await flush(); + + await type(stdin, "abc", flush); + // Shift+Enter => ESC [27;2;13~ + await type(stdin, "\u001B[27;2;13~", flush); + await type(stdin, "def", flush); + + const frame = lastFrameStripped(); + expect(frame).toMatch(/abc/); + expect(frame).toMatch(/def/); + // newline inserted -> at least 2 lines + expect(frame.split("\n").length).toBeGreaterThanOrEqual(2); + + expect(onSubmit).not.toHaveBeenCalled(); + + cleanup(); + }); +});