fix(cli): submit LF enter in thin PTYs (#20896)

This commit is contained in:
brooklyn! 2026-05-06 13:51:13 -07:00 committed by GitHub
parent d8b85bfd1c
commit 5044e1cbf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 48 additions and 7 deletions

25
cli.py
View file

@ -1774,6 +1774,20 @@ _TERMINAL_INPUT_MODE_RESET_SEQ = (
) )
def _bind_prompt_submit_keys(kb, handler) -> None:
"""Bind both CR and LF terminal Enter forms to the submit handler."""
for key in ("enter", "c-j"):
kb.add(key)(handler)
def _disable_prompt_toolkit_cpr_warning(app) -> None:
"""Let prompt_toolkit fall back from CPR without printing into the prompt."""
try:
app.renderer.cpr_not_supported_callback = None
except Exception:
pass
def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]: def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]:
"""Strip leaked terminal control-response sequences from user input. """Strip leaked terminal control-response sequences from user input.
@ -10338,7 +10352,6 @@ class HermesCLI:
# Key bindings for the input area # Key bindings for the input area
kb = KeyBindings() kb = KeyBindings()
@kb.add('enter')
def handle_enter(event): def handle_enter(event):
"""Handle Enter key - submit input. """Handle Enter key - submit input.
@ -10497,17 +10510,14 @@ class HermesCLI:
else: else:
self._pending_input.put(payload) self._pending_input.put(payload)
event.app.current_buffer.reset(append_to_history=True) event.app.current_buffer.reset(append_to_history=True)
_bind_prompt_submit_keys(kb, handle_enter)
@kb.add('escape', 'enter') @kb.add('escape', 'enter')
def handle_alt_enter(event): def handle_alt_enter(event):
"""Alt+Enter inserts a newline for multi-line input.""" """Alt+Enter inserts a newline for multi-line input."""
event.current_buffer.insert_text('\n') event.current_buffer.insert_text('\n')
@kb.add('c-j')
def handle_ctrl_enter(event):
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
event.current_buffer.insert_text('\n')
# VSCode/Cursor bind Ctrl+G to "Find Next" at the editor level, so # VSCode/Cursor bind Ctrl+G to "Find Next" at the editor level, so
# the keystroke never reaches the embedded terminal. Alt+G is unbound # the keystroke never reaches the embedded terminal. Alt+G is unbound
# in those IDEs and arrives here as ('escape', 'g') — register it as # in those IDEs and arrives here as ('escape', 'g') — register it as
@ -11106,7 +11116,7 @@ class HermesCLI:
def get_prompt(): def get_prompt():
return cli_ref._get_tui_prompt_fragments() return cli_ref._get_tui_prompt_fragments()
# Create the input area with multiline (shift+enter), autocomplete, and paste handling # Create the input area with multiline (Alt+Enter), autocomplete, and paste handling
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
@ -11848,6 +11858,7 @@ class HermesCLI:
mouse_support=False, mouse_support=False,
**({'cursor': _STEADY_CURSOR} if _STEADY_CURSOR is not None else {}), **({'cursor': _STEADY_CURSOR} if _STEADY_CURSOR is not None else {}),
) )
_disable_prompt_toolkit_cpr_warning(app)
self._app = app # Store reference for clarify_callback self._app = app # Store reference for clarify_callback
# ── Fix ghost status-bar lines on terminal resize ────────────── # ── Fix ghost status-bar lines on terminal resize ──────────────

View file

@ -3,6 +3,7 @@ that only manifest at runtime (not in mocked unit tests)."""
import os import os
import sys import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@ -161,6 +162,35 @@ class TestBusyInputMode:
assert cli._pending_input.empty() assert cli._pending_input.empty()
class TestPromptToolkitTerminalCompatibility:
def test_lf_enter_binds_to_submit_handler(self):
"""Some thin PTYs deliver Enter as LF/c-j instead of CR/enter."""
from prompt_toolkit.key_binding import KeyBindings
from cli import _bind_prompt_submit_keys
kb = KeyBindings()
def submit_handler(event):
return None
_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 bindings[("c-j",)] is submit_handler
def test_cpr_warning_callback_is_disabled(self):
from cli import _disable_prompt_toolkit_cpr_warning
renderer = SimpleNamespace(cpr_not_supported_callback=lambda: None)
app = SimpleNamespace(renderer=renderer)
_disable_prompt_toolkit_cpr_warning(app)
assert renderer.cpr_not_supported_callback is None
class TestSingleQueryState: class TestSingleQueryState:
def test_voice_and_interrupt_state_initialized_before_run(self): def test_voice_and_interrupt_state_initialized_before_run(self):
"""Single-query mode calls chat() without going through run().""" """Single-query mode calls chat() without going through run()."""