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
- 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.
Next step: read #2448, then check whether your report is one of the 1 still open.
Read #2448Pick up a Lab taskThese 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
-
#2448 fix: resolve garbled ANSI escape codes in status printouts
files:
cli.py,run_agent.pyShow 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
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
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.pyShow 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.00means 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