diff --git a/cli.py b/cli.py index ea4f1eec0..9027b4d70 100644 --- a/cli.py +++ b/cli.py @@ -2886,6 +2886,39 @@ class HermesCLI: 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. @@ -4063,6 +4096,7 @@ class HermesCLI: _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: @@ -8978,6 +9012,16 @@ class HermesCLI: """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. @@ -9429,6 +9473,7 @@ class HermesCLI: _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. @@ -9448,8 +9493,9 @@ class HermesCLI: 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') @@ -9458,12 +9504,10 @@ class HermesCLI: 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 --git a/tests/cli/test_cli_external_editor.py b/tests/cli/test_cli_external_editor.py new file mode 100644 index 000000000..082c5e40f --- /dev/null +++ b/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