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

2open issues
1open PR
8items grouped
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.

Multiple 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

  • Issue#5511feat: Ctrl+G external editor for input + /keys command
  • Issue#7262[UX] Add /editor command to compose messages in $EDITOR

Open PRs1

  • PR#7263feat: add /editor command to compose messages in $EDITOR

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #12934 feat(cli): markdown stripping, compact multiline previews, external editor

    merged to main

    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.py

    Show 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

    merged to main

    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

  • #4774 feat: Ctrl+G external editor for input + /keys command · duplicate → #5511, #11427
  • #7977 feat(cli): add /editor compose command · done elsewhere → 9e96e70, #50509, #7262
  • #9401 feat(cli): add /editor compose command · superseded by another PR → #12934
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.00 means 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

At a glance

Subsystem
hermes cli
Merged PRs
#12934, #50509
Closed issues
0
Attempts
3 PRs closed without merging
Fix link checked
22 Jun 2026

For your agent

The same facts, typed.