nanoPyCodeAgent 0.5.0__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. nanopycodeagent-0.5.1/.github/workflows/ci.yml +41 -0
  2. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/PKG-INFO +1 -1
  3. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/changelogs/0.5.x.md +9 -0
  4. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/src/nanopycodeagent/agent.py +15 -1
  5. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/tests/helpers.py +5 -0
  6. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/tests/test_agent.py +24 -0
  7. nanopycodeagent-0.5.1/tests/test_line_editing.py +169 -0
  8. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/.agents/skills/land-pr/SKILL.md +0 -0
  9. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/.agents/skills/release/SKILL.md +0 -0
  10. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/.agents/skills/release/scripts/verify-release.sh +0 -0
  11. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/.github/workflows/release.yml +0 -0
  12. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/.gitignore +0 -0
  13. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/.ignore +0 -0
  14. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/.python-version +0 -0
  15. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/.zed/debug.json +0 -0
  16. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/AGENTS.md +0 -0
  17. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/CLAUDE.md +0 -0
  18. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/LICENSE +0 -0
  19. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/README.md +0 -0
  20. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/README.zh-CN.md +0 -0
  21. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/RELEASING.md +0 -0
  22. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/changelogs/0.1.x.md +0 -0
  23. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/changelogs/0.2.x.md +0 -0
  24. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/changelogs/0.3.x.md +0 -0
  25. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/changelogs/0.4.x.md +0 -0
  26. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/changelogs/README.md +0 -0
  27. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/dev_notes/README.md +0 -0
  28. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/dev_notes/en/0.1.x.md +0 -0
  29. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/dev_notes/en/0.2.x.md +0 -0
  30. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/dev_notes/en/0.3.x.md +0 -0
  31. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/dev_notes/en/0.4.x.md +0 -0
  32. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/dev_notes/en/0.5.x.md +0 -0
  33. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/dev_notes/zh-CN/0.1.x.md +0 -0
  34. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/dev_notes/zh-CN/0.2.x.md +0 -0
  35. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/dev_notes/zh-CN/0.3.x.md +0 -0
  36. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/dev_notes/zh-CN/0.4.x.md +0 -0
  37. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/dev_notes/zh-CN/0.5.x.md +0 -0
  38. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/research/README.md +0 -0
  39. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/research/agent_tools.md +0 -0
  40. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/research/read_tool.md +0 -0
  41. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/superpowers/plans/2026-06-21-release-skills.md +0 -0
  42. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/superpowers/specs/2026-06-21-release-skill-design.md +0 -0
  43. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/docs/superpowers/specs/2026-06-29-config-file-support-design.md +0 -0
  44. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/pyproject.toml +0 -0
  45. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/src/nanopycodeagent/__init__.py +0 -0
  46. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/src/nanopycodeagent/bash_tool.py +0 -0
  47. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/src/nanopycodeagent/read_tool.py +0 -0
  48. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/src/nanopycodeagent/settings.py +0 -0
  49. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/src/nanopycodeagent/terminal.py +0 -0
  50. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/tests/conftest.py +0 -0
  51. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/tests/test_bash_tool.py +0 -0
  52. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/tests/test_read_tool.py +0 -0
  53. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/tests/test_settings.py +0 -0
  54. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/tests/test_terminal.py +0 -0
  55. {nanopycodeagent-0.5.0 → nanopycodeagent-0.5.1}/uv.lock +0 -0
@@ -0,0 +1,41 @@
1
+ name: CI
2
+
3
+ # Run the test suite on every pull request, and again on main once a change
4
+ # has landed. Read-only: this workflow never publishes anything, so it holds
5
+ # no write scopes of any kind.
6
+ on:
7
+ pull_request:
8
+ push:
9
+ branches:
10
+ - main
11
+
12
+ # A new push to a branch supersedes the run still in flight for it.
13
+ concurrency:
14
+ group: ci-${{ github.ref }}
15
+ cancel-in-progress: true
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ test:
22
+ name: pytest (Python ${{ matrix.python-version }})
23
+ runs-on: ubuntu-latest
24
+ strategy:
25
+ fail-fast: false # one version failing should not hide the other's result
26
+ matrix:
27
+ # The lowest version the project supports, and the newest it can run
28
+ # on, so a break in either direction shows up here.
29
+ python-version: ["3.13", "3.14"]
30
+ steps:
31
+ - uses: actions/checkout@v6
32
+ with:
33
+ fetch-depth: 0 # full history + tags so hatch-vcs can derive the version
34
+ - name: Install uv
35
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
36
+ with:
37
+ python-version: ${{ matrix.python-version }}
38
+ - name: Run tests
39
+ # --frozen installs exactly what uv.lock pins and fails if the lock has
40
+ # drifted from pyproject.toml, so CI tests the declared dependencies.
41
+ run: uv run --frozen pytest
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanoPyCodeAgent
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: A nano code agent built from scratch in pure Python.
5
5
  Project-URL: Homepage, https://github.com/minixalpha/nanoPyCodeAgent
