mirror of https://github.com/aliasrobotics/cai.git
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.
This commit is contained in:
parent
fda018a324
commit
937fbb5cc1
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue