Skip to content

fix(agent): erase double-width characters cleanly at the prompt - #14

Merged
minixalpha merged 2 commits into
mainfrom
claude/fix-cjk-backspace
Jul 30, 2026
Merged

fix(agent): erase double-width characters cleanly at the prompt#14
minixalpha merged 2 commits into
mainfrom
claude/fix-cjk-backspace

Conversation

@minixalpha

@minixalpha minixalpha commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Two commits: the fix, and the CI workflow that verifies it.

fix(agent): erase double-width characters cleanly at the prompt

The bug

Backspacing a CJK character at the You> prompt leaves half of its glyph on
screen, and pressing backspace again does not clear it — the input buffer is
already empty, so there is nothing left to erase:

You> 你          <- typed, then fully erased; this "你" stays on screen

Why

input() only routes through a line editor when the readline module has been
imported. Without it, the echo and the erase fall to the tty line discipline,
which emits one BS space BS per character — clearing exactly one column. East
Asian wide and fullwidth characters occupy two, so half the glyph survives.

A pty capture of the old behaviour shows both halves of the symptom at once:

type "你", then backspace  ->  \xe4\xbd\xa0 \x08 \x08     one column erased
                               GOT=''                     buffer already empty

The fix

src/nanopycodeagent/agent.py imports readline, which redraws the line from
an absolute column instead of assuming the erase is one column wide:

erase two CJK chars  ->  \r\x1b[8G\x1b[K  ->  \r\x1b[6G\x1b[K
                         (5-col prompt + 2-col 你)  (back to the prompt, cleared)

The import is guarded — a platform without readline keeps the old behaviour
rather than failing to start.

Two things follow from it:

  • Arrow-key editing and in-session history now work. Previously an arrow key
    inserted a raw ^[[A into the line.
  • The blank line between turns moves out of the prompt string, because
    readline measures the prompt to place the cursor and a newline inside it
    throws that off. The rendered layout is unchanged.

Tests

tests/test_line_editing.py drives the real agent over a pty and replays its
output onto a small model of a terminal line, so it asserts on what the user
sees
rather than on the escape sequences a particular readline build emits
(libedit and GNU readline redraw differently, both correctly).

Verified to fail on the unfixed code, reproducing the reported symptom exactly:

assert 'You> 你' == 'You>'      # both characters erased, one still on screen

The tests skip where they could not mean anything: no fork (non-POSIX), or a
non-UTF-8 ctype such as an explicit LC_ALL=C, under which multibyte editing
cannot work in the first place.

tests/test_agent.py adds two cheap in-process guards — that readline is
imported, and that the prompt carries no embedded newline — so a future edit
cannot quietly undo either half of the fix. tests/helpers.py now returns the
recorded input() prompts to make the second one possible.

ci: run pytest on pull requests

The repository had no workflow that runs the tests — release.yml only fires on
a v* tag — so pull requests reported no checks at all and nothing verified a
change before it landed.

.github/workflows/ci.yml runs pytest on every pull request and on pushes to
main, across Python 3.13 (the lowest supported) and 3.14 (the newest it runs
on), with fail-fast: false so one version failing does not hide the other's
result. uv run --frozen installs exactly what uv.lock pins and fails if the
lock has drifted from pyproject.toml. The job holds contents: read and
nothing more.

This is the first PR whose tests are verified by CI: 54 passed on both versions,
with the pty tests confirmed to have run rather than skipped.

Changelog

A Fixed entry under [Unreleased] in docs/changelogs/0.5.x.md covers the
prompt behaviour. The CI workflow gets no entry — it changes nothing for users
of the published package.

Backspacing a CJK character at the You> prompt left half its glyph on
screen. input() was leaving the erase to the tty line discipline, which
emits one BS-space-BS per character regardless of how many columns that
character occupies -- two, for East Asian wide and fullwidth ones. The
buffer really was empty; only the display was stale, so the leftover
could not be erased by pressing backspace again.

Import readline, which redraws the line from an absolute column instead
of counting on the erase being one column wide. Arrow-key editing and
in-session history come along with it. The blank line separating turns
moves out of the prompt string, since readline measures the prompt to
place the cursor and a newline inside it throws that off.

The regression test drives the agent over a pty and replays its output
onto a model of a terminal line, so it asserts on what the user sees
rather than on the escape sequences a given readline build emits.
The repository had no workflow that runs the test suite -- release.yml
only fires on a v* tag -- so a pull request reported no checks at all and
nothing verified a change before it landed.

Run pytest on every pull request and on pushes to main, across the lowest
supported Python (3.13) and the newest it runs on (3.14). uv run --frozen
installs exactly what uv.lock pins and fails if the lock has drifted from
pyproject.toml. The job holds contents:read and nothing more.
@minixalpha
minixalpha merged commit da7d167 into main Jul 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant