Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions docs/changelogs/0.5.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion src/nanopycodeagent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 24 additions & 0 deletions tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
then assert on captured stdout and the recorded calls.
"""

import pytest

from nanopycodeagent import agent

from helpers import (
Expand Down Expand Up @@ -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)
Expand Down
169 changes: 169 additions & 0 deletions tests/test_line_editing.py
Original file line number Diff line number Diff line change
@@ -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[<n>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> 中文"