{"repo":"NousResearch/hermes-agent","public_id":"5511","display_label":"External editor integration for composing CLI prompts","summary":"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.","subsystem":"hermes_cli","counts":{"members":8,"open_issues":2,"open_issues_distinct":2,"open_prs":1,"closed_issues":0,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12934},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-22T03:21:33+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22714285714285715},"fix_edges":[{"issue_number":7262,"pr_number":12934,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":7262,"pr_number":50509,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":5511,"title":"feat: Ctrl+G external editor for input + /keys command","url":null,"state":"open"},{"kind":"issue","number":7262,"title":"[UX] Add /editor command to compose messages in $EDITOR","url":null,"state":"open"},{"kind":"pr","number":4774,"title":"feat: Ctrl+G external editor for input + /keys command","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5511","number":5511},{"kind":"pr","value":"#11427","number":11427}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":7263,"title":"feat: add /editor command to compose messages in $EDITOR","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"0bb2399900","number":0},{"kind":"pr","value":"#12934","number":12934},{"kind":"pr","value":"#50509","number":50509},{"kind":"pr","value":"#7941","number":7941}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":7977,"title":"feat(cli): add /editor compose command","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9e96e709951824be8336c5a733bb0d98d6ab32da","number":0},{"kind":"pr","value":"#50509","number":50509},{"kind":"pr","value":"#7262","number":7262}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":9401,"title":"feat(cli): add /editor compose command","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12934","number":12934},{"kind":"pr","value":"#12934","number":12934}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":12934,"title":"feat(cli): markdown stripping, compact multiline previews, external editor","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"cli.py","hunks":["@@ -1141,6 +1141,43 @@ def _rich_text_from_ansi(text: str) -> _RichText:\n     return _RichText.from_ansi(text or \"\")\n \n \n+def _strip_markdown_syntax(text: str) -> str:\n+    \"\"\"Best-effort markdown marker removal for plain-text display.\"\"\"\n+    import re\n+\n+    plain = _rich_text_from_ansi(text or \"\").plain\n+    plain = re.sub(r\"^\\s{0,3}(?:[-*_]\\s*){3,}$\", \"\", plain, flags=re.MULTILINE)\n+    plain = re.sub(r\"^\\s{0,3}#{1,6}\\s+\", \"\", plain, flags=re.MULTILINE)\n+    # Preserve blockquotes, lists, and checkboxes because they carry structure.\n+    plain = re.sub(r\"(```+|~~~+)\", \"\", plain)\n+    plain = re.sub(r\"`([^`]*)`\", r\"\\1\", plain)\n+    plain = re.sub(r\"!\\[([^\\]]*)\\]\\([^\\)]*\\)\", r\"\\1\", plain)\n+    plain = re.sub(r\"\\[([^\\]]+)\\]\\([^\\)]*\\)\", r\"\\1\", plain)\n+    plain = re.sub(r\"\\*\\*\\*([^*]+)\\*\\*\\*\", r\"\\1\", plain)\n+    plain = re.sub(r\"___([^_]+)___\", r\"\\1\", plain)\n+    plain = re.sub(r\"\\*\\*([^*]+)\\*\\*\", r\"\\1\", plain)\n+    plain = re.sub(r\"__([^_]+)__\", r\"\\1\", plain)\n+    plain = re.sub(r\"\\*([^*]+)\\*\", r\"\\1\", plain)\n+    plain = re.sub(r\"_([^_]+)_\", r\"\\1\", plain)\n+    plain = re.sub(r\"~~([^~]+)~~\", r\"\\1\", plain)\n+    plain = re.sub(r\"\\n{3,}\", \"\\n\\n\", plain)\n+    return plain.strip(\"\\n\")\n+\n+\n+def _render_final_assistant_content(text: str, mode: str = \"render\"):\n+    \"\"\"Render final assistant content as markdown, stripped text, or raw text.\"\"\"\n+    from rich.markdown import Markdown\n+\n+    normalized_mode = str(mode or \"render\").strip().lower()\n+    if normalized_mode == \"strip\":\n+        return _RichText(_strip_markdown_syntax(text))\n+    if normalized_mode == \"raw\":\n+        return _rich_text_from_ansi(text or \"\")\n+\n+    plain = _rich_text_from_ansi(text or \"\").plain\n+    return Markdown(plain)\n+\n+\n def _cprint(text: str):\n     \"\"\"Print ANSI-colored text through prompt_toolkit's native renderer.\n ","@@ -1718,10 +1755,30 @@ def __init__(\n         \n         # streaming: stream tokens to the terminal as they arrive (display.streaming in config.yaml)\n         self.streaming_enabled = CLI_CONFIG[\"display\"].get(\"streaming\", False)\n+        self.final_response_markdown = str(\n+            CLI_CONFIG[\"display\"].get(\"final_response_markdown\", \"strip\")\n+        ).strip().lower() or \"strip\"\n+        if self.final_response_markdown not in {\"render\", \"strip\", \"raw\"}:\n+            self.final_response_markdown = \"strip\"\n \n         # Inline diff previews for write actions (display.inline_diffs in config.yaml)\n         self._inline_diffs_enabled = CLI_CONFIG[\"display\"].get(\"inline_diffs\", True)\n \n+        # Submitted multiline user-message preview (display.user_message_preview in config.yaml)\n+        _ump = CLI_CONFIG[\"display\"].get(\"user_message_preview\", {})\n+        if not isinstance(_ump, dict):\n+            _ump = {}\n+        try:\n+            _ump_first_lines = int(_ump.get(\"first_lines\", 2))\n+        except (TypeError, ValueError):\n+            _ump_first_lines = 2\n+        try:\n+            _ump_last_lines = int(_ump.get(\"last_lines\", 2))\n+        except (TypeError, ValueError):\n+            _ump_last_lines = 2\n+        self.user_message_preview_first_lines = max(1, _ump_first_lines)\n+        self.user_message_preview_last_lines = max(0, _ump_last_lines)\n+\n         # Streaming display state\n         self._stream_buf = \"\"        # Partial line buffer for line-buffered rendering\n         self._stream_started = False  # True once first delta arrives","@@ -2449,6 +2506,61 @@ def _flush_reasoning_preview(self, *, force: bool = False) -> None:\n         if flush_text:\n             self._emit_reasoning_preview(flush_text)\n \n+    def _format_submitted_user_message_preview(self, user_input: str) -> str:\n+        \"\"\"Format the submitted user-message scrollback preview.\"\"\"\n+        lines = user_input.split(\"\\n\")\n+        if len(lines) <= 1:\n+            return f\"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]\"\n+\n+        first_lines = int(getattr(self, \"user_message_preview_first_lines\", 2))\n+        last_lines = int(getattr(self, \"user_message_preview_last_lines\", 2))\n+        first_lines = max(1, first_lines)\n+        last_lines = max(0, last_lines)\n+        head = lines[:first_lines]\n+        remaining_after_head = max(0, len(lines) - len(head))\n+        tail_count = min(last_lines, remaining_after_head)\n+        tail = lines[-tail_count:] if tail_count else []\n+\n+        hidden_middle_count = len(lines) - len(head) - len(tail)\n+        if hidden_middle_count < 0:\n+            hidden_middle_count = 0\n+            tail = []\n+\n+        preview_lines = [\n+            f\"[bold {_accent_hex()}]●[/] [bold]{_escape(head[0])}[/]\"\n+        ]\n+        preview_lines.extend(f\"[bold]{_escape(line)}[/]\" for line in head[1:])\n+\n+        if hidden_middle_count > 0:\n+            noun = \"line\" if hidden_middle_count == 1 else \"lines\"\n+            preview_lines.append(f\"[dim]... (+{hidden_middle_count} more {noun})[/]\")\n+\n+        preview_lines.extend(f\"[bold]{_escape(line)}[/]\" for line in tail)\n+        return \"\\n\".join(preview_lines)\n+\n+    def _expand_paste_references(self, text: str | None) -> str:\n+        \"\"\"Expand [Pasted text #N -> file] placeholders into file contents.\"\"\"\n+        if not isinstance(text, str) or \"[Pasted text #\" not in text:\n+            return text or \"\"\n+        import re as _re\n+\n+        paste_ref_re = _re.compile(r'\\[Pasted text #\\d+: \\d+ lines \\u2192 (.+?)\\]')\n+\n+        def _expand_ref(match):\n+            path = Path(match.group(1))\n+            return path.read_text(encoding=\"utf-8\") if path.exists() else match.group(0)\n+\n+        return paste_ref_re.sub(_expand_ref, text)\n+\n+    def _print_user_message_preview(self, user_input: str) -> None:\n+        \"\"\"Render a user message using the normal chat scrollback style.\"\"\"\n+        ChatConsole().print(f\"[{_accent_hex()}]{'─' * 40}[/]\")\n+        text = str(user_input or \"\")\n+        if \"\\n\" in text:\n+            ChatConsole().print(self._format_submitted_user_message_preview(text))\n+        else:\n+            ChatConsole().print(f\"[bold {_accent_hex()}]●[/] [bold]{_escape(text)}[/]\")\n+\n     def _stream_reasoning_delta(self, text: str) -> None:\n         \"\"\"Stream reasoning/thinking tokens into a dim box above the response.\n ","@@ -2692,6 +2804,8 @@ def _emit_stream_text(self, text: str) -> None:\n         _tc = getattr(self, \"_stream_text_ansi\", \"\")\n         while \"\\n\" in self._stream_buf:\n             line, self._stream_buf = self._stream_buf.split(\"\\n\", 1)\n+            if self.final_response_markdown == \"strip\":\n+                line = _strip_markdown_syntax(line)\n             _cprint(f\"{_STREAM_PAD}{_tc}{line}{_RST}\" if _tc else f\"{_STREAM_PAD}{line}\")\n \n     def _flush_stream(self) -> None:","@@ -2709,7 +2823,8 @@ def _flush_stream(self) -> None:\n \n         if self._stream_buf:\n             _tc = getattr(self, \"_stream_text_ansi\", \"\")\n-            _cprint(f\"{_STREAM_PAD}{_tc}{self._stream_buf}{_RST}\" if _tc else f\"{_STREAM_PAD}{self._stream_buf}\")\n+            line = _strip_markdown_syntax(self._stream_buf) if self.final_response_markdown == \"strip\" else self._stream_buf\n+            _cprint(f\"{_STREAM_PAD}{_tc}{line}{_RST}\" if _tc else f\"{_STREAM_PAD}{line}\")\n             self._stream_buf = \"\"\n \n         # Close the response box","@@ -2771,6 +2886,39 @@ def _busy_command(self, status: str):\n             self._command_status = \"\"\n             self._invalidate(min_interval=0.0)\n \n+    def _open_external_editor(self, buffer=None) -> bool:\n+        \"\"\"Open the active input buffer in an external editor.\"\"\"\n+        app = getattr(self, \"_app\", None)\n+        if not app:\n+            _cprint(f\"{_DIM}External editor is only available inside the interactive CLI.{_RST}\")\n+            return False\n+        if self._command_running:\n+            _cprint(f\"{_DIM}Wait for the current command to finish before opening the editor.{_RST}\")\n+            return False\n+        if self._sudo_state or self._secret_state or self._approval_state or self._clarify_state:\n+            _cprint(f\"{_DIM}Finish the active prompt before opening the editor.{_RST}\")\n+            return False\n+        target_buffer = buffer or getattr(app, \"current_buffer\", None)\n+        if target_buffer is None:\n+            _cprint(f\"{_DIM}No active input buffer is available for the external editor.{_RST}\")\n+            return False\n+        try:\n+            existing_text = getattr(target_buffer, \"text\", \"\")\n+            expanded_text = self._expand_paste_references(existing_text)\n+            if expanded_text != existing_text and hasattr(target_buffer, \"text\"):\n+                self._skip_paste_collapse = True\n+                target_buffer.text = expanded_text\n+                if hasattr(target_buffer, \"cursor_position\"):\n+                    target_buffer.cursor_position = len(expanded_text)\n+            # Set skip flag (again) so the text-change event fired when the\n+            # editor closes does not re-collapse the returned content.\n+            self._skip_paste_collapse = True\n+            target_buffer.open_in_editor(validate_and_handle=False)\n+            return True\n+        except Exception as exc:\n+            _cprint(f\"{_DIM}Failed to open external editor: {exc}{_RST}\")\n+            return False\n+\n     def _ensure_runtime_credentials(self) -> bool:\n         \"\"\"\n         Ensure runtime credentials are resolved before agent use.","@@ -3948,6 +4096,7 @@ def show_help(self):\n \n         _cprint(f\"\\n  {_DIM}Tip: Just type your message to chat with Hermes!{_RST}\")\n         _cprint(f\"  {_DIM}Multi-line: Alt+Enter for a new line{_RST}\")\n+        _cprint(f\"  {_DIM}Draft editor: Ctrl+G{_RST}\")\n         if _is_termux_environment():\n             _cprint(f\"  {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\\n\")\n         else:","@@ -6047,7 +6196,7 @@ def _bg_thinking(text: str) -> None:\n \n                     _chat_console = ChatConsole()\n                     _chat_console.print(Panel(\n-                        _rich_text_from_ansi(response),\n+                        _render_final_assistant_content(response, mode=self.final_response_markdown),\n                         title=f\"[{_resp_color} bold]{label} (background #{task_num})[/]\",\n                         title_align=\"left\",\n                         border_style=_resp_color,","@@ -6172,7 +6321,7 @@ def run_btw():\n                         _resp_color = \"#4F6D4A\"\n \n                     ChatConsole().print(Panel(\n-                        _rich_text_from_ansi(response),\n+                        _render_final_assistant_content(response, mode=self.final_response_markdown),\n                         title=f\"[{_resp_color} bold]⚕ /btw[/]\",\n                         title_align=\"left\",\n                         border_style=_resp_color,","@@ -8297,7 +8446,7 @@ def run_agent():\n                 else:\n                     _chat_console = ChatConsole()\n                     _chat_console.print(Panel(\n-                        _rich_text_from_ansi(response),\n+                        _render_final_assistant_content(response, mode=self.final_response_markdown),\n                         title=f\"[{_resp_color} bold]{label}[/]\",\n                         title_align=\"left\",\n                         border_style=_resp_color,","@@ -8863,6 +9012,16 @@ def handle_ctrl_enter(event):\n             \"\"\"Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter.\"\"\"\n             event.current_buffer.insert_text('\\n')\n \n+        @kb.add(\n+            'c-g',\n+            filter=Condition(\n+                lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state\n+            ),\n+        )\n+        def handle_open_in_editor(event):\n+            \"\"\"Ctrl+G opens the current draft in an external editor.\"\"\"\n+            cli_ref._open_external_editor(event.current_buffer)\n+\n         @kb.add('tab', eager=True)\n         def handle_tab(event):\n             \"\"\"Tab: accept completion, auto-suggestion, or start completions.","@@ -9314,6 +9473,7 @@ def _input_height():\n         _prev_text_len = [0]\n         _prev_newline_count = [0]\n         _paste_just_collapsed = [False]\n+        self._skip_paste_collapse = False\n \n         def _on_text_changed(buf):\n             \"\"\"Detect large pastes and collapse them to a file reference.","@@ -9333,8 +9493,9 @@ def _on_text_changed(buf):\n             text = buf.text\n             chars_added = len(text) - _prev_text_len[0]\n             _prev_text_len[0] = len(text)\n-            if _paste_just_collapsed[0]:\n+            if _paste_just_collapsed[0] or self._skip_paste_collapse:\n                 _paste_just_collapsed[0] = False\n+                self._skip_paste_collapse = False\n                 _prev_newline_count[0] = text.count('\\n')\n                 return\n             line_count = text.count('\\n')","@@ -9343,12 +9504,10 @@ def _on_text_changed(buf):\n             is_paste = chars_added > 1 or newlines_added >= 4\n             if line_count >= 5 and is_paste and not text.startswith('/'):\n                 _paste_counter[0] += 1\n-                # Save to temp file\n                 paste_dir = _hermes_home / \"pastes\"\n                 paste_dir.mkdir(parents=True, exist_ok=True)\n                 paste_file = paste_dir / f\"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt\"\n                 paste_file.write_text(text, encoding=\"utf-8\")\n-                # Replace buffer with compact reference\n                 _paste_just_collapsed[0] = True\n                 buf.text = f\"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \\u2192 {paste_file}]\"\n                 buf.cursor_position = len(buf.text)","@@ -10070,45 +10229,9 @@ def process_loop():\n                     _paste_ref_re = _re.compile(r'\\[Pasted text #\\d+: \\d+ lines \\u2192 (.+?)\\]')\n                     paste_refs = list(_paste_ref_re.finditer(user_input)) if isinstance(user_input, str) else []\n                     if paste_refs:\n-                        def _expand_ref(m):\n-                            p = Path(m.group(1))\n-                            return p.read_text(encoding=\"utf-8\") if p.exists() else m.group(0)\n-                        expanded = _paste_ref_re.sub(_expand_ref, user_input)\n-                        total_lines = expanded.count('\\n') + 1\n-                        n_pastes = len(paste_refs)\n-                        _user_bar = f\"[{_accent_hex()}]{'─' * 40}[/]\"\n-                        print()\n-                        ChatConsole().print(_user_bar)\n-                        # Show any surrounding user text alongside the paste summary\n-                        split_parts = _paste_ref_re.split(user_input)\n-                        visible_user_text = \" \".join(\n-                            split_parts[i].strip() for i in range(0, len(split_parts), 2) if split_parts[i].strip()\n-                        )\n-                        if visible_user_text:\n-                            ChatConsole().print(\n-                                f\"[bold {_accent_hex()}]\\u25cf[/] [bold]{_escape(visible_user_text)}[/] \"\n-                                f\"[dim]({n_pastes} pasted block{'s' if n_pastes > 1 else ''}, {total_lines} lines total)[/]\"\n-                            )\n-                        else:\n-                            ChatConsole().print(\n-                                f\"[bold {_accent_hex()}]\\u25cf[/] [bold]{_escape(f'[Pasted text: {total_lines} lines]')}[/]\"\n-                            )\n-                        user_input = expanded\n-                    else:\n-                        _user_bar = f\"[{_accent_hex()}]{'─' * 40}[/]\"\n-                        if '\\n' in user_input:\n-                            first_line = user_input.split('\\n')[0]\n-                            line_count = user_input.count('\\n') + 1\n-                            print()\n-                            ChatConsole().print(_user_bar)\n-                            ChatConsole().print(\n-                                f\"[bold {_accent_hex()}]●[/] [bold]{_escape(first_line)}[/] \"\n-                                f\"[dim](+{line_count - 1} lines)[/]\"\n-                            )\n-                        else:\n-                            print()\n-                            ChatConsole().print(_user_bar)\n-                            ChatConsole().print(f\"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]\")\n+                        user_input = self._expand_paste_references(user_input)\n+                    print()\n+                    self._print_user_message_preview(user_input)\n                     \n                     # Show image attachment count\n                     if submit_images:"]},{"file":"hermes_cli/config.py","hunks":["@@ -565,9 +565,14 @@ def _ensure_hermes_home_managed(home: Path):\n         \"bell_on_complete\": False,\n         \"show_reasoning\": False,\n         \"streaming\": False,\n+        \"final_response_markdown\": \"strip\",  # render | strip | raw\n         \"inline_diffs\": True,     # Show inline diff previews for write actions (write_file, patch, skill_manage)\n         \"show_cost\": False,       # Show $ cost in the status bar (off by default)\n         \"skin\": \"default\",\n+        \"user_message_preview\": {  # CLI: how many submitted user-message lines to echo back in scrollback\n+            \"first_lines\": 2,\n+            \"last_lines\": 2,\n+        },\n         \"interim_assistant_messages\": True,  # Gateway: show natural mid-turn assistant status messages\n         \"tool_progress_command\": False,  # Enable /verbose command in messaging gateway\n         \"tool_progress_overrides\": {},  # DEPRECATED — use display.platforms instead","@@ -3374,6 +3379,10 @@ def show_config():\n     print(f\"  Personality:  {display.get('personality', 'kawaii')}\")\n     print(f\"  Reasoning:    {'on' if display.get('show_reasoning', False) else 'off'}\")\n     print(f\"  Bell:         {'on' if display.get('bell_on_complete', False) else 'off'}\")\n+    ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {}\n+    ump_first = ump.get('first_lines', 2)\n+    ump_last = ump.get('last_lines', 2)\n+    print(f\"  User preview: first {ump_first} line(s), last {ump_last} line(s)\")\n \n     # Terminal\n     print()"]},{"file":"scripts/release.py","hunks":["@@ -297,6 +297,7 @@\n     \"[redacted:email]\": \"LVT382009\",\n     \"[redacted:email]\": \"heykb\",\n     \"[redacted:email]\": \"StefanIsMe\",\n+    \"[redacted:email]\": \"lumenradley\",\n }\n \n "]},{"file":"tests/cli/test_cli_external_editor.py","hunks":["@@ -0,0 +1,105 @@\n+\"\"\"Tests for CLI external-editor support.\"\"\"\n+\n+from unittest.mock import patch\n+\n+from cli import HermesCLI\n+\n+\n+class _FakeBuffer:\n+    def __init__(self, text=\"\"):\n+        self.calls = []\n+        self.text = text\n+        self.cursor_position = len(text)\n+\n+    def open_in_editor(self, validate_and_handle=False):\n+        self.calls.append(validate_and_handle)\n+\n+\n+class _FakeApp:\n+    def __init__(self):\n+        self.current_buffer = _FakeBuffer()\n+\n+\n+def _make_cli(with_app=True):\n+    cli_obj = HermesCLI.__new__(HermesCLI)\n+    cli_obj._app = _FakeApp() if with_app else None\n+    cli_obj._command_running = False\n+    cli_obj._command_status = \"\"\n+    cli_obj._command_display = \"\"\n+    cli_obj._sudo_state = None\n+    cli_obj._secret_state = None\n+    cli_obj._approval_state = None\n+    cli_obj._clarify_state = None\n+    cli_obj._skip_paste_collapse = False\n+    return cli_obj\n+\n+def test_open_external_editor_uses_prompt_toolkit_buffer_editor():\n+    cli_obj = _make_cli()\n+\n+    assert cli_obj._open_external_editor() is True\n+    assert cli_obj._app.current_buffer.calls == [False]\n+\n+\n+def test_open_external_editor_rejects_when_no_tui():\n+    cli_obj = _make_cli(with_app=False)\n+\n+    with patch(\"cli._cprint\") as mock_cprint:\n+        assert cli_obj._open_external_editor() is False\n+\n+    assert mock_cprint.called\n+    assert \"interactive cli\" in str(mock_cprint.call_args).lower()\n+\n+\n+def test_open_external_editor_rejects_modal_prompts():\n+    cli_obj = _make_cli()\n+    cli_obj._approval_state = {\"selected\": 0}\n+\n+    with patch(\"cli._cprint\") as mock_cprint:\n+        assert cli_obj._open_external_editor() is False\n+\n+    assert mock_cprint.called\n+    assert \"active prompt\" in str(mock_cprint.call_args).lower()\n+\n+def test_open_external_editor_uses_explicit_buffer_when_provided():\n+    cli_obj = _make_cli()\n+    external_buffer = _FakeBuffer()\n+\n+    assert cli_obj._open_external_editor(buffer=external_buffer) is True\n+    assert external_buffer.calls == [False]\n+    assert cli_obj._app.current_buffer.calls == []\n+\n+\n+def test_expand_paste_references_replaces_placeholder_with_file_contents(tmp_path):\n+    cli_obj = _make_cli()\n+    paste_file = tmp_path / \"paste.txt\"\n+    paste_file.write_text(\"line one\\nline two\", encoding=\"utf-8\")\n+\n+    text = f\"before [Pasted text #1: 2 lines → {paste_file}] after\"\n+    expanded = cli_obj._expand_paste_references(text)\n+\n+    assert expanded == \"before line one\\nline two after\"\n+\n+\n+def test_open_external_editor_expands_paste_placeholders_before_open(tmp_path):\n+    cli_obj = _make_cli()\n+    paste_file = tmp_path / \"paste.txt\"\n+    paste_file.write_text(\"alpha\\nbeta\", encoding=\"utf-8\")\n+    buffer = _FakeBuffer(text=f\"[Pasted text #1: 2 lines → {paste_file}]\")\n+\n+    assert cli_obj._open_external_editor(buffer=buffer) is True\n+    assert buffer.text == \"alpha\\nbeta\"\n+    assert buffer.cursor_position == len(\"alpha\\nbeta\")\n+    assert buffer.calls == [False]\n+\n+\n+def test_open_external_editor_sets_skip_collapse_flag_during_expansion(tmp_path):\n+    cli_obj = _make_cli()\n+    paste_file = tmp_path / \"paste.txt\"\n+    paste_file.write_text(\"a\\nb\\nc\\nd\\ne\\nf\", encoding=\"utf-8\")\n+    buffer = _FakeBuffer(text=f\"[Pasted text #1: 6 lines \\u2192 {paste_file}]\")\n+\n+    # After expansion the flag should have been set (to prevent re-collapse)\n+    assert cli_obj._open_external_editor(buffer=buffer) is True\n+    # Flag is consumed by _on_text_changed, but since no handler is attached\n+    # in tests it stays True until the handler resets it.\n+    assert cli_obj._skip_paste_collapse is True"]},{"file":"tests/cli/test_cli_markdown_rendering.py","hunks":["@@ -0,0 +1,117 @@\n+from io import StringIO\n+\n+from rich.console import Console\n+from rich.markdown import Markdown\n+\n+from cli import _render_final_assistant_content\n+\n+\n+def _render_to_text(renderable) -> str:\n+    buf = StringIO()\n+    Console(file=buf, width=80, force_terminal=False, color_system=None).print(renderable)\n+    return buf.getvalue()\n+\n+\n+def test_final_assistant_content_uses_markdown_renderable():\n+    renderable = _render_final_assistant_content(\"# Title\\n\\n- one\\n- two\")\n+\n+    assert isinstance(renderable, Markdown)\n+    output = _render_to_text(renderable)\n+    assert \"Title\" in output\n+    assert \"one\" in output\n+    assert \"two\" in output\n+\n+\n+def test_final_assistant_content_strips_ansi_before_markdown_rendering():\n+    renderable = _render_final_assistant_content(\"\\x1b[31m# Title\\x1b[0m\")\n+\n+    output = _render_to_text(renderable)\n+    assert \"Title\" in output\n+    assert \"\\x1b\" not in output\n+\n+\n+def test_final_assistant_content_can_strip_markdown_syntax():\n+    renderable = _render_final_assistant_content(\n+        \"***Bold italic***\\n~~Strike~~\\n- item\\n# Title\\n`code`\",\n+        mode=\"strip\",\n+    )\n+\n+    output = _render_to_text(renderable)\n+    assert \"Bold italic\" in output\n+    assert \"Strike\" in output\n+    assert \"item\" in output\n+    assert \"Title\" in output\n+    assert \"code\" in output\n+    assert \"***\" not in output\n+    assert \"~~\" not in output\n+    assert \"`\" not in output\n+\n+\n+def test_strip_mode_preserves_lists():\n+    renderable = _render_final_assistant_content(\n+        \"**Formatting**\\n- Ran prettier\\n- Files changed\\n- Verified clean\",\n+        mode=\"strip\",\n+    )\n+\n+    output = _render_to_text(renderable)\n+    assert \"- Ran prettier\" in output\n+    assert \"- Files changed\" in output\n+    assert \"- Verified clean\" in output\n+    assert \"**\" not in output\n+\n+\n+def test_strip_mode_preserves_ordered_lists():\n+    renderable = _render_final_assistant_content(\n+        \"1. First item\\n2. Second item\\n3. Third item\",\n+        mode=\"strip\",\n+    )\n+\n+    output = _render_to_text(renderable)\n+    assert \"1. First\" in output\n+    assert \"2. Second\" in output\n+    assert \"3. Third\" in output\n+\n+\n+def test_strip_mode_preserves_blockquotes():\n+    renderable = _render_final_assistant_content(\n+        \"> This is quoted text\\n> Another quoted line\",\n+        mode=\"strip\",\n+    )\n+\n+    output = _render_to_text(renderable)\n+    assert \"> This is quoted\" in output\n+    assert \"> Another quoted\" in output\n+\n+\n+def test_strip_mode_preserves_checkboxes():\n+    renderable = _render_final_assistant_content(\n+        \"- [ ] Todo item\\n- [x] Done item\",\n+        mode=\"strip\",\n+    )\n+\n+    output = _render_to_text(renderable)\n+    assert \"- [ ] Todo\" in output\n+    assert \"- [x] Done\" in output\n+\n+\n+def test_strip_mode_preserves_table_structure_while_cleaning_cell_markdown():\n+    renderable = _render_final_assistant_content(\n+        \"| Syntax | Example |\\n|---|---|\\n| Bold | `**bold**` |\\n| Strike | `~~strike~~` |\",\n+        mode=\"strip\",\n+    )\n+\n+    output = _render_to_text(renderable)\n+    assert \"| Syntax | Example |\" in output\n+    assert \"|---|---|\" in output\n+    assert \"| Bold | bold |\" in output\n+    assert \"| Strike | strike |\" in output\n+    assert \"**\" not in output\n+    assert \"~~\" not in output\n+    assert \"`\" not in output\n+\n+\n+def test_final_assistant_content_can_leave_markdown_raw():\n+    renderable = _render_final_assistant_content(\"***Bold italic***\", mode=\"raw\")\n+\n+    output = _render_to_text(renderable)\n+    assert \"***Bold italic***\" in output"]},{"file":"tests/cli/test_cli_user_message_preview.py","hunks":["@@ -0,0 +1,92 @@\n+import importlib\n+import os\n+import sys\n+from unittest.mock import MagicMock, patch\n+\n+sys.path.insert(0, os.path.join(os.path.dirname(__file__), \"..\"))\n+\n+\n+_cli_mod = None\n+\n+\n+def _make_cli(user_message_preview=None):\n+    global _cli_mod\n+    clean_config = {\n+        \"model\": {\n+            \"default\": \"anthropic/claude-opus-4.6\",\n+            \"base_url\": \"https://openrouter.ai/api/v1\",\n+            \"provider\": \"auto\",\n+        },\n+        \"display\": {\n+            \"compact\": False,\n+            \"tool_progress\": \"all\",\n+            \"user_message_preview\": user_message_preview or {\"first_lines\": 2, \"last_lines\": 2},\n+        },\n+        \"agent\": {},\n+        \"terminal\": {\"env_type\": \"local\"},\n+    }\n+    clean_env = {\"LLM_MODEL\": \"\", \"HERMES_MAX_ITERATIONS\": \"\"}\n+    prompt_toolkit_stubs = {\n+        \"prompt_toolkit\": MagicMock(),\n+        \"prompt_toolkit.history\": MagicMock(),\n+        \"prompt_toolkit.styles\": MagicMock(),\n+        \"prompt_toolkit.patch_stdout\": MagicMock(),\n+        \"prompt_toolkit.application\": MagicMock(),\n+        \"prompt_toolkit.layout\": MagicMock(),\n+        \"prompt_toolkit.layout.processors\": MagicMock(),\n+        \"prompt_toolkit.filters\": MagicMock(),\n+        \"prompt_toolkit.layout.dimension\": MagicMock(),\n+        \"prompt_toolkit.layout.menus\": MagicMock(),\n+        \"prompt_toolkit.widgets\": MagicMock(),\n+        \"prompt_toolkit.key_binding\": MagicMock(),\n+        \"prompt_toolkit.completion\": MagicMock(),\n+        \"prompt_toolkit.formatted_text\": MagicMock(),\n+        \"prompt_toolkit.auto_suggest\": MagicMock(),\n+    }\n+    with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(\"os.environ\", clean_env, clear=False):\n+        import cli as mod\n+\n+        mod = importlib.reload(mod)\n+        _cli_mod = mod\n+        with patch.object(mod, \"get_tool_definitions\", return_value=[]), patch.dict(mod.__dict__, {\"CLI_CONFIG\": clean_config}):\n+            return mod.HermesCLI()\n+\n+\n+class TestSubmittedUserMessagePreview:\n+    def test_default_preview_shows_first_two_lines_and_last_two_lines(self):\n+        cli = _make_cli()\n+\n+        rendered = cli._format_submitted_user_message_preview(\n+            \"line1\\nline2\\nline3\\nline4\\nline5\\nline6\"\n+        )\n+\n+        assert \"line1\" in rendered\n+        assert \"line2\" in rendered\n+        assert \"line5\" in rendered\n+        assert \"line6\" in rendered\n+        assert \"line3\" not in rendered\n+        assert \"line4\" not in rendered\n+        assert \"(+2 more lines)\" in rendered\n+\n+    def test_preview_can_hide_last_lines(self):\n+        cli = _make_cli({\"first_lines\": 2, \"last_lines\": 0})\n+\n+        rendered = cli._format_submitted_user_message_preview(\n+            \"line1\\nline2\\nline3\\nline4\\nline5\\nline6\"\n+        )\n+\n+        assert \"line1\" in rendered\n+        assert \"line2\" in rendered\n+        assert \"line5\" not in rendered\n+        assert \"line6\" not in rendered\n+        assert \"(+4 more lines)\" in rendered\n+\n+    def test_invalid_first_lines_value_falls_back_to_one(self):\n+        cli = _make_cli({\"first_lines\": 0, \"last_lines\": 2})\n+\n+        rendered = cli._format_submitted_user_message_preview(\"line1\\nline2\\nline3\\nline4\")\n+\n+        assert \"line1\" in rendered\n+        assert \"line3\" in rendered\n+        assert \"line4\" in rendered\n+        assert \"(+1 more line)\" in rendered"]},{"file":"tests/hermes_cli/test_config.py","hunks":["@@ -629,3 +629,10 @@ def test_migrate_adds_discord_channel_prompts_default(self, tmp_path):\n         assert raw[\"_config_version\"] == 20\n         assert raw[\"discord\"][\"auto_thread\"] is True\n         assert raw[\"discord\"][\"channel_prompts\"] == {}\n+\n+\n+class TestUserMessagePreviewConfig:\n+    def test_default_config_preview_line_counts(self):\n+        preview = DEFAULT_CONFIG[\"display\"][\"user_message_preview\"]\n+        assert preview[\"first_lines\"] == 2\n+        assert preview[\"last_lines\"] == 2\n"]}]},{"kind":"pr","number":50509,"title":"feat(cli): /prompt — compose your next prompt in $EDITOR","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"]}],"trust":{"default_tier":"verified","includes_suggested":false}}