mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
4610551d74
commit
c026d66efc
3 changed files with 222 additions and 24 deletions
10
cli.py
10
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 <session id or title> to continue where you left off.")
|
||||
print()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
133
tests/hermes_cli/test_session_display_width.py
Normal file
133
tests/hermes_cli/test_session_display_width.py
Normal 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}"
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue