Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4474,6 +4474,120 @@ def _ask():
_ask()
return result[0]

def _resolve_editor_command(self) -> list[str] | None:
"""Resolve an external editor command, supporting args like `code --wait`."""
import shlex

candidates: list[str] = []
env_editor = os.getenv("EDITOR") or os.getenv("VISUAL")
if env_editor:
candidates.append(env_editor)

candidates.extend((
"code --wait",
"codium --wait",
"subl -w",
"mate -w",
"gedit --wait",
"nano",
"nvim",
"vim",
"vi",
"micro",
"hx",
"notepad",
))

for candidate in candidates:
try:
parts = shlex.split(candidate, posix=(os.name != "nt"))
except ValueError:
continue
if not parts:
continue
executable = parts[0]
if os.path.isabs(executable):
if os.path.exists(executable):
return parts
continue
if shutil.which(executable):
return parts

return None

def _open_external_editor(self, initial_text: str = "") -> str | None:
"""Open a temp file in an external editor and return saved content."""
import subprocess
import threading

editor_cmd = self._resolve_editor_command()
if not editor_cmd:
_cprint(" No editor found. Set $EDITOR or $VISUAL, or install a common terminal editor.")
return None

fd, temp_path = tempfile.mkstemp(prefix="hermes-editor-", suffix=".md")
os.close(fd)
temp_file = Path(temp_path)

try:
if initial_text:
temp_file.write_text(initial_text, encoding="utf-8")

def _launch_editor() -> None:
subprocess.run(editor_cmd + [str(temp_file)], check=False)

in_main_thread = threading.current_thread() is threading.main_thread()
if getattr(self, "_app", None) and in_main_thread:
from prompt_toolkit.application import run_in_terminal

was_visible = getattr(self, "_status_bar_visible", True)
self._status_bar_visible = False
self._app.invalidate()
try:
run_in_terminal(_launch_editor)
finally:
self._status_bar_visible = was_visible
self._app.invalidate()
else:
_launch_editor()

edited_text = temp_file.read_text(encoding="utf-8")
except OSError as exc:
_cprint(f" Failed to launch editor: {exc}")
return None
finally:
try:
temp_file.unlink(missing_ok=True)
except OSError:
pass

if not edited_text.strip():
_cprint(" Editor cancelled (empty buffer).")
return None

return edited_text

def _handle_editor_command(self, cmd: str) -> None:
"""Handle /editor [/edit] by composing a multiline prompt externally."""
parts = cmd.strip().split(None, 1)
initial_text = parts[1] if len(parts) > 1 else ""
edited_text = self._open_external_editor(initial_text=initial_text)
if not edited_text:
return

if not hasattr(self, "_pending_input"):
_cprint(" Editor unavailable: input queue not initialized.")
return

self._pending_input.put(edited_text)
preview = " ".join(edited_text.strip().split())
if len(preview) > 80:
preview = f"{preview[:80]}..."
if not preview:
preview = "[multiline prompt]"
prefix = "for the next turn: " if getattr(self, "_agent_running", False) else ""
_cprint(f" Editor content queued {prefix}{preview}")

