# NousResearch/hermes-agent · External editor integration for composing CLI prompts

## Open now

- 2 issues, 1 PR open on GitHub.
- #5511: feat: Ctrl+G external editor for input + /keys command
- #7262: \[UX\] Add /editor command to compose messages in $EDITOR
- PR #7263: feat: add /editor command to compose messages in $EDITOR

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.

## TL;DR

- 8 member(s): 2 open issue(s), 0 closed issue(s), 1 open PR(s), 2 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #12934. Verify it addresses your case.
- **Recommended:** read PR #12934 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

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

## Prior & competing PRs

- **#12934: feat(cli): markdown stripping, compact multiline previews, external editor**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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')
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -297,6 +297,7 @@
         "[redacted:email]": "LVT382009",
         "[redacted:email]": "heykb",
         "[redacted:email]": "StefanIsMe",
    +    "[redacted:email]": "lumenradley",
     }
     
     
    ```
  - `tests/cli/test_cli_external_editor.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
- **#4774: feat: Ctrl+G external editor for input + /keys command**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#5511`, `#11427`
- **#7263: feat: add /editor command to compose messages in $EDITOR**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `0bb2399900`, `#12934`, `#50509`, `#7941`
  - files: `cli.py`, `hermes_cli/commands.py`
- **#7977: feat(cli): add /editor compose command**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `9e96e709951824be8336c5a733bb0d98d6ab32da`, `#50509`, `#7262`
- **#9401: feat(cli): add /editor compose command**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#12934`, `#12934`

## Fix status

- Merged to main in PR #12934 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-22`.
- Subsystem: `hermes_cli`