6
6
  Project-URL: Repository, https://github.com/minixalpha/nanoPyCodeAgent
@@ -4,6 +4,15 @@ All notable changes in the **0.5.x** release series are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.1] - 2026-07-30
8
+
9
+ ### Fixed
10
+ - Backspacing a double-width character (CJK, emoji) at the `You>` prompt no
11
+ longer leaves half of its glyph on screen. The prompt now goes through
12
+ `readline`, which redraws the line, instead of letting the terminal erase
13
+ one column per backspace for a character that occupies two. Arrow-key
14
+ editing and in-session history come with it.
15
+
7
16
  ## [0.5.0] - 2026-07-29
8
17
 
9
18
  ### Added
@@ -14,6 +14,16 @@ preserved at the ``hardened-agent-loop`` tag.
14
14
  import os
15
15
  from importlib.metadata import PackageNotFoundError, version
16
16
 
17
+ try:
18
+ # Importing readline routes input() through a line editor that redraws the
19
+ # whole line. Without it the tty erases one column per backspace, which
20
+ # leaves half of a double-width character (CJK, emoji) on screen even
21
+ # though it is gone from the buffer. Editing and history come along for
22
+ # the ride. Not available on every platform, so the import is optional.
23
+ import readline # noqa: F401
24
+ except ImportError: # pragma: no cover - platform without readline
25
+ pass
26
+
17
27
  import anthropic
18
28
  from anthropic.types import MessageParam, ToolResultBlockParam, ToolUseBlock
19
29
 
@@ -105,7 +115,11 @@ def run() -> None:
105
115
  messages: list[MessageParam] = []
106
116
  while True:
107
117
  try:
108
- user_input = input("\nYou> ").strip()
118
+ # The blank line before the prompt is printed separately: readline
119
+ # measures the prompt to place the cursor, and a newline inside it
120
+ # throws that off.
121
+ print()
122
+ user_input = input("You> ").strip()
109
123
  except (EOFError, KeyboardInterrupt):
110
124
  print()
111
125
  break
@@ -100,15 +100,20 @@ def patch_client_and_input(monkeypatch, *, client, inputs):
100
100
  The config file is isolated by the autouse ``_isolate_config`` fixture in
101
101
  conftest.py, so ``run()`` sees no config file unless a test writes one to
102
102
  ``settings.SETTINGS_PATH``.
103
+
104
+ Returns the list that records the prompt of each ``input()`` call.
103
105
  """
104
106
  monkeypatch.setattr(anthropic, "Anthropic", lambda *a, **k: client)
105
107
 
106
108
  answers = iter(inputs)
109
+ prompts = []
107
110
 
108
111
  def fake_input(prompt=""):
112
+ prompts.append(prompt)
109
113
  try:
110
114
  return next(answers)
111
115
  except StopIteration as exc: # safety net: behaves like Ctrl-D
112
116
  raise EOFError from exc
113
117
 
114
118
  monkeypatch.setattr("builtins.input", fake_input)
119
+ return prompts
@@ -5,6 +5,8 @@ never spend tokens. We drive ``run()`` by scripting ``input()`` and the client,
5
5
  then assert on captured stdout and the recorded calls.
6
6
  """
7
7
 
8
+ import pytest
9
+
8
10
  from nanopycodeagent import agent
9
11
 
10
12
  from helpers import (
@@ -145,6 +147,28 @@ def test_multi_turn_accumulates_history(monkeypatch, capsys):
145
147
  ]
146
148
 