def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:
"""Open prompt_toolkit-native /model picker modal."""
self._capture_modal_input_snapshot()
Expand Down Expand Up @@ -5500,6 +5614,8 @@ def process_command(self, command: str) -> bool:
self._handle_background_command(cmd_original)
elif canonical == "btw":
self._handle_btw_command(cmd_original)
elif canonical == "editor":
self._handle_editor_command(cmd_original)
elif canonical == "queue":
# Extract prompt after "/queue " or "/q "
parts = cmd_original.split(None, 1)
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ class CommandDef:
aliases=("bg",), args_hint="<prompt>"),
CommandDef("btw", "Ephemeral side question using session context (no tools, not persisted)", "Session",
args_hint="<question>"),
CommandDef("editor", "Compose a multiline prompt in your editor", "Session",
cli_only=True, aliases=("edit",), args_hint="[initial text]"),
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
aliases=("q",), args_hint="<prompt>"),
CommandDef("status", "Show session info", "Session"),
Expand Down
103 changes: 103 additions & 0 deletions tests/cli/test_cli_editor_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Tests for the /editor CLI slash command."""

import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

from cli import HermesCLI


def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj._app = None
cli_obj._agent_running = False
cli_obj._pending_input = MagicMock()
cli_obj._status_bar_visible = True
return cli_obj


def _mkstemp_at(path: Path):
def _factory(*args, **kwargs):
fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600)
return fd, str(path)

return _factory


class TestCLIEditorCommand:
def test_process_command_queues_saved_editor_content(self):
cli_obj = _make_cli()

with patch.object(cli_obj, "_open_external_editor", return_value="line 1\nline 2") as mock_open, \
patch("cli._cprint"):
assert cli_obj.process_command("/editor") is True

mock_open.assert_called_once_with(initial_text="")
cli_obj._pending_input.put.assert_called_once_with("line 1\nline 2")

def test_edit_alias_dispatches_with_initial_text(self):
cli_obj = _make_cli()

with patch.object(cli_obj, "_open_external_editor", return_value="updated body") as mock_open, \
patch("cli._cprint"):
assert cli_obj.process_command("/edit Draft title") is True

mock_open.assert_called_once_with(initial_text="Draft title")
cli_obj._pending_input.put.assert_called_once_with("updated body")

def test_process_command_does_not_queue_when_editor_returns_none(self):
cli_obj = _make_cli()

with patch.object(cli_obj, "_open_external_editor", return_value=None), \
patch("cli._cprint"):
assert cli_obj.process_command("/editor") is True

cli_obj._pending_input.put.assert_not_called()

def test_resolve_editor_command_prefers_editor_env_with_args(self):
cli_obj = _make_cli()

with patch.dict(os.environ, {"EDITOR": "code --wait"}, clear=False), \
patch("cli.shutil.which", side_effect=lambda cmd: f"/usr/bin/{cmd}" if cmd == "code" else None):
assert cli_obj._resolve_editor_command() == ["code", "--wait"]

def test_resolve_editor_command_falls_back_to_common_editor(self, monkeypatch):
cli_obj = _make_cli()
monkeypatch.delenv("EDITOR", raising=False)
monkeypatch.delenv("VISUAL", raising=False)

with patch("cli.shutil.which", side_effect=lambda cmd: f"/usr/bin/{cmd}" if cmd == "nano" else None):
assert cli_obj._resolve_editor_command() == ["nano"]

def test_open_external_editor_reads_saved_file_and_cleans_up(self, tmp_path):
cli_obj = _make_cli()
temp_path = tmp_path / "compose.md"
seen = {}

def _fake_subprocess_run(cmd, check=False):
file_path = Path(cmd[-1])
seen["initial"] = file_path.read_text(encoding="utf-8")
file_path.write_text("final body\nwith details", encoding="utf-8")
return SimpleNamespace(returncode=0)

with patch.object(cli_obj, "_resolve_editor_command", return_value=["nano"]), \
patch("cli.tempfile.mkstemp", side_effect=_mkstemp_at(temp_path)), \
patch("subprocess.run", side_effect=_fake_subprocess_run), \
patch("cli._cprint"):
result = cli_obj._open_external_editor(initial_text="draft body")

assert result == "final body\nwith details"
assert seen["initial"] == "draft body"
assert not temp_path.exists()

def test_open_external_editor_reports_missing_editor(self):
cli_obj = _make_cli()

with patch.object(cli_obj, "_resolve_editor_command", return_value=None), \
patch("cli._cprint") as mock_print:
result = cli_obj._open_external_editor()

assert result is None
rendered = " ".join(str(arg) for call in mock_print.call_args_list for arg in call.args)
assert "No editor found" in rendered
1 change: 1 addition & 0 deletions tests/hermes_cli/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ def test_canonical_name_resolves(self):

def test_alias_resolves_to_canonical(self):
assert resolve_command("bg").name == "background"
assert resolve_command("edit").name == "editor"
assert resolve_command("reset").name == "new"
assert resolve_command("q").name == "quit"
assert resolve_command("exit").name == "quit"
Expand Down