From 4e556cc06497202acd1b8f54fb06ec7b64d53db2 Mon Sep 17 00:00:00 2001 From: Alfredo Arenas Date: Sat, 18 Apr 2026 00:09:20 -0600 Subject: [PATCH 1/2] fix(cli): respect sys.stdout.isatty() in stream renderer (#3265) --- nanobot/cli/stream.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/nanobot/cli/stream.py b/nanobot/cli/stream.py index 9454edac64..addf4fe7c1 100644 --- a/nanobot/cli/stream.py +++ b/nanobot/cli/stream.py @@ -18,7 +18,17 @@ def _make_console() -> Console: - return Console(file=sys.stdout, force_terminal=True) + """Create a Console that emits plain text when stdout is not a TTY. + + Rich's spinner, Live render, and cursor-visibility escape codes all + key off ``Console.is_terminal``. Forcing ``force_terminal=True`` overrode + the ``isatty()`` check and caused control sequences (``\\x1b[?25l``, + braille spinner frames) to pollute programmatic consumers such as + ``docker exec -i`` or pipes, even with ``NO_COLOR`` or ``TERM=dumb``. + Deferring to ``isatty()`` keeps Rich output in interactive terminals + and plain text everywhere else (#3265). + """ + return Console(file=sys.stdout, force_terminal=sys.stdout.isatty()) class ThinkingSpinner: From 40e60f512474f17f2c538c0425d13ce2cb80783d Mon Sep 17 00:00:00 2001 From: Alfredo Arenas Date: Sat, 18 Apr 2026 08:23:20 -0600 Subject: [PATCH 2/2] test(cli): update _make_console tests for isatty-based fix (#3265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old test `test_make_console_uses_force_terminal` hardcoded `force_terminal is True`, which contradicts the fix: we now defer to sys.stdout.isatty() so piped / non-TTY output gets plain text instead of ANSI escape codes. Split into two tests covering both branches: - test_make_console_force_terminal_when_stdout_is_tty: TTY path (force_terminal=True, rich output) - test_make_console_force_terminal_false_when_stdout_is_not_tty: non-TTY path (force_terminal=False, plain text) — regression guard for the bug reported in #3265 Co-authored with Claude Opus 4.7 --- tests/cli/test_cli_input.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/cli/test_cli_input.py b/tests/cli/test_cli_input.py index b772293bc5..0e1235b86c 100644 --- a/tests/cli/test_cli_input.py +++ b/tests/cli/test_cli_input.py @@ -167,7 +167,19 @@ def test_stream_renderer_stop_for_input_stops_spinner(): spinner.stop.assert_called_once() -def test_make_console_uses_force_terminal(): - """Console should be created with force_terminal=True for proper ANSI handling.""" - console = stream_mod._make_console() - assert console._force_terminal is True +def test_make_console_force_terminal_when_stdout_is_tty(): + """Console should set force_terminal=True when stdout is a TTY (rich output).""" + import sys + with patch.object(sys.stdout, "isatty", return_value=True): + console = stream_mod._make_console() + assert console._force_terminal is True + + +def test_make_console_force_terminal_false_when_stdout_is_not_tty(): + """Console should set force_terminal=False when stdout is not a TTY so that + ANSI escape codes (cursor visibility, braille spinner frames) don't pollute + piped output such as `docker exec -i` (#3265).""" + import sys + with patch.object(sys.stdout, "isatty", return_value=False): + console = stream_mod._make_console() + assert console._force_terminal is False