mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
feat(cli): Ctrl+G submits the edited draft on save (TUI parity) (#50560)
Ctrl+G already opened $EDITOR with the current draft, but used open_in_editor(validate_and_handle=False), which only loaded the saved text back into the input area — the user still had to press Enter. The TUI's Ctrl+G (openEditor) submits the draft on a clean exit. Since CLI submission is driven by the custom Enter keybinding (not the buffer accept_handler), validate_and_handle can't route through it; instead chain a done-callback on the editor Task that calls the new _submit_editor_buffer(), which mirrors the Enter handler's idle/queue/slash branches and drops an empty save.
This commit is contained in:
parent
4b09903de5
commit
74f0dd62e8
2 changed files with 161 additions and 1 deletions
76
cli.py
76
cli.py
|
|
@ -5379,12 +5379,86 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
# 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)
|
||||
# Open the editor, then submit the saved draft on a clean exit —
|
||||
# matching the TUI's Ctrl+G (openEditor), which sends the buffer
|
||||
# instead of requiring a second Enter. Submission in this CLI is
|
||||
# driven by the custom `enter` keybinding, NOT the buffer's
|
||||
# accept_handler, so validate_and_handle can't route through it;
|
||||
# chain a done-callback on the returned Task that re-uses the
|
||||
# real submit pipeline via _submit_editor_buffer().
|
||||
task = target_buffer.open_in_editor(validate_and_handle=False)
|
||||
if task is not None and hasattr(task, "add_done_callback"):
|
||||
task.add_done_callback(
|
||||
lambda _t, b=target_buffer: self._submit_editor_buffer(b)
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
_cprint(f"{_DIM}Failed to open external editor: {exc}{_RST}")
|
||||
return False
|
||||
|
||||
def _submit_editor_buffer(self, buffer) -> None:
|
||||
"""Submit the draft an external editor left in ``buffer``.
|
||||
|
||||
Invoked from the Ctrl+G done-callback so saving the editor sends the
|
||||
prompt (TUI parity) instead of leaving it sitting in the input area.
|
||||
Mirrors the idle/queue branches of the `enter` keybinding handler:
|
||||
an empty save is ignored (never submits a blank turn), a slash command
|
||||
is dispatched, otherwise the text is routed through the same input
|
||||
queues the normal Enter path uses. Runs on the prompt_toolkit event
|
||||
loop via the Task callback, so it must be cheap and non-blocking.
|
||||
"""
|
||||
try:
|
||||
text = (getattr(buffer, "text", "") or "").strip()
|
||||
except Exception:
|
||||
return
|
||||
if not text:
|
||||
# Editor saved empty / was cleared — match the TUI, which drops
|
||||
# an empty draft instead of submitting a blank turn.
|
||||
return
|
||||
|
||||
app = getattr(self, "_app", None)
|
||||
|
||||
# Slash commands: dispatch directly, same as the Enter handler's
|
||||
# _looks_like_slash_command branch.
|
||||
if _looks_like_slash_command(text):
|
||||
try:
|
||||
if not self.process_command(text):
|
||||
self._should_exit = True
|
||||
if app is not None and app.is_running:
|
||||
app.exit()
|
||||
except Exception as exc:
|
||||
_cprint(f" {_DIM}Command failed: {exc}{_RST}")
|
||||
finally:
|
||||
self._reset_input_buffer(buffer)
|
||||
if app is not None:
|
||||
app.invalidate()
|
||||
return
|
||||
|
||||
# Regular prompt: route through the same queues the Enter handler uses.
|
||||
if self._agent_running:
|
||||
# Agent busy → honour the configured busy-input behaviour by
|
||||
# queueing for the next turn (the safe default; interrupt/steer
|
||||
# remain reachable via the normal Enter path).
|
||||
self._interrupt_queue.put(text) if self.busy_input_mode == "interrupt" else self._pending_input.put(text)
|
||||
preview = text[:80] + ("..." if len(text) > 80 else "")
|
||||
_cprint(f" Queued for the next turn: {preview}")
|
||||
else:
|
||||
self._pending_input.put(text)
|
||||
|
||||
self._reset_input_buffer(buffer)
|
||||
if app is not None:
|
||||
app.invalidate()
|
||||
|
||||
def _reset_input_buffer(self, buffer) -> None:
|
||||
"""Clear an input buffer after a programmatic submit (best-effort)."""
|
||||
try:
|
||||
buffer.reset(append_to_history=True)
|
||||
except Exception:
|
||||
try:
|
||||
buffer.text = ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def _install_tool_callbacks(self) -> None:
|
||||
|
|
|
|||
86
tests/hermes_cli/test_ctrlg_editor_submit.py
Normal file
86
tests/hermes_cli/test_ctrlg_editor_submit.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Tests for Ctrl+G external-editor submit in the classic CLI.
|
||||
|
||||
Ctrl+G opens the current draft in ``$EDITOR``; on a clean save the draft is
|
||||
submitted (TUI parity) rather than left in the input area. Submission in the
|
||||
CLI is driven by the custom Enter keybinding, not the buffer accept_handler,
|
||||
so ``_open_external_editor`` chains a done-callback that calls
|
||||
``_submit_editor_buffer``. These exercise that submit helper directly.
|
||||
"""
|
||||
|
||||
import queue
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
class _FakeBuf:
|
||||
def __init__(self, text: str):
|
||||
self.text = text
|
||||
self.reset_called = False
|
||||
|
||||
def reset(self, append_to_history: bool = False):
|
||||
self.reset_called = True
|
||||
self.text = ""
|
||||
|
||||
|
||||
def _make(agent_running: bool = False, busy: str = "queue") -> HermesCLI:
|
||||
c = HermesCLI.__new__(HermesCLI)
|
||||
c._pending_input = queue.Queue()
|
||||
c._interrupt_queue = queue.Queue()
|
||||
c._agent_running = agent_running
|
||||
c.busy_input_mode = busy
|
||||
c._app = None
|
||||
c._should_exit = False
|
||||
return c
|
||||
|
||||
|
||||
def test_idle_prompt_routed_to_pending_input():
|
||||
c = _make()
|
||||
buf = _FakeBuf("Explain vector databases.\nKeep it short.")
|
||||
|
||||
c._submit_editor_buffer(buf)
|
||||
|
||||
assert c._pending_input.get_nowait() == "Explain vector databases.\nKeep it short."
|
||||
assert buf.reset_called
|
||||
|
||||
|
||||
def test_empty_save_does_not_submit():
|
||||
c = _make()
|
||||
buf = _FakeBuf(" \n \n")
|
||||
|
||||
c._submit_editor_buffer(buf)
|
||||
|
||||
assert c._pending_input.empty()
|
||||
# An empty save must not clear-and-submit a blank turn.
|
||||
assert not buf.reset_called
|
||||
|
||||
|
||||
def test_running_queue_mode_queues_for_next_turn():
|
||||
c = _make(agent_running=True, busy="queue")
|
||||
buf = _FakeBuf("next turn please")
|
||||
|
||||
c._submit_editor_buffer(buf)
|
||||
|
||||
assert c._pending_input.get_nowait() == "next turn please"
|
||||
assert c._interrupt_queue.empty()
|
||||
|
||||
|
||||
def test_running_interrupt_mode_uses_interrupt_queue():
|
||||
c = _make(agent_running=True, busy="interrupt")
|
||||
buf = _FakeBuf("interrupt this")
|
||||
|
||||
c._submit_editor_buffer(buf)
|
||||
|
||||
assert c._interrupt_queue.get_nowait() == "interrupt this"
|
||||
assert c._pending_input.empty()
|
||||
|
||||
|
||||
def test_slash_command_dispatched_not_queued():
|
||||
c = _make()
|
||||
seen = {}
|
||||
c.process_command = lambda command: seen.setdefault("cmd", command) or True
|
||||
buf = _FakeBuf("/status")
|
||||
|
||||
c._submit_editor_buffer(buf)
|
||||
|
||||
assert seen.get("cmd") == "/status"
|
||||
assert c._pending_input.empty()
|
||||
Loading…
Add table
Add a link
Reference in a new issue