fix(cli): align session list table for CJK terminal width

Replace str.ljust-based columns with east_asian_width-aware padding so
`hermes sessions list` and inline /history session picks line up when
titles/previews mix Chinese and ASCII. Share one formatter via
print_sessions_table in hermes_cli/main.py.

test(cli): add coverage for display-width helpers in
tests/hermes_cli/test_session_display_width.py.
This commit is contained in:
GatewayJ 2026-04-15 10:19:55 +08:00
parent 4610551d74
commit c026d66efc
3 changed files with 222 additions and 24 deletions

10
cli.py
View file

@ -3990,7 +3990,7 @@ class HermesCLI:
if not sessions:
return False
from hermes_cli.main import _relative_time
from hermes_cli.main import print_sessions_table
print()
if reason == "history":
@ -3998,13 +3998,7 @@ class HermesCLI:
else:
print(" Recent sessions:")
print()
print(f" {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
print(f" {'' * 32} {'' * 40} {'' * 13} {'' * 24}")
for session in sessions:
title = (session.get("title") or "")[:30]
preview = (session.get("preview") or "")[:38]
last_active = _relative_time(session.get("last_active"))
print(f" {title:<32} {preview:<40} {last_active:<13} {session['id']}")
print_sessions_table(sessions, has_titles=True, indent=" ")
print()
print(" Use /resume <session id or title> to continue where you left off.")
print()

View file

@ -173,6 +173,92 @@ from hermes_constants import OPENROUTER_BASE_URL
logger = logging.getLogger(__name__)
def _display_cell_width(ch: str) -> int:
"""Return terminal column width for a single character (CJK-aware)."""
from unicodedata import east_asian_width
# Fullwidth and wide East Asian — occupy two cells in typical terminals.
return 2 if east_asian_width(ch) in ("F", "W") else 1
def _text_display_width(s: str) -> int:
return sum(_display_cell_width(c) for c in s)
def _fit_display_width(s: str, max_cells: int, *, ellipsis: str = "") -> str:
"""Truncate ``s`` to at most ``max_cells`` display cells; append ellipsis if truncated."""
if _text_display_width(s) <= max_cells:
return s
ell_w = _text_display_width(ellipsis)
if max_cells <= ell_w:
return ellipsis[:max_cells] if max_cells > 0 else ""
out: list[str] = []
w = 0
for ch in s:
cw = _display_cell_width(ch)
if w + cw + ell_w > max_cells:
break
out.append(ch)
w += cw
return "".join(out) + ellipsis
def _pad_display_right(s: str, width: int) -> str:
"""Pad ``s`` on the right to exactly ``width`` display cells (truncate first if needed)."""
if _text_display_width(s) > width:
s = _fit_display_width(s, width)
pad = width - _text_display_width(s)
return s + (" " * max(0, pad))
def print_sessions_table(
sessions: list,
*,
has_titles: bool,
indent: str = "",
) -> None:
"""Print session rows with terminal-accurate column alignment (CJK-safe).
Plain ``str.ljust`` / f-string padding counts Unicode code points, not
terminal cell width, which breaks mixed Chinese/Latin rows.
"""
def _row_titled(title: str, preview: str, last_active: str, sid: str) -> str:
return (
f"{_pad_display_right(title, 32)} {_pad_display_right(preview, 40)} "
f"{_pad_display_right(last_active, 13)} {sid}"
)
def _row_plain(preview: str, last_active: str, src: str, sid: str) -> str:
return (
f"{_pad_display_right(preview, 50)} {_pad_display_right(last_active, 13)} "
f"{_pad_display_right(src, 6)} {sid}"
)
if has_titles:
header = _row_titled("Title", "Preview", "Last Active", "ID")
rule_w = min(_text_display_width(header) + 4, 120)
print(f"{indent}{header}")
print(f"{indent}{'' * rule_w}")
for s in sessions:
title = s.get("title") or ""
preview = s.get("preview") or ""
last_active = _relative_time(s.get("last_active"))
sid = s.get("id") or ""
print(f"{indent}{_row_titled(title, preview, last_active, sid)}")
else:
header = _row_plain("Preview", "Last Active", "Src", "ID")
rule_w = min(_text_display_width(header) + 4, 120)
print(f"{indent}{header}")
print(f"{indent}{'' * rule_w}")
for s in sessions:
preview = s.get("preview") or ""
last_active = _relative_time(s.get("last_active"))
src = s.get("source") or ""
sid = s.get("id") or ""
print(f"{indent}{_row_plain(preview, last_active, src, sid)}")
def _relative_time(ts) -> str:
"""Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
if not ts:
@ -5576,22 +5662,7 @@ Examples:
print("No sessions found.")
return
has_titles = any(s.get("title") for s in sessions)
if has_titles:
print(f"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
print("" * 110)
else:
print(f"{'Preview':<50} {'Last Active':<13} {'Src':<6} {'ID'}")
print("" * 95)
for s in sessions:
last_active = _relative_time(s.get("last_active"))
preview = s.get("preview", "")[:38] if has_titles else s.get("preview", "")[:48]
if has_titles:
title = (s.get("title") or "")[:30]
sid = s["id"]
print(f"{title:<32} {preview:<40} {last_active:<13} {sid}")
else:
sid = s["id"]
print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}")
print_sessions_table(sessions, has_titles=has_titles)
elif action == "export":
if args.session_id:

View file

@ -0,0 +1,133 @@
"""Tests for CJK-aware session list column padding (hermes_cli.main display helpers)."""
from unittest.mock import patch
from hermes_cli.main import (
_fit_display_width,
_pad_display_right,
_text_display_width,
print_sessions_table,
)
class TestTextDisplayWidth:
def test_empty(self) -> None:
assert _text_display_width("") == 0
def test_ascii(self) -> None:
assert _text_display_width("abc") == 3
def test_wide_cjk(self) -> None:
# Typical Han characters are wide (W) — two terminal cells each.
assert _text_display_width("中文") == 4
def test_mixed(self) -> None:
assert _text_display_width("a中") == 1 + 2
class TestFitDisplayWidth:
def test_no_truncation_when_fits(self) -> None:
assert _fit_display_width("hello", 10) == "hello"
def test_truncates_with_ellipsis(self) -> None:
out = _fit_display_width("abcdefghij", 5)
assert out.endswith("")
assert _text_display_width(out) <= 5
def test_cjk_truncation_respects_cells(self) -> None:
s = "一二三四五六"
out = _fit_display_width(s, 5)
assert out.endswith("")
assert _text_display_width(out) <= 5
def test_max_cells_zero_returns_empty(self) -> None:
assert _fit_display_width("hello", 0) == ""
class TestPadDisplayRight:
def test_pads_ascii_to_width(self) -> None:
assert _pad_display_right("hi", 8) == "hi "
assert _text_display_width(_pad_display_right("hi", 8)) == 8
def test_pads_mixed_to_display_width(self) -> None:
# "a" + two wide chars = 1 + 4 = 5 cells; pad to 8 → 3 spaces
got = _pad_display_right("a中文", 8)
assert _text_display_width(got) == 8
assert got.startswith("a中文")
def test_truncates_before_pad_when_too_long(self) -> None:
long = "x" * 80
got = _pad_display_right(long, 8)
assert _text_display_width(got) == 8
assert got.endswith("")
class TestPrintSessionsTable:
"""Smoke + fixed-clock integration so Last Active is deterministic."""
def test_with_titles_prints_header_and_row(self, capsys) -> None:
fixed_now = 1_700_000_000.0
with patch("hermes_cli.main._time.time", return_value=fixed_now):
print_sessions_table(
[
{
"title": "Rust 项目",
"preview": "分析当前",
"last_active": fixed_now - 400,
"id": "20260415_testsession",
}
],
has_titles=True,
)
out = capsys.readouterr().out
assert "Title" in out and "Preview" in out and "Last Active" in out
assert "Rust 项目" in out
assert "20260415_testsession" in out
assert "6m ago" in out
lines = [ln for ln in out.splitlines() if ln.strip()]
assert len(lines) >= 3
def test_without_titles_includes_source(self, capsys) -> None:
fixed_now = 1_700_000_000.0
with patch("hermes_cli.main._time.time", return_value=fixed_now):
print_sessions_table(
[
{
"preview": "hello",
"last_active": fixed_now - 90,
"source": "cli",
"id": "id_only",
}
],
has_titles=False,
)
out = capsys.readouterr().out
assert "Preview" in out and "Src" in out
assert "cli" in out and "id_only" in out
def test_indent_prefix(self, capsys) -> None:
fixed_now = 1_700_000_000.0
with patch("hermes_cli.main._time.time", return_value=fixed_now):
print_sessions_table(
[{"title": "t", "preview": "p", "last_active": fixed_now, "id": "i"}],
has_titles=True,
indent=" ",
)
out = capsys.readouterr().out
for line in out.splitlines():
if line.strip():
assert line.startswith(" ")
def test_row_columns_have_expected_display_widths(self) -> None:
"""Guardrail: titled row layout matches fixed column budgets + spaces."""
title = _pad_display_right("ColdStore Rust 项目", 32)
preview = _pad_display_right("分析当前项目", 40)
last = _pad_display_right("6m ago", 13)
sid = "20260415_081027_6b979d"
line = f"{title} {preview} {last} {sid}"
assert _text_display_width(title) == 32
assert _text_display_width(preview) == 40
assert _text_display_width(last) == 13
# Single space between padded columns, then unbounded id.
assert line == f"{title} {preview} {last} {sid}"