From 428b9a0c42dddadd19bddb259b3418fa39177be6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 1 Jul 2026 16:25:48 -0500 Subject: [PATCH] fix(cli): render /journey color instead of leaking raw ANSI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the interactive CLI, /journey dispatched straight to `args.func(args)`, letting Rich write ANSI to stdout — which patch_stdout's StdoutProxy passes through as literal `?[38;2;…m` garbage. Route the read-only views (default + `list`) through a captured, force-color Console and re-emit via `_cprint` (prompt_toolkit's ANSI parser), matching the `ChatConsole` idiom. `delete`/`edit` stay on real stdio since they prompt / open `$EDITOR`. --- cli.py | 16 +---------- hermes_cli/cli_commands_mixin.py | 37 ++++++++++++++++++++++++ hermes_cli/journey.py | 20 ++++++++++--- tests/hermes_cli/test_journey_render.py | 38 +++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 19 deletions(-) create mode 100644 tests/hermes_cli/test_journey_render.py diff --git a/cli.py b/cli.py index e2069486fad..b5900da7154 100644 --- a/cli.py +++ b/cli.py @@ -8640,21 +8640,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): elif canonical == "agents": self._handle_agents_command() elif canonical == "journey": - try: - import argparse - import shlex - - from hermes_cli.journey import register_cli as _register_journey_cli - - parser = argparse.ArgumentParser(prog="/journey", add_help=False) - _register_journey_cli(parser) - argv = shlex.split(cmd_original.split(None, 1)[1]) if len(cmd_original.split(None, 1)) > 1 else [] - args = parser.parse_args(argv) - args.func(args) - except SystemExit: - pass - except Exception as exc: - _cprint(f" /journey failed: {exc}") + self._handle_journey_command(cmd_original) elif canonical == "background": self._handle_background_command(cmd_original) elif canonical == "queue": diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 70f82f2ea50..c4004c2ddee 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -294,6 +294,43 @@ class CLICommandsMixin: agent_running = getattr(self, "_agent_running", False) _cprint(f" Agent: {'running' if agent_running else 'idle'}") + def _handle_journey_command(self, cmd_original: str) -> None: + """Handle /journey — the learning timeline (see `hermes journey`). + + The read-only views (default + ``list``) render Rich color, which + patch_stdout would swallow as raw escapes; capture with forced ANSI and + re-emit through ``_cprint``. ``delete``/``edit`` are interactive + (confirm prompt / ``$EDITOR``) so they keep the real stdio. + """ + import argparse + import io + import shlex + from contextlib import redirect_stdout + + from cli import _cprint + from hermes_cli.journey import register_cli + + parser = argparse.ArgumentParser(prog="/journey", add_help=False) + register_cli(parser) + rest = cmd_original.split(None, 1) + try: + args = parser.parse_args(shlex.split(rest[1]) if len(rest) > 1 else []) + except SystemExit: + return + + interactive = getattr(args, "journey_action", None) in ("delete", "edit") + try: + if interactive: + args.func(args) + return + args.force_color = True + buf = io.StringIO() + with redirect_stdout(buf): + args.func(args) + _cprint(buf.getvalue().rstrip("\n")) + except Exception as exc: + _cprint(f" /journey failed: {exc}") + def _handle_paste_command(self): """Handle /paste — explicitly check clipboard for an image. diff --git a/hermes_cli/journey.py b/hermes_cli/journey.py index 11d9bb4fd66..1e404baa224 100644 --- a/hermes_cli/journey.py +++ b/hermes_cli/journey.py @@ -171,6 +171,17 @@ def _frame_renderable(payload, *, cols, rows, reveal, color): return Group(*parts) +def _console(*, color: bool, width: Optional[int] = None, force: bool = False): + """A Rich console. ``force`` emits truecolor ANSI even into a captured + stream — the interactive CLI grabs that output and re-renders it through + prompt_toolkit (raw escapes to a real terminal would otherwise be + swallowed). Mirrors the ``ChatConsole`` idiom in ``cli.py``.""" + from rich.console import Console + + extra = {"force_terminal": True, "color_system": "truecolor"} if force else {} + return Console(no_color=not color, width=width, **extra) + + def _cmd_show(args: argparse.Namespace) -> int: from rich.console import Console @@ -183,7 +194,7 @@ def _cmd_show(args: argparse.Namespace) -> int: payload = _build_payload() color = not bool(getattr(args, "no_color", False)) cols, rows = _term_size(getattr(args, "width", None), getattr(args, "height", None)) - console = Console(no_color=not color, width=cols) + console = _console(color=color, width=cols, force=bool(getattr(args, "force_color", False))) if not payload.get("nodes"): console.print( @@ -226,11 +237,9 @@ def _clamp(v: float, lo: float, hi: float) -> float: def _cmd_list(args: argparse.Namespace) -> int: - from rich.console import Console - from agent.learning_graph_render import format_date - console = Console(no_color=bool(getattr(args, "no_color", False))) + console = _console(color=not bool(getattr(args, "no_color", False)), force=bool(getattr(args, "force_color", False))) nodes = sorted(_build_payload().get("nodes", []), key=lambda n: n.get("timestamp") or 0) if not nodes: console.print("[grey62]No learning yet.[/grey62]") @@ -315,6 +324,8 @@ def register_cli(parent: argparse.ArgumentParser) -> None: parent.add_argument("--width", type=int, default=None, help="Override render width in columns.") parent.add_argument("--height", type=int, default=None, help="Override render height in rows.") parent.add_argument("--no-color", action="store_true", help="Disable color output.") + # Force ANSI even when stdout is captured — the interactive CLI re-renders it. + parent.add_argument("--force-color", action="store_true", help=argparse.SUPPRESS) parent.add_argument("--json", action="store_true", help="Print the raw graph payload as JSON and exit.") parent.set_defaults(func=_cmd_show) @@ -322,6 +333,7 @@ def register_cli(parent: argparse.ArgumentParser) -> None: p_list = sub.add_parser("list", help="List node ids (for delete/edit).") p_list.add_argument("--no-color", action="store_true") + p_list.add_argument("--force-color", action="store_true", help=argparse.SUPPRESS) p_list.set_defaults(func=_cmd_list) p_del = sub.add_parser("delete", help="Delete a learned skill (archived) or memory by node id.") diff --git a/tests/hermes_cli/test_journey_render.py b/tests/hermes_cli/test_journey_render.py new file mode 100644 index 00000000000..f456c382521 --- /dev/null +++ b/tests/hermes_cli/test_journey_render.py @@ -0,0 +1,38 @@ +"""Behavior contracts for /journey output routing. + +The interactive CLI captures Rich output and re-renders it through +prompt_toolkit, so it needs forced ANSI (``--force-color``); chat surfaces +render plain text, so the default captured path must stay escape-free. +""" + +from __future__ import annotations + +import argparse +import contextlib +import io + + +def _capture(argv: list[str], *, force: bool) -> str: + from hermes_cli.journey import register_cli + + parser = argparse.ArgumentParser(add_help=False) + register_cli(parser) + args = parser.parse_args(argv) + if force: + args.force_color = True + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + args.func(args) + return buf.getvalue() + + +def test_force_color_emits_ansi_for_reemission(): + assert "\x1b[" in _capture([], force=True) + assert "\x1b[" in _capture(["list"], force=True) + + +def test_default_capture_is_plain_for_chat_bubbles(): + # Rich auto-detects the StringIO as non-tty → no color, no raw escapes. + assert "\x1b[" not in _capture([], force=False) + assert "\x1b[" not in _capture(["list"], force=False)