fix(cli): render /journey color instead of leaking raw ANSI

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`.
This commit is contained in:
Brooklyn Nicholson 2026-07-01 16:25:48 -05:00
parent ec319e4e3e
commit 428b9a0c42
4 changed files with 92 additions and 19 deletions

16
cli.py
View file

@ -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":

View file

@ -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.

View file

@ -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.")

View file

@ -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)