diff --git a/cli.py b/cli.py index b8f34c9bb5..487b67bd6c 100644 --- a/cli.py +++ b/cli.py @@ -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 to continue where you left off.") print() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c73344be4e..06795f2264 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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: diff --git a/tests/hermes_cli/test_session_display_width.py b/tests/hermes_cli/test_session_display_width.py new file mode 100644 index 0000000000..f7254c02f8 --- /dev/null +++ b/tests/hermes_cli/test_session_display_width.py @@ -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}" +