From 74f0dd62e87536e2d53ece79a71f9a1fa75f038c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:43:55 -0700 Subject: [PATCH] feat(cli): Ctrl+G submits the edited draft on save (TUI parity) (#50560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli.py | 76 ++++++++++++++++- tests/hermes_cli/test_ctrlg_editor_submit.py | 86 ++++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_ctrlg_editor_submit.py diff --git a/cli.py b/cli.py index a195f8ab5f2..6ee25e2fcec 100644 --- a/cli.py +++ b/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: diff --git a/tests/hermes_cli/test_ctrlg_editor_submit.py b/tests/hermes_cli/test_ctrlg_editor_submit.py new file mode 100644 index 00000000000..4864d84602a --- /dev/null +++ b/tests/hermes_cli/test_ctrlg_editor_submit.py @@ -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()