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:
Teknium 2026-06-21 22:43:55 -07:00 committed by GitHub
parent 4b09903de5
commit 74f0dd62e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 161 additions and 1 deletions

76
cli.py
View file

@ -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:

View 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()