""" E2E regression test: two-press Ctrl+C exit with clear-input semantics in the ``omnigent chat`` REPL. Behavior contract (see ``TerminalHost.__init__::_on_ctrl_c``): - Input buffer has text → first Ctrl+C clears the buffer, does NOT exit, does NOT arm any exit confirmation. - Input buffer is empty → first Ctrl+C arms a ``_EXIT_CONFIRM_WINDOW``-second countdown and renders ``Press Ctrl+C again to exit`` in the bottom toolbar. - Second Ctrl+C within that window exits the REPL cleanly. - If the window expires without a second press, the hint clears and the next Ctrl+C arms a fresh countdown. Without this fix, any single Ctrl+C tears the whole REPL down, which was a frequent fat-finger loss-of-input complaint. The driver (``_ctrl_c_driver.py``) prints ``CTRL_C_DRIVER_EXITED_CLEANLY_XYZZY`` to stdout after :meth:`TerminalHost.run` returns — the only way for it to return in this driver is the second Ctrl+C raising :class:`KeyboardInterrupt`, which the host's run loop catches and breaks on. """ from __future__ import annotations import sys import time from pathlib import Path import pexpect import pyte import pytest sys.path.insert(0, str(Path(__file__).parent)) # mypy can't resolve this — the driver isn't a proper package # module, it's only importable after the sys.path insert above. from _ctrl_c_driver import _EXIT_SENTINEL # type: ignore[import-not-found] _DRIVER = Path(__file__).parent / "_ctrl_c_driver.py" _TERM = "xterm-256color" _ROWS = 40 _COLS = 120 _BOOT_TIMEOUT = 10.0 _RENDER_TIMEOUT = 3.0 _SHUTDOWN_TIMEOUT = 5.0 def _wait_for_screen( child: pexpect.spawn, screen: pyte.Screen, stream: pyte.Stream, substring: str, timeout: float, ) -> str: """ Drain the PTY stream into a pyte terminal emulator until *substring* appears on the rendered display or *timeout* elapses. :param child: Live pexpect child. :param screen: pyte screen to update. :param stream: pyte stream feeding *screen*. :param substring: Plain-text sentinel to look for. :param timeout: Max seconds to wait. :returns: The rendered display at match time. :raises pexpect.TIMEOUT: If *substring* never appears. """ deadline = time.monotonic() + timeout while time.monotonic() < deadline: try: chunk = child.read_nonblocking(size=4096, timeout=0.1) except pexpect.TIMEOUT: continue stream.feed(chunk) rendered = "\n".join(screen.display) if substring in rendered: return rendered rendered = "\n".join(screen.display) raise pexpect.TIMEOUT( f"did not see {substring!r} within {timeout}s. Rendered:\n{rendered}", ) @pytest.fixture def ctrl_c_child() -> pexpect.spawn: """ Spawn the Ctrl+C driver under a PTY. :returns: A live pexpect child. Fixture teardown sends Ctrl+D + force-close so a hung child never blocks the suite. """ child = pexpect.spawn( sys.executable, [str(_DRIVER)], env={"TERM": _TERM, "LINES": str(_ROWS), "COLUMNS": str(_COLS)}, encoding="utf-8", timeout=_BOOT_TIMEOUT, dimensions=(_ROWS, _COLS), ) yield child if not child.closed: # Drain any stale pending input, send EOF, force-close. try: child.sendcontrol("d") child.expect(pexpect.EOF, timeout=_SHUTDOWN_TIMEOUT) except pexpect.TIMEOUT: pass child.close(force=True) def test_ctrl_c_clears_input_before_exit( ctrl_c_child: pexpect.spawn, ) -> None: """ Ctrl+C with text in the input field clears the field; a subsequent empty-input Ctrl+C arms the exit hint; a second Ctrl+C within the window exits cleanly. Steps: 1. Wait for the input marker. 2. Type ``hello`` — it appears next to the marker. 3. Press Ctrl+C once — input clears; no exit hint appears. 4. Press Ctrl+C again — input is empty, so the exit hint arms and appears in the toolbar. 5. Press Ctrl+C a third time — second press within the window, REPL exits; driver prints the sentinel. **What breaks if this fails:** - The ``c-c`` binding in TerminalHost.__init__ stops checking ``buf.text`` first and always raises :class:`KeyboardInterrupt` — step 3 would hard-exit. - ``_exit_confirm_deadline`` logic regresses so the hint never shows (step 4's toolbar text missing) or the second press doesn't exit (step 5's sentinel never prints). - ``build_toolbar`` stops rendering the confirm hint when the deadline is set. :param ctrl_c_child: Fresh PTY-spawned driver. """ screen = pyte.Screen(_COLS, _ROWS) stream = pyte.Stream(screen) # 1. Boot. _wait_for_screen( ctrl_c_child, screen, stream, "❯", timeout=_BOOT_TIMEOUT, ) # 2. Type some input. ctrl_c_child.send("hello") _wait_for_screen( ctrl_c_child, screen, stream, "hello", timeout=_RENDER_TIMEOUT, ) # 3. First Ctrl+C clears the input. Verify the buffer no # longer contains "hello" on the input row. pyte's # display is updated-in-place; we look for the marker # row and assert "hello" isn't there anymore. ctrl_c_child.sendcontrol("c") # prompt-toolkit redraws within a frame or two; give it a # short drain before the absence check. time.sleep(0.3) try: chunk = ctrl_c_child.read_nonblocking(size=4096, timeout=0.3) stream.feed(chunk) except pexpect.TIMEOUT: pass rendered = "\n".join(screen.display) # The input-marker row shouldn't have "hello" anymore. # A confirm hint shouldn't show yet (buffer had text → # binding early-returned before arming). marker_rows = [r for r in screen.display if "❯" in r] assert marker_rows, "lost track of the input marker row" assert "hello" not in "".join(marker_rows), ( f"first Ctrl+C should clear input text but 'hello' is still on the marker row. " f"Rendered:\n{rendered}" ) assert "Press Ctrl+C again" not in rendered, ( "exit hint appeared on first Ctrl+C with non-empty input; " "the binding should have cleared the buffer and NOT armed the hint. " f"Rendered:\n{rendered}" ) # 4. Second Ctrl+C on empty input arms the exit hint. # The hint renders in the prompt-toolkit bottom_toolbar # which, under pexpect + pyte, doesn't reliably land on # the emulated screen (the toolbar uses cursor-save / # relative positioning that pyte's minimal emulator # doesn't always capture). We assert the BEHAVIOR # instead: the child must still be alive after this # Ctrl+C (would've exited if the hint-arm path wasn't # firing). The first-ctrl-c-in-empty-buffer path MUST # NOT exit — if it does, one stray Ctrl+C at any time # hard-kills the REPL and the whole two-press contract # breaks. ctrl_c_child.sendcontrol("c") # Short settling drain — give the binding time to run; if # it was going to raise, we'd see EOF within this window. time.sleep(0.3) assert ctrl_c_child.isalive(), ( "Child exited on a single Ctrl+C with empty buffer — the two-press " "contract requires the first empty-input Ctrl+C to ARM the hint, " "not exit. Likely regression: the c-c binding is raising " "KeyboardInterrupt on first press even when no deadline is set." ) # 5. Third Ctrl+C within the window exits. The driver # prints the sentinel after host.run() returns; we # expect(...) on raw bytes (not pyte) since the # sentinel goes to stdout AFTER prompt-toolkit has # restored the terminal. ctrl_c_child.sendcontrol("c") ctrl_c_child.expect(_EXIT_SENTINEL, timeout=_RENDER_TIMEOUT)