External editor integration for composing CLI prompts
NousResearch/hermes-agent · cluster 5511 · hermes cli
Data as of
3 open items. Fix merged in #12934. Check it covers your case.
The short answer
- Known?
- Yes. 8 related items grouped here, 0 issues already closed.
- Fix event
- A fix was merged to main in PR #12934. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 2 issues and 1 PR.
Next step: read #12934, then check whether your report is one of the 2 still open.
Read #12934Pick up a Lab taskMultiple proposals and implementations for launching an external $EDITOR (via a /editor command or keyboard shortcut) to compose messages within the CLI. Members cover the feature from several angles including UX, markdown handling, and keybinding support.
Open issues2
Open PRs1
- PR#7263feat: add /editor command to compose messages in $EDITOR
Fixes on record
-
#12934 feat(cli): markdown stripping, compact multiline previews, external editor
files:
cli.py,hermes_cli/config.py,scripts/release.py,tests/cli/test_cli_external_editor.py,tests/cli/test_cli_markdown_rendering.py,tests/cli/test_cli_user_message_preview.py,tests/hermes_cli/test_config.pyShow the diff
-
cli.py@@ -1141,6 +1141,43 @@ def _rich_text_from_ansi(text: str) -> _RichText: return _RichText.from_ansi(text or "") +def _strip_markdown_syntax(text: str) -> str: + """Best-effort markdown marker removal for plain-text display.""" + import re + + plain = _rich_text_from_ansi(text or "").plain + plain = re.sub(r"^\s{0,3}(?:[-*_]\s*){3,}$", "", plain, flags=re.MULTILINE) + plain = re.sub(r"^\s{0,3}#{1,6}\s+", "", plain, flags=re.MULTILINE) + # Preserve blockquotes, lists, and checkboxes because they carry structure. + plain = re.sub(r"(```+|~~~+)", "", plain) + plain = re.sub(r"`([^`]*)`", r"\1", plain) + plain = re.sub(r"!\[([^\]]*)\]\([^\)]*\)", r"\1", plain) + plain = re.sub(r"\[([^\]]+)\]\([^\)]*\)", r"\1", plain) + plain = re.sub(r"\*\*\*([^*]+)\*\*\*", r"\1", plain) + plain = re.sub(r"___([^_]+)___", r"\1", plain) + plain = re.sub(r"\*\*([^*]+)\*\*", r"\1", plain) + plain = re.sub(r"__([^_]+)__", r"\1", plain) + plain = re.sub(r"\*([^*]+)\*", r"\1", plain) + plain = re.sub(r"_([^_]+)_", r"\1", plain) + plain = re.sub(r"~~([^~]+)~~", r"\1", plain) + plain = re.sub(r"\n{3,}", "\n\n", plain) + return plain.strip("\n") + + +def _render_final_assistant_content(text: str, mode: str = "render"): + """Render final assistant content as markdown, stripped text, or raw text.""" + from rich.markdown import Markdown + + normalized_mode = str(mode or "render").strip().lower() + if normalized_mode == "strip": + return _RichText(_strip_markdown_syntax(text)) + if normalized_mode == "raw": + return _rich_text_from_ansi(text or "") + + plain = _rich_text_from_ansi(text or "").plain + return Markdown(plain) + + def _cprint(text: str): """Print ANSI-colored text through prompt_toolkit's native renderer. @@ -1718,10 +1755,30 @@ def __init__( # streaming: stream tokens to the terminal as they arrive (display.streaming in config.yaml) self.streaming_enabled = CLI_CONFIG["display"].get("streaming", False) + self.final_response_markdown = str( + CLI_CONFIG["display"].get("final_response_markdown", "strip") + ).strip().lower() or "strip" + if self.final_response_markdown not in {"render", "strip", "raw"}: + self.final_response_markdown = "strip" # 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 +2506,61 @@ def _flush_reasoning_preview(self, *, force: bool = False) -> None: 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. @@ -2692,6 +2804,8 @@ def _emit_stream_text(self, text: str) -> None: _tc = getattr(self, "_stream_text_ansi", "") while "\n" in self._stream_buf: line, self._stream_buf = self._stream_buf.split("\n", 1) + if self.final_response_markdown == "strip": + line = _strip_markdown_syntax(line) _cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}") def _flush_stream(self) -> None: @@ -2709,7 +2823,8 @@ def _flush_stream(self) -> None: if self._stream_buf: _tc = getattr(self, "_stream_text_ansi", "") - _cprint(f"{_STREAM_PAD}{_tc}{self._stream_buf}{_RST}" if _tc else f"{_STREAM_PAD}{self._stream_buf}") + line = _strip_markdown_syntax(self._stream_buf) if self.final_response_markdown == "strip" else self._stream_buf + _cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}") self._stream_buf = "" # Close the response box @@ -2771,6 +2886,39 @@ def _busy_command(self, status: str): self._command_status = "" self._invalidate(min_interval=0.0) + def _open_external_editor(self, buffer=None) -> bool: + """Open the active input buffer in an external editor.""" + app = getattr(self, "_app", None) + if not app: + _cprint(f"{_DIM}External editor is only available inside the interactive CLI.{_RST}") + return False + if self._command_running: + _cprint(f"{_DIM}Wait for the current command to finish before opening the editor.{_RST}") + return False + if self._sudo_state or self._secret_state or self._approval_state or self._clarify_state: + _cprint(f"{_DIM}Finish the active prompt before opening the editor.{_RST}") + return False + target_buffer = buffer or getattr(app, "current_buffer", None) + if target_buffer is None: + _cprint(f"{_DIM}No active input buffer is available for the external editor.{_RST}") + return False + try: + existing_text = getattr(target_buffer, "text", "") + expanded_text = self._expand_paste_references(existing_text) + if expanded_text != existing_text and hasattr(target_buffer, "text"): + self._skip_paste_collapse = True + target_buffer.text = expanded_text + if hasattr(target_buffer, "cursor_position"): + target_buffer.cursor_position = len(expanded_text) + # Set skip flag (again) so the text-change event fired when the + # editor closes does not re-collapse the returned content. + self._skip_paste_collapse = True + target_buffer.open_in_editor(validate_and_handle=False) + return True + except Exception as exc: + _cprint(f"{_DIM}Failed to open external editor: {exc}{_RST}") + return False + def _ensure_runtime_credentials(self) -> bool: """ Ensure runtime credentials are resolved before agent use. @@ -3948,6 +4096,7 @@ def show_help(self): _cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}") _cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}") + _cprint(f" {_DIM}Draft editor: Ctrl+G{_RST}") if _is_termux_environment(): _cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n") else: @@ -6047,7 +6196,7 @@ def _bg_thinking(text: str) -> None: _chat_console = ChatConsole() _chat_console.print(Panel( - _rich_text_from_ansi(response), + _render_final_assistant_content(response, mode=self.final_response_markdown), title=f"[{_resp_color} bold]{label} (background #{task_num})[/]", title_align="left", border_style=_resp_color, @@ -6172,7 +6321,7 @@ def run_btw(): _resp_color = "#4F6D4A" ChatConsole().print(Panel( - _rich_text_from_ansi(response), + _render_final_assistant_content(response, mode=self.final_response_markdown), title=f"[{_resp_color} bold]⚕ /btw[/]", title_align="left", border_style=_resp_color, @@ -8297,7 +8446,7 @@ def run_agent(): else: _chat_console = ChatConsole() _chat_console.print(Panel( - _rich_text_from_ansi(response), + _render_final_assistant_content(response, mode=self.final_response_markdown), title=f"[{_resp_color} bold]{label}[/]", title_align="left", border_style=_resp_color, @@ -8863,6 +9012,16 @@ def handle_ctrl_enter(event): """Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter.""" event.current_buffer.insert_text('\n') + @kb.add( + 'c-g', + filter=Condition( + lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state + ), + ) + def handle_open_in_editor(event): + """Ctrl+G opens the current draft in an external editor.""" + cli_ref._open_external_editor(event.current_buffer) + @kb.add('tab', eager=True) def handle_tab(event): """Tab: accept completion, auto-suggestion, or start completions. @@ -9314,6 +9473,7 @@ def _input_height(): _prev_text_len = [0] _prev_newline_count = [0] _paste_just_collapsed = [False] + self._skip_paste_collapse = False def _on_text_changed(buf): """Detect large pastes and collapse them to a file reference. @@ -9333,8 +9493,9 @@ def _on_text_changed(buf): text = buf.text chars_added = len(text) - _prev_text_len[0] _prev_text_len[0] = len(text) - if _paste_just_collapsed[0]: + if _paste_just_collapsed[0] or self._skip_paste_collapse: _paste_just_collapsed[0] = False + self._skip_paste_collapse = False _prev_newline_count[0] = text.count('\n') return line_count = text.count('\n') @@ -9343,12 +9504,10 @@ def _on_text_changed(buf): is_paste = chars_added > 1 or newlines_added >= 4 if line_count >= 5 and is_paste and not text.startswith('/'): _paste_counter[0] += 1 - # Save to temp file paste_dir = _hermes_home / "pastes" paste_dir.mkdir(parents=True, exist_ok=True) paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt" paste_file.write_text(text, encoding="utf-8") - # Replace buffer with compact reference _paste_just_collapsed[0] = True buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]" buf.cursor_position = len(buf.text) @@ -10070,45 +10229,9 @@ def process_loop(): _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: -
hermes_cli/config.py@@ -565,9 +565,14 @@ def _ensure_hermes_home_managed(home: Path): "bell_on_complete": False, "show_reasoning": False, "streaming": False, + "final_response_markdown": "strip", # render | strip | raw "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 +3379,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() -
scripts/release.py@@ -297,6 +297,7 @@ "[redacted:email]": "LVT382009", "[redacted:email]": "heykb", "[redacted:email]": "StefanIsMe", + "[redacted:email]": "lumenradley", } -
tests/cli/test_cli_external_editor.py@@ -0,0 +1,105 @@ +"""Tests for CLI external-editor support.""" + +from unittest.mock import patch + +from cli import HermesCLI + + +class _FakeBuffer: + def __init__(self, text=""): + self.calls = [] + self.text = text + self.cursor_position = len(text) + + def open_in_editor(self, validate_and_handle=False): + self.calls.append(validate_and_handle) + + +class _FakeApp: + def __init__(self): + self.current_buffer = _FakeBuffer() + + +def _make_cli(with_app=True): + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj._app = _FakeApp() if with_app else None + cli_obj._command_running = False + cli_obj._command_status = "" + cli_obj._command_display = "" + cli_obj._sudo_state = None + cli_obj._secret_state = None + cli_obj._approval_state = None + cli_obj._clarify_state = None + cli_obj._skip_paste_collapse = False + return cli_obj + +def test_open_external_editor_uses_prompt_toolkit_buffer_editor(): + cli_obj = _make_cli() + + assert cli_obj._open_external_editor() is True + assert cli_obj._app.current_buffer.calls == [False] + + +def test_open_external_editor_rejects_when_no_tui(): + cli_obj = _make_cli(with_app=False) + + with patch("cli._cprint") as mock_cprint: + assert cli_obj._open_external_editor() is False + + assert mock_cprint.called + assert "interactive cli" in str(mock_cprint.call_args).lower() + + +def test_open_external_editor_rejects_modal_prompts(): + cli_obj = _make_cli() + cli_obj._approval_state = {"selected": 0} + + with patch("cli._cprint") as mock_cprint: + assert cli_obj._open_external_editor() is False + + assert mock_cprint.called + assert "active prompt" in str(mock_cprint.call_args).lower() + +def test_open_external_editor_uses_explicit_buffer_when_provided(): + cli_obj = _make_cli() + external_buffer = _FakeBuffer() + + assert cli_obj._open_external_editor(buffer=external_buffer) is True + assert external_buffer.calls == [False] + assert cli_obj._app.current_buffer.calls == [] + + +def test_expand_paste_references_replaces_placeholder_with_file_contents(tmp_path): + cli_obj = _make_cli() + paste_file = tmp_path / "paste.txt" + paste_file.write_text("line one\nline two", encoding="utf-8") + + text = f"before [Pasted text #1: 2 lines → {paste_file}] after" + expanded = cli_obj._expand_paste_references(text) + + assert expanded == "before line one\nline two after" + + +def test_open_external_editor_expands_paste_placeholders_before_open(tmp_path): + cli_obj = _make_cli() + paste_file = tmp_path / "paste.txt" + paste_file.write_text("alpha\nbeta", encoding="utf-8") + buffer = _FakeBuffer(text=f"[Pasted text #1: 2 lines → {paste_file}]") + + assert cli_obj._open_external_editor(buffer=buffer) is True + assert buffer.text == "alpha\nbeta" + assert buffer.cursor_position == len("alpha\nbeta") + assert buffer.calls == [False] + + +def test_open_external_editor_sets_skip_collapse_flag_during_expansion(tmp_path): + cli_obj = _make_cli() + paste_file = tmp_path / "paste.txt" + paste_file.write_text("a\nb\nc\nd\ne\nf", encoding="utf-8") + buffer = _FakeBuffer(text=f"[Pasted text #1: 6 lines \u2192 {paste_file}]") + + # After expansion the flag should have been set (to prevent re-collapse) + assert cli_obj._open_external_editor(buffer=buffer) is True + # Flag is consumed by _on_text_changed, but since no handler is attached + # in tests it stays True until the handler resets it. + assert cli_obj._skip_paste_collapse is True -
tests/cli/test_cli_markdown_rendering.py@@ -0,0 +1,117 @@ +from io import StringIO + +from rich.console import Console +from rich.markdown import Markdown + +from cli import _render_final_assistant_content + + +def _render_to_text(renderable) -> str: + buf = StringIO() + Console(file=buf, width=80, force_terminal=False, color_system=None).print(renderable) + return buf.getvalue() + + +def test_final_assistant_content_uses_markdown_renderable(): + renderable = _render_final_assistant_content("# Title\n\n- one\n- two") + + assert isinstance(renderable, Markdown) + output = _render_to_text(renderable) + assert "Title" in output + assert "one" in output + assert "two" in output + + +def test_final_assistant_content_strips_ansi_before_markdown_rendering(): + renderable = _render_final_assistant_content("\x1b[31m# Title\x1b[0m") + + output = _render_to_text(renderable) + assert "Title" in output + assert "\x1b" not in output + + +def test_final_assistant_content_can_strip_markdown_syntax(): + renderable = _render_final_assistant_content( + "***Bold italic***\n~~Strike~~\n- item\n# Title\n`code`", + mode="strip", + ) + + output = _render_to_text(renderable) + assert "Bold italic" in output + assert "Strike" in output + assert "item" in output + assert "Title" in output + assert "code" in output + assert "***" not in output + assert "~~" not in output + assert "`" not in output + + +def test_strip_mode_preserves_lists(): + renderable = _render_final_assistant_content( + "**Formatting**\n- Ran prettier\n- Files changed\n- Verified clean", + mode="strip", + ) + + output = _render_to_text(renderable) + assert "- Ran prettier" in output + assert "- Files changed" in output + assert "- Verified clean" in output + assert "**" not in output + + +def test_strip_mode_preserves_ordered_lists(): + renderable = _render_final_assistant_content( + "1. First item\n2. Second item\n3. Third item", + mode="strip", + ) + + output = _render_to_text(renderable) + assert "1. First" in output + assert "2. Second" in output + assert "3. Third" in output + + +def test_strip_mode_preserves_blockquotes(): + renderable = _render_final_assistant_content( + "> This is quoted text\n> Another quoted line", + mode="strip", + ) + + output = _render_to_text(renderable) + assert "> This is quoted" in output + assert "> Another quoted" in output + + +def test_strip_mode_preserves_checkboxes(): + renderable = _render_final_assistant_content( + "- [ ] Todo item\n- [x] Done item", + mode="strip", + ) + + output = _render_to_text(renderable) + assert "- [ ] Todo" in output + assert "- [x] Done" in output + + +def test_strip_mode_preserves_table_structure_while_cleaning_cell_markdown(): + renderable = _render_final_assistant_content( + "| Syntax | Example |\n|---|---|\n| Bold | `**bold**` |\n| Strike | `~~strike~~` |", + mode="strip", + ) + + output = _render_to_text(renderable) + assert "| Syntax | Example |" in output + assert "|---|---|" in output + assert "| Bold | bold |" in output + assert "| Strike | strike |" in output + assert "**" not in output + assert "~~" not in output + assert "`" not in output + + +def test_final_assistant_content_can_leave_markdown_raw(): + renderable = _render_final_assistant_content("***Bold italic***", mode="raw") + + output = _render_to_text(renderable) + assert "***Bold italic***" in output -
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 -
tests/hermes_cli/test_config.py@@ -629,3 +629,10 @@ def test_migrate_adds_discord_channel_prompts_default(self, tmp_path): 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
-
-
#50509 feat(cli): /prompt — compose your next prompt in $EDITOR
files:
cli.py,hermes_cli/cli_commands_mixin.py,hermes_cli/commands.py,tests/hermes_cli/test_prompt_compose_command.py,ui-tui/src/__tests__/createSlashHandler.test.ts,ui-tui/src/app/interfaces.ts,ui-tui/src/app/slash/commands/core.ts,ui-tui/src/app/useMainApp.ts
3 PRs closed without merging
From the close comments: done elsewhere 1 · duplicate 1 · superseded by another PR 1
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.23: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab