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
28 changes: 28 additions & 0 deletions docs/changelogs/0.6.x.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Changelog — 0.6.x

All notable changes in the **0.6.x** release series are documented here.

## [Unreleased]

### Added
- A spinner animates while waiting for the model's reply or a running bash
command, so long waits no longer look like a frozen terminal. It draws
only when stdout is a terminal (and `NO_COLOR` is unset) and erases
itself once output arrives, leaving no trace in the transcript.

### Changed
- Echoed tool calls (`[bash]$ ...`, `[read] ...`) now sit on a lighter
background shade than their output, so the command and what it printed
read apart at a glance.

<!--
When cutting a release, copy the relevant items from [Unreleased] into a new
version section above it, e.g.:

## [0.6.0] - YYYY-MM-DD

### Added
### Changed
### Fixed
### Removed
-->
9 changes: 9 additions & 0 deletions docs/dev_notes/en/0.6.x.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Development Notes — 0.6.x

> Generated from the Chinese source [`../zh-CN/0.6.x.md`](../zh-CN/0.6.x.md). Do not edit by hand.

## 0.6.0 - 2026.08.01

Before continuing with the write tool, let's first add some improvements to how the agent presents its output: tool use and tool output now get separate background colors, so it is easier to tell at a glance which part is the tool use and which is the tool use result. Down the road we may want to emit something like an execution trace into a jsonl file; for now this will do.

I also tried the animated ASCII indicator pi shows while waiting for the agent's reply: `⠋ Working`. To my surprise, the animated characters turn out to be a few Braille glyphs displayed in rotation — and they are fairly easy to implement, so let's just build it.
8 changes: 8 additions & 0 deletions docs/dev_notes/zh-CN/0.6.x.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 开发笔记 — 0.6.x

> 本文件为**手写中文源文件**(source of truth);英文版 [`../en/0.6.x.md`](../en/0.6.x.md) 由其生成。

## 0.6.0 - 2026.08.01
继续开发 write tool 之前,先加一些 Agent 输出方式相关的优化,tool use 和 tool output 背景颜色分开,更容易看出哪里是 tool use ,哪里是 tool use result。后续可能需要输出类似执行轨迹的东西,放 jsonl 里,目前暂时先这样处理。

另外,尝试了一下 pi 里等待 Agent 回复时的 ascii 动态字符: `⠋ Working`,没想到动态字符居然是几个交替显示的盲文,实现起来也比较容易,就先实现吧。
25 changes: 17 additions & 8 deletions src/nanopycodeagent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from .bash_tool import BASH_TOOL, run_bash
from .read_tool import READ_TOOL, run_read
from .settings import load_settings_env
from .terminal import print_tool
from .terminal import Spinner, print_tool_output, print_tool_use

