diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ce2644b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +# Run the test suite on every pull request, and again on main once a change +# has landed. Read-only: this workflow never publishes anything, so it holds +# no write scopes of any kind. +on: + pull_request: + push: + branches: + - main + +# A new push to a branch supersedes the run still in flight for it. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: pytest (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false # one version failing should not hide the other's result + matrix: + # The lowest version the project supports, and the newest it can run + # on, so a break in either direction shows up here. + python-version: ["3.13", "3.14"] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # full history + tags so hatch-vcs can derive the version + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: ${{ matrix.python-version }} + - name: Run tests + # --frozen installs exactly what uv.lock pins and fails if the lock has + # drifted from pyproject.toml, so CI tests the declared dependencies. + run: uv run --frozen pytest diff --git a/docs/changelogs/0.5.x.md b/docs/changelogs/0.5.x.md index db16611..bbfdddd 100644 --- a/docs/changelogs/0.5.x.md +++ b/docs/changelogs/0.5.x.md @@ -4,6 +4,13 @@ All notable changes in the **0.5.x** release series are documented here. ## [Unreleased] +### Fixed +- Backspacing a double-width character (CJK, emoji) at the `You>` prompt no + longer leaves half of its glyph on screen. The prompt now goes through + `readline`, which redraws the line, instead of letting the terminal erase + one column per backspace for a character that occupies two. Arrow-key + editing and in-session history come with it. + ## [0.5.0] - 2026-07-29 ### Added diff --git a/src/nanopycodeagent/agent.py b/src/nanopycodeagent/agent.py index d4fd31a..c3a9089 100644 --- a/src/nanopycodeagent/agent.py +++ b/src/nanopycodeagent/agent.py @@ -14,6 +14,16 @@ import os from importlib.metadata import PackageNotFoundError, version +try: + # Importing readline routes input() through a line editor that redraws the + # whole line. Without it the tty erases one column per backspace, which + # leaves half of a double-width character (CJK, emoji) on screen even + # though it is gone from the buffer. Editing and history come along for + # the ride. Not available on every platform, so the import is optional. + import readline # noqa: F401 +except ImportError: # pragma: no cover - platform without readline + pass + import anthropic from anthropic.types import MessageParam, ToolResultBlockParam, ToolUseBlock @@ -105,7 +115,11 @@ def run() -> None: messages: list[MessageParam] = [] while True: try: - user_input = input("\nYou> ").strip() + # The blank line before the prompt is printed separately: readline + # measures the prompt to place the cursor, and a newline inside it + # throws that off. + print() + user_input = input("You> ").strip() except (EOFError, KeyboardInterrupt): print() break diff --git a/tests/helpers.py b/tests/helpers.py index a0c1cf1..c12f7e8 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -100,15 +100,20 @@ def patch_client_and_input(monkeypatch, *, client, inputs): The config file is isolated by the autouse ``_isolate_config`` fixture in conftest.py, so ``run()`` sees no config file unless a test writes one to ``settings.SETTINGS_PATH``. + + Returns the list that records the prompt of each ``input()`` call. """ monkeypatch.setattr(anthropic, "Anthropic", lambda *a, **k: client) answers = iter(inputs) + prompts = [] def fake_input(prompt=""): + prompts.append(prompt) try: return next(answers) except StopIteration as exc: # safety net: behaves like Ctrl-D raise EOFError from exc monkeypatch.setattr("builtins.input", fake_input) + return prompts diff --git a/tests/test_agent.py b/tests/test_agent.py index 8e0607b..3b055e0 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -5,6 +5,8 @@ then assert on captured stdout and the recorded calls. """ +import pytest + from nanopycodeagent import agent from helpers import ( @@ -145,6 +147,28 @@ def test_multi_turn_accumulates_history(monkeypatch, capsys): ] +def test_readline_is_imported_for_line_editing(): + # Without readline, input() leaves erasing to the tty, which clears one + # column per backspace and so strands half of a double-width character. + pytest.importorskip("readline") + assert hasattr(agent, "readline") + + +def test_prompt_carries_no_embedded_newline(monkeypatch, capsys): + messages = FakeMessages([]) + client = FakeClient(messages) + prompts = patch_client_and_input(monkeypatch, client=client, inputs=["/exit"]) + + agent.run() + + # readline measures the prompt to place the cursor, so the blank line + # between turns is printed on its own rather than embedded in the prompt. + assert prompts == ["You> "] + # The banner's last line is still followed by that blank line. (input() is + # faked here, so the prompt itself never reaches stdout.) + assert "or /exit to quit.\n\n" in capsys.readouterr().out + + def test_blank_input_is_skipped(monkeypatch, capsys): messages = FakeMessages([]) client = FakeClient(messages) diff --git a/tests/test_line_editing.py b/tests/test_line_editing.py new file mode 100644 index 0000000..28a3736 --- /dev/null +++ b/tests/test_line_editing.py @@ -0,0 +1,169 @@ +"""End-to-end check that backspace erases wide characters cleanly. + +Erasing a double-width character (CJK, emoji) used to leave half its glyph on +screen: the tty clears one column per backspace while the character occupies +two, so the buffer emptied but the display did not. Showing that needs a real +terminal, so this test drives the agent over a pty and replays everything it +writes onto a model of a terminal line — the assertion is on what the user +would see, not on the escape sequences a particular readline build emits. + +The child never reaches the API: the session ends at ``/exit``. +""" + +import locale +import os +import select +import sys +import time +import unicodedata + +import pytest + + +def _ctype_is_utf8() -> bool: + """Whether this environment's character type is UTF-8. + + readline only treats a multibyte character as a single unit under a UTF-8 + ctype, so under, say, an explicit ``LC_ALL=C`` there is nothing sensible + to assert about erasing a CJK character. + """ + if not hasattr(locale, "nl_langinfo"): # not POSIX + return False + return "utf-8" in locale.nl_langinfo(locale.CODESET).replace("_", "-").lower() + + +pytestmark = [ + pytest.mark.skipif(not hasattr(os, "fork"), reason="needs a pty (POSIX only)"), + pytest.mark.skipif(not _ctype_is_utf8(), reason="needs a UTF-8 locale"), +] + +CHILD = "from nanopycodeagent.agent import run; run()" +PROMPT = b"You> " + + +def render(stream: str) -> str: + """Replay a terminal write stream and return the last line it leaves. + + Enough of a terminal to judge this: printable characters (one cell wide, + or two for East Asian wide/fullwidth ones), carriage return, backspace, + and the two escapes a line editor redraws with — ``ESC[G`` to move to + an absolute column and ``ESC[K`` to erase to end of line. + """ + cells: list[str] = [] # one entry per column; "" continues a wide glyph + col = 0 + i = 0 + while i < len(stream): + char = stream[i] + if char == "\x1b" and stream[i + 1 : i + 2] == "[": + end = i + 2 + while end < len(stream) and not stream[end].isalpha(): + end += 1 + params, final = stream[i + 2 : end], stream[end : end + 1] + if final == "G": + col = max(int(params or "1") - 1, 0) + elif final == "K": + del cells[col:] + i = end + 1 + continue + if char == "\r": + col = 0 + elif char == "\n": + cells, col = [], 0 # only the line the prompt sits on matters + elif char == "\b": + col = max(col - 1, 0) + else: + width = 2 if unicodedata.east_asian_width(char) in "WF" else 1 + cells.extend(" " for _ in range(col + width - len(cells))) + cells[col] = char + for offset in range(1, width): + cells[col + offset] = "" + col += width + i += 1 + return "".join(cells).rstrip() + + +def drive(keystrokes: bytes, home) -> str: + """Run the agent on a pty and type ``keystrokes`` at the prompt. + + Returns everything the child wrote up to that point — the snapshot is + taken before ``/exit`` is sent, so it is exactly the screen the user is + looking at mid-edit. + """ + import pty + import termios + + # The autouse _isolate_config fixture has already cleared the ambient + # ANTHROPIC_* vars; HOME is redirected so the child cannot read the + # developer's ~/.nanoPyCodeAgent/settings.json either. + env = {**os.environ, "HOME": str(home), "ANTHROPIC_API_KEY": "sk-test"} + + pid, fd = pty.fork() + if pid == 0: # child: replaced by exec, or dies trying + try: + os.execve(sys.executable, [sys.executable, "-c", CHILD], env) + finally: + os._exit(1) + + attrs = termios.tcgetattr(fd) + attrs[0] |= termios.IUTF8 # multibyte-aware ERASE, as a real terminal has + termios.tcsetattr(fd, termios.TCSANOW, attrs) + + output = b"" + + def drain(seconds: float) -> bool: + """Read what is ready within ``seconds``; False once the child is done.""" + nonlocal output + if not select.select([fd], [], [], seconds)[0]: + return True # nothing to read yet, but the child is still up + try: + chunk = os.read(fd, 4096) + except OSError: # child exited and closed its end + return False + if not chunk: + return False + output += chunk + return True + + def wait_for(marker: bytes, timeout: float = 10.0) -> None: + deadline = time.monotonic() + timeout + while marker not in output and time.monotonic() < deadline: + if not drain(0.1): + return + + def settle(quiet: float = 0.3, timeout: float = 5.0) -> None: + """Read until the child has written nothing for ``quiet`` seconds.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + before = len(output) + if not drain(quiet) or len(output) == before: + return + + try: + wait_for(PROMPT) + assert PROMPT in output, f"never reached the prompt: {output!r}" + os.write(fd, keystrokes) + settle() + screen = output.decode("utf-8", "replace") + os.write(fd, b"/exit\r") + wait_for(b"Bye!", timeout=5.0) + finally: + os.close(fd) + os.waitpid(pid, 0) + return screen + + +def test_backspace_clears_a_wide_character(tmp_path): + # Type two CJK characters, then erase both. + screen = drive("你好".encode() + b"\x7f\x7f", tmp_path) + + # Nothing of either character is left beside the prompt. Before the fix + # two backspaces cleared two columns of the four they had filled, so + # "You> 你" stayed on screen after the input was already empty. + assert render(screen) == PROMPT.decode().rstrip() + + +def test_backspace_keeps_the_characters_it_should(tmp_path): + # Erasing one of three leaves the other two intact and correctly placed. + screen = drive("中文字".encode() + b"\x7f", tmp_path) + + assert render(screen) == "You> 中文"