147
149
 
150
+ def test_readline_is_imported_for_line_editing():
151
+ # Without readline, input() leaves erasing to the tty, which clears one
152
+ # column per backspace and so strands half of a double-width character.
153
+ pytest.importorskip("readline")
154
+ assert hasattr(agent, "readline")
155
+
156
+
157
+ def test_prompt_carries_no_embedded_newline(monkeypatch, capsys):
158
+ messages = FakeMessages([])
159
+ client = FakeClient(messages)
160
+ prompts = patch_client_and_input(monkeypatch, client=client, inputs=["/exit"])
161
+
162
+ agent.run()
163
+
164
+ # readline measures the prompt to place the cursor, so the blank line
165
+ # between turns is printed on its own rather than embedded in the prompt.
166
+ assert prompts == ["You> "]
167
+ # The banner's last line is still followed by that blank line. (input() is
168
+ # faked here, so the prompt itself never reaches stdout.)
169
+ assert "or /exit to quit.\n\n" in capsys.readouterr().out
170
+
171
+
148
172
  def test_blank_input_is_skipped(monkeypatch, capsys):
149
173
  messages = FakeMessages([])
150
174
  client = FakeClient(messages)
@@ -0,0 +1,169 @@
1
+ """End-to-end check that backspace erases wide characters cleanly.
2
+
3
+ Erasing a double-width character (CJK, emoji) used to leave half its glyph on
4
+ screen: the tty clears one column per backspace while the character occupies
5
+ two, so the buffer emptied but the display did not. Showing that needs a real
6
+ terminal, so this test drives the agent over a pty and replays everything it
7
+ writes onto a model of a terminal line — the assertion is on what the user
8
+ would see, not on the escape sequences a particular readline build emits.
9
+
10
+ The child never reaches the API: the session ends at ``/exit``.
11
+ """
12
+
13
+ import locale
14
+ import os
15
+ import select
16
+ import sys
17
+ import time
18
+ import unicodedata
19
+
20
+ import pytest
21
+
22
+
23
+ def _ctype_is_utf8() -> bool:
24
+ """Whether this environment's character type is UTF-8.
25
+
26
+ readline only treats a multibyte character as a single unit under a UTF-8
27
+ ctype, so under, say, an explicit ``LC_ALL=C`` there is nothing sensible
28
+ to assert about erasing a CJK character.
29
+ """
30
+ if not hasattr(locale, "nl_langinfo"): # not POSIX
31
+ return False
32
+ return "utf-8" in locale.nl_langinfo(locale.CODESET).replace("_", "-").lower()
33
+
34
+
35
+ pytestmark = [
36
+ pytest.mark.skipif(not hasattr(os, "fork"), reason="needs a pty (POSIX only)"),
37
+ pytest.mark.skipif(not _ctype_is_utf8(), reason="needs a UTF-8 locale"),
38
+ ]
39
+
40
+ CHILD = "from nanopycodeagent.agent import run; run()"
41
+ PROMPT = b"You> "
42
+
43
+
44
+ def render(stream: str) -> str:
45
+ """Replay a terminal write stream and return the last line it leaves.
46
+
47
+ Enough of a terminal to judge this: printable characters (one cell wide,
48
+ or two for East Asian wide/fullwidth ones), carriage return, backspace,
49
+ and the two escapes a line editor redraws with — ``ESC[<n>G`` to move to
50
+ an absolute column and ``ESC[K`` to erase to end of line.
51
+ """
52
+ cells: list[str] = [] # one entry per column; "" continues a wide glyph
53
+ col = 0
54
+ i = 0
55
+ while i < len(stream):
56
+ char = stream[i]
57
+ if char == "\x1b" and stream[i + 1 : i + 2] == "[":
58
+ end = i + 2
59
+ while end < len(stream) and not stream[end].isalpha():
60
+ end += 1
61
+ params, final = stream[i + 2 : end], stream[end : end + 1]
62
+ if final == "G":
63
+ col = max(int(params or "1") - 1, 0)
64
+ elif final == "K":
65
+ del cells[col:]
66
+ i = end + 1
67
+ continue
68
+ if char == "\r":
69
+ col = 0
70
+ elif char == "\n":
71
+ cells, col = [], 0 # only the line the prompt sits on matters
72
+ elif char == "\b":
73
+ col = max(col - 1, 0)
74
+ else:
75
+ width = 2 if unicodedata.east_asian_width(char) in "WF" else 1
76
+ cells.extend(" " for _ in range(col + width - len(cells)))
77
+ cells[col] = char
78
+ for offset in range(1, width):
79
+ cells[col + offset] = ""
80
+ col += width
81
+ i += 1
82
+ return "".join(cells).rstrip()
83
+
84
+
85
+ def drive(keystrokes: bytes, home) -> str:
86
+ """Run the agent on a pty and type ``keystrokes`` at the prompt.
87
+
88
+ Returns everything the child wrote up to that point — the snapshot is
89
+ taken before ``/exit`` is sent, so it is exactly the screen the user is
90
+ looking at mid-edit.
91
+ """
92
+ import pty
93
+ import termios
94
+
95
+ # The autouse _isolate_config fixture has already cleared the ambient
96
+ # ANTHROPIC_* vars; HOME is redirected so the child cannot read the
97
+ # developer's ~/.nanoPyCodeAgent/settings.json either.
98
+ env = {**os.environ, "HOME": str(home), "ANTHROPIC_API_KEY": "sk-test"}
99
+
100
+ pid, fd = pty.fork()
101
+ if pid == 0: # child: replaced by exec, or dies trying
102
+ try:
103
+ os.execve(sys.executable, [sys.executable, "-c", CHILD], env)
104
+ finally:
105
+ os._exit(1)
106
+
107
+ attrs = termios.tcgetattr(fd)
108
+ attrs[0] |= termios.IUTF8 # multibyte-aware ERASE, as a real terminal has
109
+ termios.tcsetattr(fd, termios.TCSANOW, attrs)
110
+
111
+ output = b""
112
+
113
+ def drain(seconds: float) -> bool:
114
+ """Read what is ready within ``seconds``; False once the child is done."""
115
+ nonlocal output
116
+ if not select.select([fd], [], [], seconds)[0]:
117
+ return True # nothing to read yet, but the child is still up
118
+ try:
119
+ chunk = os.read(fd, 4096)
120
+ except OSError: # child exited and closed its end
121
+ return False
122
+ if not chunk:
123
+ return False
124
+ output += chunk
125
+ return True
126
+
127
+ def wait_for(marker: bytes, timeout: float = 10.0) -> None:
128
+ deadline = time.monotonic() + timeout
129
+ while marker not in output and time.monotonic() < deadline:
130
+ if not drain(0.1):
131
+ return
132
+
133
+ def settle(quiet: float = 0.3, timeout: float = 5.0) -> None:
134
+ """Read until the child has written nothing for ``quiet`` seconds."""
135
+ deadline = time.monotonic() + timeout
136
+ while time.monotonic() < deadline:
137
+ before = len(output)
138
+ if not drain(quiet) or len(output) == before:
139
+ return
140
+
141
+ try:
142
+ wait_for(PROMPT)
143
+ assert PROMPT in output, f"never reached the prompt: {output!r}"
144
+ os.write(fd, keystrokes)
145
+ settle()
146
+ screen = output.decode("utf-8", "replace")
147
+ os.write(fd, b"/exit\r")
148
+ wait_for(b"Bye!", timeout=5.0)
149
+ finally:
150
+ os.close(fd)
151
+ os.waitpid(pid, 0)
152
+ return screen
153
+
154
+
155
+ def test_backspace_clears_a_wide_character(tmp_path):
156
+ # Type two CJK characters, then erase both.
157
+ screen = drive("你好".encode() + b"\x7f\x7f", tmp_path)
158
+
159
+ # Nothing of either character is left beside the prompt. Before the fix
160
+ # two backspaces cleared two columns of the four they had filled, so
161
+ # "You> 你" stayed on screen after the input was already empty.
162
+ assert render(screen) == PROMPT.decode().rstrip()
163
+
164
+
165
+ def test_backspace_keeps_the_characters_it_should(tmp_path):
166
+ # Erasing one of three leaves the other two intact and correctly placed.
167
+ screen = drive("中文字".encode() + b"\x7f", tmp_path)
168
+
169
+ assert render(screen) == "You> 中文"
File without changes
File without changes
File without changes