# The model used when ANTHROPIC_MODEL is set in neither the environment nor
# the config file.
Expand Down Expand Up @@ -65,17 +65,18 @@ def _run_one_tool(block: ToolUseBlock) -> ToolResultBlockParam:
"""Execute one ``tool_use`` block, echoing the call and its output."""
if block.name == "read":
path = block.input["path"]
print_tool(f"[read] {path}")
print_tool_use(f"[read] {path}")
output, is_error = run_read(
path,
offset=block.input.get("offset", 1),
limit=block.input.get("limit"),
)
else: # bash — the only other tool offered
command = block.input["command"]
print_tool(f"[bash]$ {command}")
output, is_error = run_bash(command)
print_tool(output)
print_tool_use(f"[bash]$ {command}")
with Spinner("Running..."):
output, is_error = run_bash(command)
print_tool_output(output)
return {
"type": "tool_result",
"tool_use_id": block.id,
Expand Down Expand Up @@ -132,20 +133,28 @@ def run() -> None:
# The model may ask to run tools; keep streaming replies and feeding
# results back until it finishes a reply without tool calls.
while True:
print("\nAgent> ", end="", flush=True)
# A spinner marks the wait for the reply; the first streamed
# token replaces it with the Agent> prompt. A tool-only reply
# streams no text, so the prompt is skipped for it entirely.
replied = False
# Stream the reply so text shows up as it is generated, then grab
# the accumulated message for the conversation history.
with client.messages.stream(
with Spinner() as spinner, client.messages.stream(
model=model,
max_tokens=MAX_TOKENS,
system=SYSTEM_PROMPT,
tools=TOOLS,
messages=messages,
) as stream:
for text in stream.text_stream:
if not replied:
spinner.stop()
print("\nAgent> ", end="", flush=True)
replied = True
print(text, end="", flush=True)
message = stream.get_final_message()
print()
if replied:
print()

messages.append({"role": "assistant", "content": message.content})
if message.stop_reason != "tool_use":
Expand Down
85 changes: 76 additions & 9 deletions src/nanopycodeagent/terminal.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
"""Background shading for tool activity in the terminal."""
"""Terminal presentation: tool-activity shading and a wait spinner."""

import itertools
import os
import sys
import threading

# Dark gray from the 256-color palette, so echoed commands and their output
# read apart from the user's prompts and the model's prose.
_TOOL_BG = "\x1b[48;5;236m"
# Grays from the 256-color palette. The echoed call sits on a lighter shade
# than its output so the two read apart at a glance, and both read apart
# from the user's prompts and the model's prose. The output keeps the
# darker shade: it stays subdued while remaining visible on common dark
# terminal backgrounds.
_USE_BG = "\x1b[48;5;238m"
_OUTPUT_BG = "\x1b[48;5;236m"
_RESET = "\x1b[0m"


Expand All @@ -14,16 +20,77 @@ def _use_color() -> bool:
return sys.stdout.isatty() and not os.environ.get("NO_COLOR")


def print_tool(text: str) -> None:
"""Print tool activity (an echoed command or its output) shaded.
def _print_shaded(text: str, bg: str) -> None:
"""Print ``text`` with every line shaded in ``bg``.

Each line carries its own set-background / erase-to-EOL / reset, so the
shading spans the full terminal width and never leaks past a line
boundary. Escape sequences inside ``text`` are printed as-is and may
disrupt the shading — a cosmetic trade for staying simple.
"""
if _use_color():
text = "\n".join(
f"{_TOOL_BG}{line}\x1b[K{_RESET}" for line in text.split("\n")
)
text = "\n".join(f"{bg}{line}\x1b[K{_RESET}" for line in text.split("\n"))
print(text, flush=True)


def print_tool_use(text: str) -> None:
"""Print an echoed tool call (e.g. ``[bash]$ ls``) on the lighter shade."""
_print_shaded(text, _USE_BG)


def print_tool_output(text: str) -> None:
"""Print a tool's output on the darker shade."""
_print_shaded(text, _OUTPUT_BG)


# The classic braille "dots" spinner: one glyph per frame, different dots
# raised in each, cycled fast enough to read as rotation.
_SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
_SPINNER_INTERVAL = 0.08


class Spinner:
"""Animate ``⠋ Working...`` in place on one line until stopped.

A background thread redraws the line with carriage returns while the
caller blocks on slow work, and erases it on stop, so the spinner
leaves no trace in the transcript. When stdout is not a terminal (or
NO_COLOR is set) nothing is printed at all. ``stop`` is idempotent:
stopping early — say, when the first streamed token arrives — and
again on context-manager exit is fine.
"""

def __init__(self, text: str = "Working..."):
self._text = text
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None

def start(self) -> None:
if not _use_color():
return
self._thread = threading.Thread(target=self._spin, daemon=True)
self._thread.start()

def stop(self) -> None:
if self._thread is None:
return
self._stop_event.set()
self._thread.join()

def __enter__(self) -> "Spinner":
self.start()
return self

def __exit__(self, *exc_info: object) -> None:
self.stop()

def _spin(self) -> None:
# Draw a frame, then sleep on the stop event so stop() interrupts
# the pause instead of waiting out the full interval.
for frame in itertools.cycle(_SPINNER_FRAMES):
sys.stdout.write(f"\r{frame} {self._text}")
sys.stdout.flush()
if self._stop_event.wait(_SPINNER_INTERVAL):
break
sys.stdout.write("\r\x1b[K") # erase the spinner line
sys.stdout.flush()
55 changes: 51 additions & 4 deletions tests/test_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,69 @@ def test_use_color_requires_tty_and_unset_no_color(monkeypatch):
assert not terminal._use_color()


def test_print_tool_shades_each_line_to_full_width(monkeypatch, capsys):
def test_print_tool_use_shades_each_line_to_full_width(monkeypatch, capsys):
# Every line carries its own set-background / erase-to-EOL / reset so the
# shading spans the terminal width and never leaks past a line boundary.
monkeypatch.setattr(terminal, "_use_color", lambda: True)

terminal.print_tool("[bash]$ ls\nout")
terminal.print_tool_use("[bash]$ ls")

assert capsys.readouterr().out == "\x1b[48;5;238m[bash]$ ls\x1b[K\x1b[0m\n"


def test_print_tool_output_uses_a_darker_shade_than_the_call(monkeypatch, capsys):
# The echoed call and its output sit on different shades so where one
# ends and the other begins is visible at a glance.
monkeypatch.setattr(terminal, "_use_color", lambda: True)

terminal.print_tool_output("a\nb")

assert capsys.readouterr().out == (
"\x1b[48;5;236m[bash]$ ls\x1b[K\x1b[0m\n\x1b[48;5;236mout\x1b[K\x1b[0m\n"
"\x1b[48;5;236ma\x1b[K\x1b[0m\n\x1b[48;5;236mb\x1b[K\x1b[0m\n"
)
assert terminal._OUTPUT_BG != terminal._USE_BG


def test_print_tool_plain_without_terminal(monkeypatch, capsys):
# Redirected output (pipes, log files) must stay free of escape codes.
monkeypatch.setattr(terminal, "_use_color", lambda: False)

terminal.print_tool("[bash]$ ls\nout")
terminal.print_tool_use("[bash]$ ls")
terminal.print_tool_output("out")

assert capsys.readouterr().out == "[bash]$ ls\nout\n"


def test_spinner_draws_frames_and_erases_itself(monkeypatch, capsys):
# The spinner redraws one line in place with carriage returns; stopping
# erases the line so the transcript keeps no trace of the animation.
monkeypatch.setattr(terminal, "_use_color", lambda: True)

spinner = terminal.Spinner("Working...")
spinner.start()
spinner.stop()

out = capsys.readouterr().out
assert out.startswith("\r⠋ Working...") # the first frame drew immediately
assert out.endswith("\r\x1b[K") # and stopping erased the line


def test_spinner_stop_is_idempotent(monkeypatch, capsys):
# An early stop (first streamed token) followed by the context-manager
# exit must erase the line exactly once.
monkeypatch.setattr(terminal, "_use_color", lambda: True)

with terminal.Spinner() as spinner:
spinner.stop()

assert capsys.readouterr().out.count("\x1b[K") == 1


def test_spinner_silent_without_terminal(monkeypatch, capsys):
# Redirected output (pipes, log files) must stay free of animation.
monkeypatch.setattr(terminal, "_use_color", lambda: False)

with terminal.Spinner():
pass

assert capsys.readouterr().out == ""