diff --git a/cli.py b/cli.py index bf0c3610fc8..d3097863bfd 100644 --- a/cli.py +++ b/cli.py @@ -2481,8 +2481,9 @@ _TERMINAL_INPUT_MODE_RESET_SEQ = ( def _preserve_ctrl_enter_newline() -> bool: """Detect environments where Ctrl+Enter must produce a newline, not submit. - Native Windows, WSL, SSH sessions, and Windows Terminal all send Ctrl+Enter - as bare LF (c-j). On those terminals c-j must NOT be bound to submit; + Windows Terminal, WSL, SSH sessions, Ghostty, and some modern terminals + deliver Ctrl+Enter/Ctrl+J as bare LF (c-j). On those terminals c-j must + NOT be bound to submit; binding it to submit makes Ctrl+Enter (intended as 'newline like Alt+Enter') submit instead. Local POSIX TTYs that deliver Enter as LF (docker exec, some thin PTYs without SSH) still need c-j bound to submit, so we keep @@ -2496,6 +2497,12 @@ def _preserve_ctrl_enter_newline() -> bool: return True if os.environ.get("WT_SESSION"): return True + if os.environ.get("GHOSTTY_RESOURCES_DIR") or os.environ.get("GHOSTTY_BIN_DIR"): + return True + if os.environ.get("TERM", "").lower() == "xterm-ghostty": + return True + if os.environ.get("TERM_PROGRAM", "").lower() == "ghostty": + return True if "microsoft" in os.environ.get("WSL_DISTRO_NAME", "").lower(): return True # WSL detection — env vars can be scrubbed under sudo, also peek /proc. @@ -2516,7 +2523,7 @@ def _bind_prompt_submit_keys(kb, handler) -> None: some thin PTYs (docker exec, certain SSH flavors) deliver Enter as LF instead of CR — without this, Enter appears dead on those terminals. - Exception: on Windows, WSL, SSH sessions, and Windows Terminal, + Exception: on Windows, WSL, SSH sessions, Windows Terminal, and Ghostty, c-j is the wire encoding of Ctrl+Enter (a distinct keystroke from plain Enter / c-m). We leave c-j unbound there so the c-j newline handler registered separately can fire — giving the user an diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index 67004384ae7..105ec31f5b6 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -180,9 +180,9 @@ class TestPromptToolkitTerminalCompatibility: def test_lf_enter_binds_to_submit_handler_posix(self): """Some thin PTYs deliver Enter as LF/c-j instead of CR/enter. - On a bare local POSIX TTY (no SSH/WSL/WT) we keep c-j → submit so + On a bare local POSIX TTY (no SSH/WSL/WT/Ghostty) we keep c-j → submit so Enter works on thin PTYs (docker exec, certain ssh configurations). - On Windows, WSL, SSH sessions, and Windows Terminal we leave c-j + On Windows, WSL, SSH sessions, Windows Terminal, and Ghostty we leave c-j unbound here so it can be used as the Ctrl+Enter newline keystroke without conflicting with submit. See issue #22379. """ @@ -217,6 +217,17 @@ class TestPromptToolkitTerminalCompatibility: assert bindings[("c-m",)] is submit_handler assert ("c-j",) not in bindings + # Ghostty through tmux: TERM_PROGRAM is tmux, but Ghostty exports a + # stable env marker. Keep c-j free so Ctrl+J inserts a newline. + with _patch.object(_sys, "platform", "linux"), \ + _patch.dict(_os.environ, {"TERM": "tmux-256color", "TERM_PROGRAM": "tmux", "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty"}, clear=True), \ + _patch("builtins.open", side_effect=OSError("no /proc")): + kb = KeyBindings() + _bind_prompt_submit_keys(kb, submit_handler) + bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings} + assert bindings[("c-m",)] is submit_handler + assert ("c-j",) not in bindings + # Windows: only enter submits; c-j is free for the newline binding # added separately in the prompt setup. with _patch.object(_sys, "platform", "win32"): diff --git a/tests/cli/test_ctrl_enter_newline.py b/tests/cli/test_ctrl_enter_newline.py index 57056ab0e18..58cdd7c26eb 100644 --- a/tests/cli/test_ctrl_enter_newline.py +++ b/tests/cli/test_ctrl_enter_newline.py @@ -51,8 +51,20 @@ def test_windows_terminal_session_preserves_newline(): assert cli_mod._preserve_ctrl_enter_newline() is True +def test_ghostty_tmux_session_preserves_ctrl_j_newline(): + """Ghostty-inherited env survives tmux even when TERM_PROGRAM becomes tmux.""" + import cli as cli_mod + with patch.object(sys, "platform", "linux"): + with patch.dict( + os.environ, + {"TERM": "tmux-256color", "TERM_PROGRAM": "tmux", "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty"}, + clear=True, + ): + assert cli_mod._preserve_ctrl_enter_newline() is True + + def test_pure_local_linux_does_not_preserve(): - """A bare local Linux TTY (no SSH/WSL/WT) keeps c-j → submit so docker exec + """A bare local Linux TTY (no SSH/WSL/WT/Ghostty) keeps c-j → submit so docker exec style Enter-as-LF stays usable.""" import cli as cli_mod # Stub out /proc reads — those are the WSL fallback signal. diff --git a/ui-tui/src/__tests__/textInputPassThrough.test.ts b/ui-tui/src/__tests__/textInputPassThrough.test.ts index 5988580f9b9..1fb47779b0f 100644 --- a/ui-tui/src/__tests__/textInputPassThrough.test.ts +++ b/ui-tui/src/__tests__/textInputPassThrough.test.ts @@ -1,11 +1,27 @@ import { describe, expect, it } from 'vitest' -import { shouldPassThroughToGlobalHandler } from '../components/textInput.js' +import { shouldPassThroughToGlobalHandler, shouldPreserveCtrlJNewline } from '../components/textInput.js' import { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } from '../lib/platform.js' const key = (overrides: Record = {}) => ({ ctrl: false, meta: false, ...overrides }) as any +describe('shouldPreserveCtrlJNewline', () => { + it('preserves Ctrl+J as newline in Ghostty even when tmux masks TERM/TERM_PROGRAM', () => { + expect( + shouldPreserveCtrlJNewline({ + GHOSTTY_RESOURCES_DIR: '/usr/share/ghostty', + TERM: 'tmux-256color', + TERM_PROGRAM: 'tmux' + }) + ).toBe(true) + }) + + it('keeps bare local POSIX LF-compatible prompts submitting on Ctrl+J', () => { + expect(shouldPreserveCtrlJNewline({ TERM: 'xterm-256color' })).toBe(false) + }) +}) + describe('shouldPassThroughToGlobalHandler', () => { it('passes through the configured voice shortcut while composer is focused', () => { expect( diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 2e117a0a007..564484999f6 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -36,6 +36,7 @@ const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') const FRAME_BATCH_MS = 16 const MULTI_CLICK_MS = 500 +type MinimalEnv = Record const invert = (s: string) => INV + s + INV_OFF const dim = (s: string) => DIM + s + DIM_OFF @@ -122,6 +123,30 @@ export function applyPrintableInsert( export const shouldRouteMultiCharInputAsPaste = (text: string): boolean => text.includes('\n') +export function shouldPreserveCtrlJNewline(env: MinimalEnv = process.env): boolean { + if (env.WT_SESSION) { + return true + } + + if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) { + return true + } + + if (env.GHOSTTY_RESOURCES_DIR || env.GHOSTTY_BIN_DIR) { + return true + } + + if ((env.TERM ?? '').toLowerCase() === 'xterm-ghostty') { + return true + } + + if ((env.TERM_PROGRAM ?? '').toLowerCase() === 'ghostty') { + return true + } + + return (env.WSL_DISTRO_NAME ?? '').toLowerCase().includes('microsoft') +} + function prevPos(s: string, p: number) { const pos = snapPos(s, p) let prev = 0 @@ -943,7 +968,10 @@ export function TextInput({ if (k.return) { flushKeyBurst() - if (k.shift || k.ctrl || (isMac ? isActionMod(k) : k.meta)) { + const sequence = (event.keypress as { sequence?: string }).sequence + const preserveBareLineFeed = shouldPreserveCtrlJNewline() && sequence === '\n' + + if (k.shift || k.ctrl || preserveBareLineFeed || (isMac ? isActionMod(k) : k.meta)) { commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1) } else { cbSubmit.current?.(vRef.current)