mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(cli): add editor workflow for drafts
This commit is contained in:
parent
09ced16ecc
commit
a2b5627e6d
2 changed files with 152 additions and 3 deletions
50
cli.py
50
cli.py
|
|
@ -2886,6 +2886,39 @@ class HermesCLI:
|
||||||
self._command_status = ""
|
self._command_status = ""
|
||||||
self._invalidate(min_interval=0.0)
|
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:
|
def _ensure_runtime_credentials(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Ensure runtime credentials are resolved before agent use.
|
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"\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}Multi-line: Alt+Enter for a new line{_RST}")
|
||||||
|
_cprint(f" {_DIM}Draft editor: Ctrl+G{_RST}")
|
||||||
if _is_termux_environment():
|
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")
|
_cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n")
|
||||||
else:
|
else:
|
||||||
|
|
@ -8978,6 +9012,16 @@ class HermesCLI:
|
||||||
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
|
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
|
||||||
event.current_buffer.insert_text('\n')
|
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)
|
@kb.add('tab', eager=True)
|
||||||
def handle_tab(event):
|
def handle_tab(event):
|
||||||
"""Tab: accept completion, auto-suggestion, or start completions.
|
"""Tab: accept completion, auto-suggestion, or start completions.
|
||||||
|
|
@ -9429,6 +9473,7 @@ class HermesCLI:
|
||||||
_prev_text_len = [0]
|
_prev_text_len = [0]
|
||||||
_prev_newline_count = [0]
|
_prev_newline_count = [0]
|
||||||
_paste_just_collapsed = [False]
|
_paste_just_collapsed = [False]
|
||||||
|
self._skip_paste_collapse = False
|
||||||
|
|
||||||
def _on_text_changed(buf):
|
def _on_text_changed(buf):
|
||||||
"""Detect large pastes and collapse them to a file reference.
|
"""Detect large pastes and collapse them to a file reference.
|
||||||
|
|
@ -9448,8 +9493,9 @@ class HermesCLI:
|
||||||
text = buf.text
|
text = buf.text
|
||||||
chars_added = len(text) - _prev_text_len[0]
|
chars_added = len(text) - _prev_text_len[0]
|
||||||
_prev_text_len[0] = len(text)
|
_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
|
_paste_just_collapsed[0] = False
|
||||||
|
self._skip_paste_collapse = False
|
||||||
_prev_newline_count[0] = text.count('\n')
|
_prev_newline_count[0] = text.count('\n')
|
||||||
return
|
return
|
||||||
line_count = text.count('\n')
|
line_count = text.count('\n')
|
||||||
|
|
@ -9458,12 +9504,10 @@ class HermesCLI:
|
||||||
is_paste = chars_added > 1 or newlines_added >= 4
|
is_paste = chars_added > 1 or newlines_added >= 4
|
||||||
if line_count >= 5 and is_paste and not text.startswith('/'):
|
if line_count >= 5 and is_paste and not text.startswith('/'):
|
||||||
_paste_counter[0] += 1
|
_paste_counter[0] += 1
|
||||||
# Save to temp file
|
|
||||||
paste_dir = _hermes_home / "pastes"
|
paste_dir = _hermes_home / "pastes"
|
||||||
paste_dir.mkdir(parents=True, exist_ok=True)
|
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 = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
|
||||||
paste_file.write_text(text, encoding="utf-8")
|
paste_file.write_text(text, encoding="utf-8")
|
||||||
# Replace buffer with compact reference
|
|
||||||
_paste_just_collapsed[0] = True
|
_paste_just_collapsed[0] = True
|
||||||
buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]"
|
buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]"
|
||||||
buf.cursor_position = len(buf.text)
|
buf.cursor_position = len(buf.text)
|
||||||
|
|
|
||||||
105
tests/cli/test_cli_external_editor.py
Normal file
105
tests/cli/test_cli_external_editor.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue