From 22655ed1e6583671c70f1d88b67777f5e9dc947c Mon Sep 17 00:00:00 2001 From: Lumen Radley <261797239+lumenradley@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:58:52 +0200 Subject: [PATCH] feat(cli): improve multiline previews --- cli.py | 112 ++++++++++++++------- hermes_cli/config.py | 8 ++ tests/cli/test_cli_user_message_preview.py | 92 +++++++++++++++++ tests/hermes_cli/test_config.py | 7 ++ 4 files changed, 180 insertions(+), 39 deletions(-) create mode 100644 tests/cli/test_cli_user_message_preview.py diff --git a/cli.py b/cli.py index a5f4bbbd8..15bf303d8 100644 --- a/cli.py +++ b/cli.py @@ -1722,6 +1722,21 @@ class HermesCLI: # Inline diff previews for write actions (display.inline_diffs in config.yaml) self._inline_diffs_enabled = CLI_CONFIG["display"].get("inline_diffs", True) + # Submitted multiline user-message preview (display.user_message_preview in config.yaml) + _ump = CLI_CONFIG["display"].get("user_message_preview", {}) + if not isinstance(_ump, dict): + _ump = {} + try: + _ump_first_lines = int(_ump.get("first_lines", 2)) + except (TypeError, ValueError): + _ump_first_lines = 2 + try: + _ump_last_lines = int(_ump.get("last_lines", 2)) + except (TypeError, ValueError): + _ump_last_lines = 2 + self.user_message_preview_first_lines = max(1, _ump_first_lines) + self.user_message_preview_last_lines = max(0, _ump_last_lines) + # Streaming display state self._stream_buf = "" # Partial line buffer for line-buffered rendering self._stream_started = False # True once first delta arrives @@ -2449,6 +2464,61 @@ class HermesCLI: if flush_text: self._emit_reasoning_preview(flush_text) + def _format_submitted_user_message_preview(self, user_input: str) -> str: + """Format the submitted user-message scrollback preview.""" + lines = user_input.split("\n") + if len(lines) <= 1: + return f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]" + + first_lines = int(getattr(self, "user_message_preview_first_lines", 2)) + last_lines = int(getattr(self, "user_message_preview_last_lines", 2)) + first_lines = max(1, first_lines) + last_lines = max(0, last_lines) + head = lines[:first_lines] + remaining_after_head = max(0, len(lines) - len(head)) + tail_count = min(last_lines, remaining_after_head) + tail = lines[-tail_count:] if tail_count else [] + + hidden_middle_count = len(lines) - len(head) - len(tail) + if hidden_middle_count < 0: + hidden_middle_count = 0 + tail = [] + + preview_lines = [ + f"[bold {_accent_hex()}]●[/] [bold]{_escape(head[0])}[/]" + ] + preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in head[1:]) + + if hidden_middle_count > 0: + noun = "line" if hidden_middle_count == 1 else "lines" + preview_lines.append(f"[dim]... (+{hidden_middle_count} more {noun})[/]") + + preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in tail) + return "\n".join(preview_lines) + + def _expand_paste_references(self, text: str | None) -> str: + """Expand [Pasted text #N -> file] placeholders into file contents.""" + if not isinstance(text, str) or "[Pasted text #" not in text: + return text or "" + import re as _re + + paste_ref_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]') + + def _expand_ref(match): + path = Path(match.group(1)) + return path.read_text(encoding="utf-8") if path.exists() else match.group(0) + + return paste_ref_re.sub(_expand_ref, text) + + def _print_user_message_preview(self, user_input: str) -> None: + """Render a user message using the normal chat scrollback style.""" + ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") + text = str(user_input or "") + if "\n" in text: + ChatConsole().print(self._format_submitted_user_message_preview(text)) + else: + ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(text)}[/]") + def _stream_reasoning_delta(self, text: str) -> None: """Stream reasoning/thinking tokens into a dim box above the response. @@ -10070,45 +10140,9 @@ class HermesCLI: _paste_ref_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]') paste_refs = list(_paste_ref_re.finditer(user_input)) if isinstance(user_input, str) else [] if paste_refs: - def _expand_ref(m): - p = Path(m.group(1)) - return p.read_text(encoding="utf-8") if p.exists() else m.group(0) - expanded = _paste_ref_re.sub(_expand_ref, user_input) - total_lines = expanded.count('\n') + 1 - n_pastes = len(paste_refs) - _user_bar = f"[{_accent_hex()}]{'─' * 40}[/]" - print() - ChatConsole().print(_user_bar) - # Show any surrounding user text alongside the paste summary - split_parts = _paste_ref_re.split(user_input) - visible_user_text = " ".join( - split_parts[i].strip() for i in range(0, len(split_parts), 2) if split_parts[i].strip() - ) - if visible_user_text: - ChatConsole().print( - f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(visible_user_text)}[/] " - f"[dim]({n_pastes} pasted block{'s' if n_pastes > 1 else ''}, {total_lines} lines total)[/]" - ) - else: - ChatConsole().print( - f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(f'[Pasted text: {total_lines} lines]')}[/]" - ) - user_input = expanded - else: - _user_bar = f"[{_accent_hex()}]{'─' * 40}[/]" - if '\n' in user_input: - first_line = user_input.split('\n')[0] - line_count = user_input.count('\n') + 1 - print() - ChatConsole().print(_user_bar) - ChatConsole().print( - f"[bold {_accent_hex()}]●[/] [bold]{_escape(first_line)}[/] " - f"[dim](+{line_count - 1} lines)[/]" - ) - else: - print() - ChatConsole().print(_user_bar) - ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]") + user_input = self._expand_paste_references(user_input) + print() + self._print_user_message_preview(user_input) # Show image attachment count if submit_images: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4456c677a..d50be112f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -568,6 +568,10 @@ DEFAULT_CONFIG = { "inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage) "show_cost": False, # Show $ cost in the status bar (off by default) "skin": "default", + "user_message_preview": { # CLI: how many submitted user-message lines to echo back in scrollback + "first_lines": 2, + "last_lines": 2, + }, "interim_assistant_messages": True, # Gateway: show natural mid-turn assistant status messages "tool_progress_command": False, # Enable /verbose command in messaging gateway "tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead @@ -3374,6 +3378,10 @@ def show_config(): print(f" Personality: {display.get('personality', 'kawaii')}") print(f" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}") print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}") + ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {} + ump_first = ump.get('first_lines', 2) + ump_last = ump.get('last_lines', 2) + print(f" User preview: first {ump_first} line(s), last {ump_last} line(s)") # Terminal print() diff --git a/tests/cli/test_cli_user_message_preview.py b/tests/cli/test_cli_user_message_preview.py new file mode 100644 index 000000000..f3e84759e --- /dev/null +++ b/tests/cli/test_cli_user_message_preview.py @@ -0,0 +1,92 @@ +import importlib +import os +import sys +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +_cli_mod = None + + +def _make_cli(user_message_preview=None): + global _cli_mod + clean_config = { + "model": { + "default": "anthropic/claude-opus-4.6", + "base_url": "https://openrouter.ai/api/v1", + "provider": "auto", + }, + "display": { + "compact": False, + "tool_progress": "all", + "user_message_preview": user_message_preview or {"first_lines": 2, "last_lines": 2}, + }, + "agent": {}, + "terminal": {"env_type": "local"}, + } + clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} + prompt_toolkit_stubs = { + "prompt_toolkit": MagicMock(), + "prompt_toolkit.history": MagicMock(), + "prompt_toolkit.styles": MagicMock(), + "prompt_toolkit.patch_stdout": MagicMock(), + "prompt_toolkit.application": MagicMock(), + "prompt_toolkit.layout": MagicMock(), + "prompt_toolkit.layout.processors": MagicMock(), + "prompt_toolkit.filters": MagicMock(), + "prompt_toolkit.layout.dimension": MagicMock(), + "prompt_toolkit.layout.menus": MagicMock(), + "prompt_toolkit.widgets": MagicMock(), + "prompt_toolkit.key_binding": MagicMock(), + "prompt_toolkit.completion": MagicMock(), + "prompt_toolkit.formatted_text": MagicMock(), + "prompt_toolkit.auto_suggest": MagicMock(), + } + with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict("os.environ", clean_env, clear=False): + import cli as mod + + mod = importlib.reload(mod) + _cli_mod = mod + with patch.object(mod, "get_tool_definitions", return_value=[]), patch.dict(mod.__dict__, {"CLI_CONFIG": clean_config}): + return mod.HermesCLI() + + +class TestSubmittedUserMessagePreview: + def test_default_preview_shows_first_two_lines_and_last_two_lines(self): + cli = _make_cli() + + rendered = cli._format_submitted_user_message_preview( + "line1\nline2\nline3\nline4\nline5\nline6" + ) + + assert "line1" in rendered + assert "line2" in rendered + assert "line5" in rendered + assert "line6" in rendered + assert "line3" not in rendered + assert "line4" not in rendered + assert "(+2 more lines)" in rendered + + def test_preview_can_hide_last_lines(self): + cli = _make_cli({"first_lines": 2, "last_lines": 0}) + + rendered = cli._format_submitted_user_message_preview( + "line1\nline2\nline3\nline4\nline5\nline6" + ) + + assert "line1" in rendered + assert "line2" in rendered + assert "line5" not in rendered + assert "line6" not in rendered + assert "(+4 more lines)" in rendered + + def test_invalid_first_lines_value_falls_back_to_one(self): + cli = _make_cli({"first_lines": 0, "last_lines": 2}) + + rendered = cli._format_submitted_user_message_preview("line1\nline2\nline3\nline4") + + assert "line1" in rendered + assert "line3" in rendered + assert "line4" in rendered + assert "(+1 more line)" in rendered diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 1896be003..e87fe0a52 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -629,3 +629,10 @@ class TestDiscordChannelPromptsConfig: assert raw["_config_version"] == 20 assert raw["discord"]["auto_thread"] is True assert raw["discord"]["channel_prompts"] == {} + + +class TestUserMessagePreviewConfig: + def test_default_config_preview_line_counts(self): + preview = DEFAULT_CONFIG["display"]["user_message_preview"] + assert preview["first_lines"] == 2 + assert preview["last_lines"] == 2