mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
CJK and emoji glyphs render as two terminal cells but JS String#length and the model's own padding count them as one, so any markdown table with Chinese / Japanese / Korean cells drifts right per row when a real terminal renders it. Both surfaces fix this with a display-cell width measurement (wcswidth on the Python side, stringWidth on the TUI side). Changes: - agent/markdown_tables.py: new helper. realign_markdown_tables(text) detects markdown table blocks (header + |---| divider) and rewrites the row padding using wcwidth.wcswidth so every pipe and dash lines up across rows. No-op on text without tables. - cli.py: hook the helper into _render_final_assistant_content for strip / render modes (raw passes through untouched), and into the streaming line emitter so live token-by-token rendering also produces aligned tables. A small two-buffer state machine in _emit_stream_text holds table rows until the block ends, then flushes them through the realigner so all rows pad to a single per-column width. - ui-tui/src/components/markdown.tsx: renderTable now uses stringWidth (Bun.stringWidth fast path + East-Asian-width-aware fallback, already memoised in @hermes/ink) instead of UTF-16 String#length for both column-width measurement and per-cell padding. Drops the comment that documented the bug as a deliberate limitation. Validation: - New tests/agent/test_markdown_tables.py (11): every rebuilt block shares pipe column offsets across rows for pure CJK, mixed CJK+emoji, ragged-row, and multi-table inputs. - Updated tests/cli/test_cli_markdown_rendering.py: the existing strip-mode test asserted exact whitespace; rewritten to assert the alignment contract (cell content survives + every rendered row shares pipe offsets). - New ui-tui markdown.test.ts case (1): rendered column-2 start offset is identical for the header + every body row, including the CJK row that drifted before the fix. - Live: hermes chat -q with the user-reported screenshot prompt now produces a perfectly aligned table on the wire (header, divider, 4 body rows including '通义千问', all pipes at identical columns).
181 lines
5.6 KiB
Python
181 lines
5.6 KiB
Python
from io import StringIO
|
|
|
|
from rich.console import Console
|
|
from rich.markdown import Markdown
|
|
|
|
from cli import _render_final_assistant_content
|
|
|
|
|
|
def _render_to_text(renderable) -> str:
|
|
buf = StringIO()
|
|
Console(file=buf, width=80, force_terminal=False, color_system=None).print(renderable)
|
|
return buf.getvalue()
|
|
|
|
|
|
def test_final_assistant_content_uses_markdown_renderable():
|
|
renderable = _render_final_assistant_content("# Title\n\n- one\n- two")
|
|
|
|
assert isinstance(renderable, Markdown)
|
|
output = _render_to_text(renderable)
|
|
assert "Title" in output
|
|
assert "one" in output
|
|
assert "two" in output
|
|
|
|
|
|
def test_final_assistant_content_preserves_windows_hidden_dir_paths():
|
|
renderable = _render_final_assistant_content(
|
|
r"D:\Projects\SourceCode\hermes-agent\.ai\skills" + "\\"
|
|
)
|
|
|
|
output = _render_to_text(renderable)
|
|
assert r"D:\Projects\SourceCode\hermes-agent\.ai\skills" + "\\" in output
|
|
|
|
|
|
def test_final_assistant_content_keeps_non_path_markdown_escapes():
|
|
renderable = _render_final_assistant_content(r"1\. Not an ordered list")
|
|
|
|
output = _render_to_text(renderable)
|
|
assert "1. Not an ordered list" in output
|
|
assert r"1\." not in output
|
|
|
|
|
|
def test_final_assistant_content_strips_ansi_before_markdown_rendering():
|
|
renderable = _render_final_assistant_content("\x1b[31m# Title\x1b[0m")
|
|
|
|
output = _render_to_text(renderable)
|
|
assert "Title" in output
|
|
assert "\x1b" not in output
|
|
|
|
|
|
def test_final_assistant_content_can_strip_markdown_syntax():
|
|
renderable = _render_final_assistant_content(
|
|
"***Bold italic***\n~~Strike~~\n- item\n# Title\n`code`",
|
|
mode="strip",
|
|
)
|
|
|
|
output = _render_to_text(renderable)
|
|
assert "Bold italic" in output
|
|
assert "Strike" in output
|
|
assert "item" in output
|
|
assert "Title" in output
|
|
assert "code" in output
|
|
assert "***" not in output
|
|
assert "~~" not in output
|
|
assert "`" not in output
|
|
|
|
|
|
def test_strip_mode_preserves_lists():
|
|
renderable = _render_final_assistant_content(
|
|
"**Formatting**\n- Ran prettier\n- Files changed\n- Verified clean",
|
|
mode="strip",
|
|
)
|
|
|
|
output = _render_to_text(renderable)
|
|
assert "- Ran prettier" in output
|
|
assert "- Files changed" in output
|
|
assert "- Verified clean" in output
|
|
assert "**" not in output
|
|
|
|
|
|
def test_strip_mode_preserves_ordered_lists():
|
|
renderable = _render_final_assistant_content(
|
|
"1. First item\n2. Second item\n3. Third item",
|
|
mode="strip",
|
|
)
|
|
|
|
output = _render_to_text(renderable)
|
|
assert "1. First" in output
|
|
assert "2. Second" in output
|
|
assert "3. Third" in output
|
|
|
|
|
|
def test_strip_mode_preserves_blockquotes():
|
|
renderable = _render_final_assistant_content(
|
|
"> This is quoted text\n> Another quoted line",
|
|
mode="strip",
|
|
)
|
|
|
|
output = _render_to_text(renderable)
|
|
assert "> This is quoted" in output
|
|
assert "> Another quoted" in output
|
|
|
|
|
|
def test_strip_mode_preserves_checkboxes():
|
|
renderable = _render_final_assistant_content(
|
|
"- [ ] Todo item\n- [x] Done item",
|
|
mode="strip",
|
|
)
|
|
|
|
output = _render_to_text(renderable)
|
|
assert "- [ ] Todo" in output
|
|
assert "- [x] Done" in output
|
|
|
|
|
|
def test_strip_mode_preserves_table_structure_while_cleaning_cell_markdown():
|
|
renderable = _render_final_assistant_content(
|
|
"| Syntax | Example |\n|---|---|\n| Bold | `**bold**` |\n| Strike | `~~strike~~` |",
|
|
mode="strip",
|
|
)
|
|
|
|
output = _render_to_text(renderable)
|
|
|
|
# Inline cell markdown is stripped (the contract this test enforces).
|
|
assert "**" not in output
|
|
assert "~~" not in output
|
|
assert "`" not in output
|
|
|
|
# Cell *content* survives, even if the surrounding whitespace was
|
|
# rewritten by the wcwidth-aware re-aligner. Asserting on bare
|
|
# cell text keeps this test focused on the strip behaviour rather
|
|
# than snapshotting incidental column padding (which is what the
|
|
# CJK-alignment fix changes).
|
|
assert "Syntax" in output
|
|
assert "Example" in output
|
|
assert "Bold" in output and "bold" in output
|
|
assert "Strike" in output and "strike" in output
|
|
|
|
# Structural sanity: the table still renders as pipe-bordered rows
|
|
# (header + divider + 2 body rows).
|
|
body_rows = [ln for ln in output.splitlines() if ln.strip().startswith("|")]
|
|
assert len(body_rows) == 4
|
|
|
|
# Every rendered table row shares the same pipe column offsets — the
|
|
# alignment guarantee from realign_markdown_tables.
|
|
pipe_cols = [
|
|
[i for i, ch in enumerate(row) if ch == "|"] for row in body_rows
|
|
]
|
|
assert all(p == pipe_cols[0] for p in pipe_cols), (
|
|
"table rows misaligned after strip-mode rendering:\n"
|
|
+ "\n".join(body_rows)
|
|
)
|
|
|
|
|
|
def test_final_assistant_content_can_leave_markdown_raw():
|
|
renderable = _render_final_assistant_content("***Bold italic***", mode="raw")
|
|
|
|
output = _render_to_text(renderable)
|
|
assert "***Bold italic***" in output
|
|
|
|
|
|
def test_strip_mode_preserves_intraword_underscores_in_snake_case_identifiers():
|
|
renderable = _render_final_assistant_content(
|
|
"Let me look at test_case_with_underscores and SOME_CONST "
|
|
"then /tmp/snake_case_dir/file_with_name.py",
|
|
mode="strip",
|
|
)
|
|
|
|
output = _render_to_text(renderable)
|
|
assert "test_case_with_underscores" in output
|
|
assert "SOME_CONST" in output
|
|
assert "snake_case_dir" in output
|
|
assert "file_with_name" in output
|
|
|
|
|
|
def test_strip_mode_still_strips_boundary_underscore_emphasis():
|
|
renderable = _render_final_assistant_content(
|
|
"say _hi_ and __bold__ now",
|
|
mode="strip",
|
|
)
|
|
|
|
output = _render_to_text(renderable)
|
|
assert "say hi and bold now" in output
|