diff --git a/cli.py b/cli.py index 6ee25e2fcec..ad0a5050aa2 100644 --- a/cli.py +++ b/cli.py @@ -6216,6 +6216,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): preview_limit = 400 visible_index = 0 hidden_tool_messages = 0 + show_ts = bool(getattr(self, "show_timestamps", False)) + + def _ts_suffix(message: dict) -> str: + # Messages restored from SessionDB carry a unix `timestamp`; live + # unsaved turns may not. Only annotate when both the toggle is on + # and the turn actually has a stored time — never fabricate one. + if not show_ts: + return "" + ts = message.get("timestamp") + if not ts: + return "" + try: + from datetime import datetime + return f" [{datetime.fromtimestamp(float(ts)).strftime('%H:%M')}]" + except (ValueError, OSError, TypeError): + return "" def flush_tool_summary(): nonlocal hidden_tool_messages @@ -6249,13 +6265,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): content_text = "" if content is None else str(content) if role == "user": - print(f"\n [You #{visible_index}]") + print(f"\n [You #{visible_index}]{_ts_suffix(msg)}") print( f" {content_text[:preview_limit]}{'...' if len(content_text) > preview_limit else ''}" ) continue - print(f"\n [Hermes #{visible_index}]") + print(f"\n [Hermes #{visible_index}]{_ts_suffix(msg)}") tool_calls = msg.get("tool_calls") or [] if content_text: preview = content_text[:preview_limit] @@ -7978,6 +7994,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._status_bar_visible = not self._status_bar_visible state = "visible" if self._status_bar_visible else "hidden" self._console_print(f" Status bar {state}") + elif canonical == "timestamps": + self._handle_timestamps_command(cmd_original) elif canonical == "verbose": self._toggle_verbose() elif canonical == "footer": diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index d93897d2609..831cde7c85b 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2086,6 +2086,56 @@ class CLICommandsMixin: else: _cprint(" Failed to save runtime_footer setting to config.yaml") + def _handle_timestamps_command(self, cmd_original: str) -> None: + """Toggle or inspect ``display.timestamps`` from the CLI. + + When on, submitted and streamed message labels carry an ``[HH:MM]`` + suffix and ``/history`` prefixes each turn with its time (for turns + that carry a stored timestamp). + + Usage: + /timestamps → toggle + /timestamps on|off → explicit + /timestamps status → show current state + """ + from cli import _cprint, save_config_value + from hermes_cli.colors import Colors as _Colors + + arg = "" + try: + parts = (cmd_original or "").strip().split(None, 1) + if len(parts) > 1: + arg = parts[1].strip().lower() + except Exception: + arg = "" + + current = bool(getattr(self, "show_timestamps", False)) + + if arg in {"status", "?"}: + state = "ON" if current else "OFF" + _cprint(f" {_Colors.BOLD}Message timestamps:{_Colors.RESET} {state}") + return + + if arg in {"on", "enable", "true", "1"}: + new_state = True + elif arg in {"off", "disable", "false", "0"}: + new_state = False + elif arg == "": + new_state = not current + else: + _cprint(" Usage: /timestamps [on|off|status]") + return + + self.show_timestamps = new_state + if save_config_value("display.timestamps", new_state): + state = ( + f"{_Colors.GREEN}ON{_Colors.RESET}" if new_state + else f"{_Colors.DIM}OFF{_Colors.RESET}" + ) + _cprint(f" Message timestamps: {state}") + else: + _cprint(" Failed to save timestamps setting to config.yaml") + def _handle_reasoning_command(self, cmd: str): """Handle /reasoning — manage effort level and display toggle. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index d5cc9cee8c1..d9d9d1b3579 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -135,6 +135,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="[name]"), CommandDef("statusbar", "Toggle the context/model status bar", "Configuration", cli_only=True, aliases=("sb",)), + CommandDef("timestamps", "Toggle [HH:MM] timestamps on messages and /history", "Configuration", + cli_only=True, args_hint="[on|off|status]", + subcommands=("on", "off", "status"), aliases=("ts",)), CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose", "Configuration", cli_only=True, gateway_config_gate="display.tool_progress_command"), diff --git a/tests/hermes_cli/test_timestamps_command.py b/tests/hermes_cli/test_timestamps_command.py new file mode 100644 index 00000000000..79784e85f87 --- /dev/null +++ b/tests/hermes_cli/test_timestamps_command.py @@ -0,0 +1,98 @@ +"""Tests for the CLI `/timestamps` toggle and timestamps in `/history`. + +`display.timestamps` already drove the live `[HH:MM]` label suffix on +submitted/streamed messages but had no runtime toggle and `/history` +ignored it. These assert the new `/timestamps` command flips and persists +the flag and that `/history` renders `[HH:MM]` only for turns that carry a +stored unix `timestamp` (never fabricating one for live unsaved turns). +""" + +import io +import sys +import time +from datetime import datetime + +import yaml + +from hermes_cli.cli_commands_mixin import CLICommandsMixin + + +class _Stub(CLICommandsMixin): + def __init__(self): + self.show_timestamps = False + + +def _seed(tmp_path, monkeypatch, value=False): + hh = tmp_path / ".hermes" + hh.mkdir() + (hh / "config.yaml").write_text(f"display:\n timestamps: {str(value).lower()}\n") + monkeypatch.setenv("HERMES_HOME", str(hh)) + import cli + + monkeypatch.setattr(cli, "_hermes_home", hh, raising=False) + return hh + + +def test_timestamps_on_sets_and_persists(tmp_path, monkeypatch): + hh = _seed(tmp_path, monkeypatch) + s = _Stub() + s._handle_timestamps_command("/timestamps on") + assert s.show_timestamps is True + assert yaml.safe_load((hh / "config.yaml").read_text())["display"]["timestamps"] is True + + +def test_timestamps_bare_toggles(tmp_path, monkeypatch): + _seed(tmp_path, monkeypatch) + s = _Stub() + s.show_timestamps = True + s._handle_timestamps_command("/timestamps") + assert s.show_timestamps is False + + +def test_timestamps_status_is_noop(tmp_path, monkeypatch): + _seed(tmp_path, monkeypatch) + s = _Stub() + s.show_timestamps = True + s._handle_timestamps_command("/timestamps status") + assert s.show_timestamps is True + + +def _render_history(history, show_ts): + from cli import HermesCLI + + h = HermesCLI.__new__(HermesCLI) + h.show_timestamps = show_ts + h.conversation_history = history + h._show_recent_sessions = lambda reason="history", limit=10: True + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + h.show_history() + finally: + sys.stdout = old + return buf.getvalue() + + +def test_history_shows_timestamp_for_stored_turns(): + ts = time.time() + hist = [ + {"role": "user", "content": "hello", "timestamp": ts}, + {"role": "assistant", "content": "hi", "timestamp": ts + 60}, + {"role": "user", "content": "live turn, no ts"}, + ] + out = _render_history(hist, show_ts=True) + hhmm = datetime.fromtimestamp(ts).strftime("%H:%M") + assert f"[You #1] [{hhmm}]" in out + assert "[Hermes #2] [" in out + # a turn with no stored timestamp must NOT get a fabricated time + assert "[You #3]\n" in out + + +def test_history_hides_timestamps_when_off(): + ts = time.time() + hist = [{"role": "user", "content": "hello", "timestamp": ts}] + out = _render_history(hist, show_ts=False) + # label present, no [HH:MM] suffix + first_label_line = out.split("[You #1]")[1].split("\n")[0] + assert "[" not in first_label_line