From 937fbb5cc192291a522109d67c0f2119eb42d108 Mon Sep 17 00:00:00 2001 From: Rufino Cabrera Date: Thu, 11 Jun 2026 10:47:59 +0200 Subject: [PATCH] Fix /compact y/N prompt echoing ^M on Enter Force cooked canonical TTY mode after prompt_toolkit and route /compact confirmation through read_repl_yes_no, which restores the terminal and strips stray carriage returns from the answer. --- src/cai/repl/commands/compact.py | 10 +++--- src/cai/repl/ui/tty_input.py | 52 +++++++++++++++++++++++++++++ src/cai/util/streaming.py | 26 +++++++++++++++ tests/repl/test_multiline_prompt.py | 11 ++++++ tests/repl/test_tty_input.py | 48 ++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 src/cai/repl/ui/tty_input.py create mode 100644 tests/repl/test_tty_input.py diff --git a/src/cai/repl/commands/compact.py b/src/cai/repl/commands/compact.py index 8dba1cfe..af2fc0e9 100644 --- a/src/cai/repl/commands/compact.py +++ b/src/cai/repl/commands/compact.py @@ -13,6 +13,7 @@ from rich.table import Table from rich.panel import Panel from cai.repl.commands.base import Command, register_command +from cai.repl.ui.tty_input import read_repl_yes_no from cai.sdk.agents.models.openai_chatcompletions import get_current_active_model from cai.repl.commands.model import ( get_all_predefined_models, @@ -572,11 +573,10 @@ class CompactCommand(Command): f"\n[#9aa0a6][CAI] Compact current conversation? [/]" f"[bold white]({msg_count} messages)[/bold white]" ) - confirm = console.input( - "[#9aa0a6][CAI] Compact conversation? [/][bold #00ff9d](y/N): [/]" - ) - - if confirm.lower() == "y": + if read_repl_yes_no( + console, + "[#9aa0a6][CAI] Compact conversation? [/][bold #00ff9d](y/N): [/]", + ): # Pass the detected agent name to _perform_compaction return self._perform_compaction(None, None, agent_name=agent_name) else: diff --git a/src/cai/repl/ui/tty_input.py b/src/cai/repl/ui/tty_input.py new file mode 100644 index 00000000..6e9dc63e --- /dev/null +++ b/src/cai/repl/ui/tty_input.py @@ -0,0 +1,52 @@ +"""Line-oriented prompts after prompt_toolkit or Rich Live (y/N, confirmations).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from rich.console import Console + + +def prepare_tty_for_line_input() -> None: + """Restore cooked TTY before ``input()`` / Rich ``console.input``.""" + from cai.util.streaming import ensure_cooked_tty, restore_terminal_state + + restore_terminal_state(emit_trailing_newline=False) + ensure_cooked_tty() + + +def normalize_repl_line(value: str) -> str: + """Strip carriage returns left over from non-canonical TTY reads.""" + return value.replace("\r", "").strip() + + +def read_repl_line( + console: Console, + prompt: str = "", + *, + markup: bool = True, +) -> str: + """Read one line with Rich prompt styling; safe after the main CAI> prompt.""" + prepare_tty_for_line_input() + if prompt: + console.print(prompt, markup=markup, emoji=False, end="") + try: + raw = input() + except EOFError: + return "" + return normalize_repl_line(raw) + + +def read_repl_yes_no( + console: Console, + prompt: str, + *, + default: bool = False, + markup: bool = True, +) -> bool: + """Return True when the user answers y/yes (default answer when they press Enter).""" + answer = read_repl_line(console, prompt, markup=markup) + if not answer: + return default + return answer.lower() in ("y", "yes") diff --git a/src/cai/util/streaming.py b/src/cai/util/streaming.py index ead368db..628d1743 100644 --- a/src/cai/util/streaming.py +++ b/src/cai/util/streaming.py @@ -1386,6 +1386,32 @@ def _reset_controlling_tty_sane() -> None: ) except Exception: pass + ensure_cooked_tty() + + +def ensure_cooked_tty() -> None: + """Force canonical line input so ``input()`` / Rich ``console.input`` accept Enter. + + ``stty sane`` alone is not always enough after prompt_toolkit: it may restore a + snapshot taken while ICRNL was cleared, leaving Enter as raw ``\\r`` (shown as ^M). + """ + if not sys.stdin.isatty(): + return + try: + import termios + + fd = sys.stdin.fileno() + attrs = termios.tcgetattr(fd) + iflag, _, _, lflag, _, cc = attrs + iflag |= termios.ICRNL | termios.BRKINT + iflag &= ~(termios.INLCR | termios.IGNCR) + lflag |= termios.ICANON | termios.ECHO | termios.ISIG + cc[termios.VMIN] = 1 + cc[termios.VTIME] = 0 + termios.tcsetattr(fd, termios.TCSADRAIN, (iflag, attrs[1], attrs[2], lflag, attrs[4], cc)) + termios.tcflush(fd, termios.TCIFLUSH) + except Exception: + pass def restore_terminal_state( diff --git a/tests/repl/test_multiline_prompt.py b/tests/repl/test_multiline_prompt.py index 4835055b..1fc00469 100644 --- a/tests/repl/test_multiline_prompt.py +++ b/tests/repl/test_multiline_prompt.py @@ -56,6 +56,17 @@ class TestMultilinePromptConfig: 'REGRESSION: finally block must call restore_terminal_state after prompt().' ) + def test_ensure_cooked_tty_after_stty_sane(self): + """stty sane must be followed by explicit cooked termios for y/N prompts.""" + from cai.util.streaming import _reset_controlling_tty_sane + import inspect + + source = inspect.getsource(_reset_controlling_tty_sane) + assert 'ensure_cooked_tty' in source, ( + 'REGRESSION: _reset_controlling_tty_sane must call ensure_cooked_tty so ' + 'follow-up console.input accepts Enter instead of echoing ^M.' + ) + def test_icrnl_cleared_before_prompt(self): """ICRNL/INLCR/IGNCR must be cleared before prompt() to keep Enter as submit. diff --git a/tests/repl/test_tty_input.py b/tests/repl/test_tty_input.py new file mode 100644 index 00000000..13af3e2c --- /dev/null +++ b/tests/repl/test_tty_input.py @@ -0,0 +1,48 @@ +"""Tests for REPL follow-up prompts (y/N after prompt_toolkit).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from cai.repl.ui.tty_input import ( + normalize_repl_line, + read_repl_line, + read_repl_yes_no, +) + + +class TestNormalizeReplLine: + def test_strips_carriage_return_from_enter(self): + assert normalize_repl_line("y\r") == "y" + + def test_strips_whitespace(self): + assert normalize_repl_line(" yes \r\n") == "yes" + + +class TestReadReplYesNo: + @patch("cai.repl.ui.tty_input.input", return_value="y\r") + @patch("cai.repl.ui.tty_input.prepare_tty_for_line_input") + def test_yes_with_carriage_return(self, _prepare, _input): + console = MagicMock() + assert read_repl_yes_no(console, "Continue? (y/N): ") is True + + @patch("cai.repl.ui.tty_input.input", return_value="") + @patch("cai.repl.ui.tty_input.prepare_tty_for_line_input") + def test_empty_defaults_to_no(self, _prepare, _input): + console = MagicMock() + assert read_repl_yes_no(console, "Continue? (y/N): ", default=False) is False + + @patch("cai.repl.ui.tty_input.input", return_value="n") + @patch("cai.repl.ui.tty_input.prepare_tty_for_line_input") + def test_no_answer(self, _prepare, _input): + console = MagicMock() + assert read_repl_yes_no(console, "Continue? (y/N): ") is False + + +class TestReadReplLine: + @patch("cai.repl.ui.tty_input.input", return_value="RESET\r") + @patch("cai.repl.ui.tty_input.prepare_tty_for_line_input") + def test_prepares_tty_before_read(self, prepare, _input): + console = MagicMock() + assert read_repl_line(console, "> ") == "RESET" + prepare.assert_called_once()