CLI ANSI rendering fixes and Rich migration

NousResearch/hermes-agent · cluster 2262 · dashboard

Data as of

12 open items. Fix merged in #2448. Check it covers your case.

The short answer

1open issue
11open PRs
31items grouped
Known?
Yes. 31 related items grouped here, 4 issues already closed.
Fix event
A fix was merged to main in PR #2448. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #2448, #56641. That is separate from the fix event.
Still open
1 issue and 11 PRs.

These items address garbled or broken ANSI escape code output across CLI and TUI contexts, alongside a broad effort to migrate various CLI components (status, setup, config, uninstall, cron, curses UI) to Rich rendering.

Open issues1

  • Issue#59397[Bug]: ANSI Code Broken Render in CLI

Open PRs11

  • PR#9205fix(cli): make /tools slash commands ANSI-safe and idempotent
  • PR#59648fix(cli): fix broken ANSI code rendering (#59397)
  • PR#60073feat(cli): migrated cron cli to rich rendering
  • PR#61322feat(cli): Migrated Cron and Doctor CLI to Rich Rendering
  • PR#61954feat(cli): Migrated Status CLI to Rich Rendering
  • PR#64142feat(cli): Migrated GUI Uninstall CLI to Rich Rendering
  • PR#64307feat(cli): Migrated cli_output Tools to Rich Rendering
  • PR#67993feat(cli): Migrated Config CLI to Rich Rendering
  • PR#68466feat(cli): Migrated Curses UI to Rich Rendering
  • PR#69158feat(cli): Migrated Setup CLI to Rich Rendering
  • PR#87613fix(cli): stop garbled ANSI from welcome banner and deprecated-env warning (#2262)

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #2448 fix: resolve garbled ANSI escape codes in status printouts

    merged to main

    files: cli.py, run_agent.py

    Show the diff
    • cli.py
      @@ -1915,6 +1915,9 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No
                       tool_progress_callback=self._on_tool_progress,
                       stream_delta_callback=self._stream_delta if self.streaming_enabled else None,
                   )
      +            # Route agent status output through prompt_toolkit so ANSI escape
      +            # sequences aren't garbled by patch_stdout's StdoutProxy (#2262).
      +            self.agent._print_fn = _cprint
                   self._active_agent_route_signature = (
                       effective_model,
                       runtime.get("provider"),
      @@ -4238,13 +4241,18 @@ def _toggle_verbose(self):
                   elif not self.show_reasoning:
                       self.agent.reasoning_callback = None
       
      +        # Use raw ANSI codes via _cprint so the output is routed through
      +        # prompt_toolkit's renderer.  self.console.print() with Rich markup
      +        # writes directly to stdout which patch_stdout's StdoutProxy mangles
      +        # into garbled sequences like '?[33mTool progress: NEW?[0m' (#2262).
      +        from hermes_cli.colors import Colors as _Colors
               labels = {
      -            "off": "[dim]Tool progress: OFF[/] — silent mode, just the final response.",
      -            "new": "[yellow]Tool progress: NEW[/] — show each new tool (skip repeats).",
      -            "all": "[green]Tool progress: ALL[/] — show every tool call.",
      -            "verbose": "[bold green]Tool progress: VERBOSE[/] — full args, results, think blocks, and debug logs.",
      +            "off": f"{_Colors.DIM}Tool progress: OFF{_Colors.RESET} — silent mode, just the final response.",
      +            "new": f"{_Colors.YELLOW}Tool progress: NEW{_Colors.RESET} — show each new tool (skip repeats).",
      +            "all": f"{_Colors.GREEN}Tool progress: ALL{_Colors.RESET} — show every tool call.",
      +            "verbose": f"{_Colors.BOLD}{_Colors.GREEN}Tool progress: VERBOSE{_Colors.RESET} — full args, results, think blocks, and debug logs.",
               }
      -        self.console.print(labels.get(self.tool_progress_mode, ""))
      +        _cprint(labels.get(self.tool_progress_mode, ""))
       
           def _handle_reasoning_command(self, cmd: str):
               """Handle /reasoning — manage effort level and display toggle.
    • run_agent.py
      @@ -473,6 +473,11 @@ def __init__(
               self.quiet_mode = quiet_mode
               self.ephemeral_system_prompt = ephemeral_system_prompt
               self.platform = platform  # "cli", "telegram", "discord", "whatsapp", etc.
      +        # Pluggable print function — CLI replaces this with _cprint so that
      +        # raw ANSI status lines are routed through prompt_toolkit's renderer
      +        # instead of going directly to stdout where patch_stdout's StdoutProxy
      +        # would mangle the escape sequences.  None = use builtins.print.
      +        self._print_fn = None
               self.skip_context_files = skip_context_files
               self.pass_session_id = pass_session_id
               self.log_prefix_chars = log_prefix_chars
      @@ -1101,16 +1106,21 @@ def reset_session_state(self):
                   self.context_compressor.compression_count = 0
                   self.context_compressor._context_probed = False
           
      -    @staticmethod
      -    def _safe_print(*args, **kwargs):
      +    def _safe_print(self, *args, **kwargs):
               """Print that silently handles broken pipes / closed stdout.
       
               In headless environments (systemd, Docker, nohup) stdout may become
               unavailable mid-session.  A raw ``print()`` raises ``OSError`` which
               can crash cron jobs and lose completed work.
      +
      +        Internally routes through ``self._print_fn`` (default: builtin
      +        ``print``) so callers such as the CLI can inject a renderer that
      +        handles ANSI escape sequences properly (e.g. prompt_toolkit's
      +        ``print_formatted_text(ANSI(...))``) without touching this method.
               """
               try:
      -            print(*args, **kwargs)
      +            fn = self._print_fn or print
      +            fn(*args, **kwargs)
               except OSError:
                   pass
       
  • #13187 fix: salvage tier-1 bug fixes from PRs #7387, #9091, #6293, #13131

    merged to main

    files: cli.py, gateway/platforms/discord.py, gateway/platforms/telegram.py, hermes_cli/setup.py, scripts/release.py, tests/e2e/conftest.py, tests/e2e/test_discord_adapter.py, tests/hermes_cli/test_setup_openclaw_migration.py

  • #56641 fix(journey): crash on non-dict skill metadata + ANSI leaks in CLI/desktop

    merged to main

    files: agent/learning_graph.py, cli.py, hermes_cli/cli_commands_mixin.py, hermes_cli/journey.py, tests/agent/test_learning_graph.py, tests/hermes_cli/test_journey_render.py, tests/tui_gateway/test_slash_worker_ansi.py, tui_gateway/slash_worker.py

    Show the diff
    • agent/learning_graph.py
      @@ -48,8 +48,16 @@ def _frontmatter(text: str) -> dict[str, Any]:
               return {}
       
       
      +def _hermes_meta(fm: dict[str, Any]) -> dict[str, Any]:
      +    """``metadata.hermes`` as a dict, tolerant of the string-valued frontmatter
      +    that ``parse_frontmatter``'s malformed-YAML fallback produces."""
      +    meta = fm.get("metadata")
      +    hermes = meta.get("hermes") if isinstance(meta, dict) else None
      +    return hermes if isinstance(hermes, dict) else {}
      +
      +
       def _related(fm: dict[str, Any]) -> list[str]:
      -    raw = fm.get("related_skills") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("related_skills")
      +    raw = fm.get("related_skills") or _hermes_meta(fm).get("related_skills")
           if isinstance(raw, list):
               return [str(r).strip() for r in raw if str(r).strip()]
           if isinstance(raw, str):
      @@ -58,7 +66,7 @@ def _related(fm: dict[str, Any]) -> list[str]:
       
       
       def _category(fm: dict[str, Any], skill_md: Path) -> str:
      -    cat = fm.get("category") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("category")
      +    cat = fm.get("category") or _hermes_meta(fm).get("category")
           if cat:
               return str(cat)
           # …/skills/<category>/<skill>/SKILL.md
    • cli.py
      @@ -8640,21 +8640,7 @@ def process_command(self, command: str) -> bool:
               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":
    • hermes_cli/cli_commands_mixin.py
      @@ -294,6 +294,43 @@ def _handle_agents_command(self):
               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.
       
    • hermes_cli/journey.py
      @@ -171,6 +171,17 @@ def label_row(item) -> Text:
           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.")
    • tests/agent/test_learning_graph.py
      @@ -88,6 +88,30 @@ def test_memory_is_cards_split_on_separator(tmp_path):
           assert any(n["kind"] == "memory" for n in graph["nodes"])
       
       
      +def test_malformed_frontmatter_metadata_does_not_crash(tmp_path):
      +    """``parse_frontmatter``'s malformed-YAML fallback stores every value as a
      +    string, so ``metadata`` can be a str. The graph must tolerate that instead
      +    of crashing on chained ``.get()`` (the /journey base-CLI crash)."""
      +    skill_dir = tmp_path / "skills" / "misc" / "bad-skill"
      +    skill_dir.mkdir(parents=True)
      +    # The unterminated quote makes yaml_load raise → fallback → metadata is a str.
      +    skill_dir.joinpath("SKILL.md").write_text(
      +        '---\nname: bad-skill\nmetadata: not-a-dict\ndescription: "oops\n---\n# Bad\n',
      +        encoding="utf-8",
      +    )
      +
      +    node = learning_graph.build_skill_nodes([("profile", tmp_path / "skills")])["bad-skill"]
      +
      +    assert node.category == "misc"  # directory fallback, not a crash
      +    assert node.related == []
      +
      +
      +def test_hermes_meta_tolerates_non_dict():
      +    assert learning_graph._hermes_meta({"metadata": "junk"}) == {}
      +    assert learning_graph._hermes_meta({"metadata": {"hermes": "junk"}}) == {}
      +    assert learning_graph._hermes_meta({"metadata": {"hermes": {"category": "x"}}}) == {"category": "x"}
      +
      +
       def test_full_payload_shape_and_edge_integrity(tmp_path):
           home = tmp_path / ".hermes"
           home.mkdir()
    • 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)
    • tests/tui_gateway/test_slash_worker_ansi.py
      @@ -0,0 +1,23 @@
      +"""The slash worker feeds desktop chat bubbles, which render plain text — so
      +any ANSI a worker-routed command emits (e.g. /journey's own Rich Console) must
      +be stripped from the worker's return value."""
      +
      +from __future__ import annotations
      +
      +
      +class _FakeCLI:
      +    console = None
      +
      +    def process_command(self, cmd: str) -> None:
      +        import sys
      +
      +        sys.stdout.write("\x1b[38;2;1;2;3mcolored\x1b[0m plain")
      +
      +
      +def test_run_strips_ansi_from_output():
      +    from tui_gateway import slash_worker
      +
      +    out = slash_worker._run(_FakeCLI(), "/anything")
      +
      +    assert "\x1b[" not in out
      +    assert out == "colored plain"
    • tui_gateway/slash_worker.py
      @@ -102,7 +102,14 @@ def _run(cli: HermesCLI, command: str) -> str:
               if old is not None:
                   cli_mod._cprint = old
       
      -    return buf.getvalue().rstrip()
      +    # Desktop chat bubbles render plain text, not ANSI. A worker-routed command
      +    # that emits Rich color (e.g. /journey building its own Console, which picks
      +    # up truecolor from the gateway's inherited COLORTERM) would otherwise leak
      +    # raw escapes; strip them at the single choke point. (The TUI opens /journey
      +    # as an overlay, so it never travels this path.)
      +    from tools.ansi_strip import strip_ansi
      +
      +    return strip_ansi(buf.getvalue().rstrip())
       
       
       def main():

