From 0348ee228811ea03a28bfa70fad982e171d300f4 Mon Sep 17 00:00:00 2001 From: minixalpha Date: Fri, 31 Jul 2026 23:14:21 +0800 Subject: [PATCH 1/5] feat(terminal): shade echoed tool calls lighter than their output --- src/nanopycodeagent/agent.py | 8 ++++---- src/nanopycodeagent/terminal.py | 28 ++++++++++++++++++++-------- tests/test_terminal.py | 20 ++++++++++++++++---- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/nanopycodeagent/agent.py b/src/nanopycodeagent/agent.py index c3a9089..4c5708f 100644 --- a/src/nanopycodeagent/agent.py +++ b/src/nanopycodeagent/agent.py @@ -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 print_tool_output, print_tool_use # The model used when ANTHROPIC_MODEL is set in neither the environment nor # the config file. @@ -65,7 +65,7 @@ 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), @@ -73,9 +73,9 @@ def _run_one_tool(block: ToolUseBlock) -> ToolResultBlockParam: ) else: # bash — the only other tool offered command = block.input["command"] - print_tool(f"[bash]$ {command}") + print_tool_use(f"[bash]$ {command}") output, is_error = run_bash(command) - print_tool(output) + print_tool_output(output) return { "type": "tool_result", "tool_use_id": block.id, diff --git a/src/nanopycodeagent/terminal.py b/src/nanopycodeagent/terminal.py index 2ccf2f1..6cf7d62 100644 --- a/src/nanopycodeagent/terminal.py +++ b/src/nanopycodeagent/terminal.py @@ -3,9 +3,13 @@ import os import sys -# 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" @@ -14,8 +18,8 @@ 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 @@ -23,7 +27,15 @@ def print_tool(text: str) -> None: 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) diff --git a/tests/test_terminal.py b/tests/test_terminal.py index fa82947..f8dbc38 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -18,22 +18,34 @@ 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" From 6362285d3ee6a96ad525c037e7637c19dd9c14f7 Mon Sep 17 00:00:00 2001 From: minixalpha Date: Fri, 31 Jul 2026 23:22:18 +0800 Subject: [PATCH 2/5] feat(terminal): show a spinner while awaiting the model or bash --- src/nanopycodeagent/agent.py | 19 ++++++++--- src/nanopycodeagent/terminal.py | 57 ++++++++++++++++++++++++++++++++- tests/test_terminal.py | 35 ++++++++++++++++++++ 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/src/nanopycodeagent/agent.py b/src/nanopycodeagent/agent.py index 4c5708f..5e30fa1 100644 --- a/src/nanopycodeagent/agent.py +++ b/src/nanopycodeagent/agent.py @@ -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_output, print_tool_use +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. @@ -74,7 +74,8 @@ def _run_one_tool(block: ToolUseBlock) -> ToolResultBlockParam: else: # bash — the only other tool offered command = block.input["command"] print_tool_use(f"[bash]$ {command}") - output, is_error = run_bash(command) + with Spinner("Running..."): + output, is_error = run_bash(command) print_tool_output(output) return { "type": "tool_result", @@ -132,10 +133,13 @@ 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, @@ -143,9 +147,14 @@ def run() -> None: 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": diff --git a/src/nanopycodeagent/terminal.py b/src/nanopycodeagent/terminal.py index 6cf7d62..2548b03 100644 --- a/src/nanopycodeagent/terminal.py +++ b/src/nanopycodeagent/terminal.py @@ -1,7 +1,9 @@ -"""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 # 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 @@ -39,3 +41,56 @@ def print_tool_use(text: str) -> None: 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() diff --git a/tests/test_terminal.py b/tests/test_terminal.py index f8dbc38..4974a74 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -49,3 +49,38 @@ def test_print_tool_plain_without_terminal(monkeypatch, capsys): 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 == "" From 2a208c98edf41c5bdf5fae4649e4cec79bde8b1a Mon Sep 17 00:00:00 2001 From: minixalpha Date: Sat, 1 Aug 2026 17:31:11 +0800 Subject: [PATCH 3/5] docs: add changelog entry for tool shading and wait spinner --- docs/changelogs/0.5.x.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/changelogs/0.5.x.md b/docs/changelogs/0.5.x.md index c850675..36d0869 100644 --- a/docs/changelogs/0.5.x.md +++ b/docs/changelogs/0.5.x.md @@ -4,6 +4,17 @@ All notable changes in the **0.5.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. + ## [0.5.1] - 2026-07-30 ### Fixed From 457f70f369ad8c56b7fb53b257f5e9d902f10d90 Mon Sep 17 00:00:00 2001 From: minixalpha Date: Sat, 1 Aug 2026 17:33:04 +0800 Subject: [PATCH 4/5] docs: move changelog entry to a new 0.6.x series --- docs/changelogs/0.5.x.md | 11 ----------- docs/changelogs/0.6.x.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 11 deletions(-) create mode 100644 docs/changelogs/0.6.x.md diff --git a/docs/changelogs/0.5.x.md b/docs/changelogs/0.5.x.md index 36d0869..c850675 100644 --- a/docs/changelogs/0.5.x.md +++ b/docs/changelogs/0.5.x.md @@ -4,17 +4,6 @@ All notable changes in the **0.5.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. - ## [0.5.1] - 2026-07-30 ### Fixed diff --git a/docs/changelogs/0.6.x.md b/docs/changelogs/0.6.x.md new file mode 100644 index 0000000..e6ce04a --- /dev/null +++ b/docs/changelogs/0.6.x.md @@ -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. + + From 669f89ac5a1c92079baa40b5a4f3b94023453c9a Mon Sep 17 00:00:00 2001 From: minixalpha Date: Sat, 1 Aug 2026 17:37:25 +0800 Subject: [PATCH 5/5] docs: add 0.6.x dev notes and generate the English version --- docs/dev_notes/en/0.6.x.md | 9 +++++++++ docs/dev_notes/zh-CN/0.6.x.md | 8 ++++++++ 2 files changed, 17 insertions(+) create mode 100644 docs/dev_notes/en/0.6.x.md create mode 100644 docs/dev_notes/zh-CN/0.6.x.md diff --git a/docs/dev_notes/en/0.6.x.md b/docs/dev_notes/en/0.6.x.md new file mode 100644 index 0000000..0680bf8 --- /dev/null +++ b/docs/dev_notes/en/0.6.x.md @@ -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. diff --git a/docs/dev_notes/zh-CN/0.6.x.md b/docs/dev_notes/zh-CN/0.6.x.md new file mode 100644 index 0000000..3ec17f0 --- /dev/null +++ b/docs/dev_notes/zh-CN/0.6.x.md @@ -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`,没想到动态字符居然是几个交替显示的盲文,实现起来也比较容易,就先实现吧。