12 PRs closed without merging

From the close comments: stale or abandoned 5 · done elsewhere 2 · merged via another change 2 · superseded by another PR 2 · closed without comment 1

  • #2265 fix: resolve garbled ANSI escape codes in status printouts · merged via another change → #2448
  • #8630 fix(cli): render /tools list through prompt_toolkit · done elsewhere → 9de4a38
  • #9091 fix(tui): make "/tools list" show real colors instead of "?[32m" etc.… · merged via another change → #13187
  • #11335 fix(tui): replace bright yellow with dark_goldenrod for light bg contrast · stale or abandoned
  • #56542 fix(tui): strip ANSI from slash-command output before returning to chat · superseded by another PR → #56641
Show 7 more
  • #56546 fix(tui): strip ANSI from slash worker output · superseded by another PR → #56641
  • #59418 fix: enable ANSI rendering on Windows consoles · done elsewhere → cfeffe8
  • #59750 fix(Windows): enable VT processing for ANSI escape sequences · stale or abandoned
  • #59835 fix(bootstrap): enable Windows VT processing for ANSI escape codes · stale or abandoned
  • #59951 fix(cli): enable Windows Virtual Terminal Processing for ANSI colors · stale or abandoned
  • #60011 fix(setup): enable Windows VT processing for ANSI escape codes · stale or abandoned
  • #63705 feat(cli): Migrated Uninstall CLI to Rich Rendering · closed without comment
Closed issues · 4
  • Closed#2262[Bug]: Garbled ANSI codes from status printouts like `\verbose` and `approaching compaction`
  • Closed#4128[Bug]: /tools list shows raw terminal escape codes
  • Closed#9017[Feature]: Improve the display format of the /tools list command
  • Closed#56533/journey slash command leaks raw ANSI escape codes in TUI and Desktop chat views
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.09: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
dashboard
Merged PRs
#2448, #13187, #56641
Closed issues
4
Attempts
12 PRs closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.