feat(cli): add /update slash command to CLI and TUI (#23854)

* feat: add /update slash command to CLI and TUI

* test(cli): add Python tests for /update slash command

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): address Copilot review for /update slash command

Route classic CLI /update through prompt_toolkit modal confirmation and
defer relaunch to the main-thread cleanup path after app.exit(). Tighten
Y/n semantics, add Python wrapper and catalog coverage tests, and assert
/update stays visible in the TUI command catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): address review feedback on /update command

- Replace raw input() with _prompt_text_input_modal in _handle_update_command
  to avoid EOF/hang/keystroke-leak races with prompt_toolkit's stdin ownership
- Fix confirmation logic: only proceed on recognized affirmative aliases
  (y/yes/1/ok); cancel on everything else including empty string, typos,
  and unrecognized input — matches all other [Y/n] prompts in the codebase
- Route relaunch through main-thread shutdown path: set _pending_relaunch
  and return False from process_command so process_loop triggers app.exit();
  run() then calls relaunch() after prompt_toolkit has restored terminal modes
  and after cleanup — safe on both POSIX (execvp) and Windows (subprocess+exit)
- Fix misleading docstring in test_update_command.py: the Vitest only covers
  the TypeScript slash handler that emits code 42, not the Python wrapper
  branch that acts on it
- Rewrite tests to use SimpleNamespace pattern (like test_destructive_slash_confirm)
  so _prompt_text_input_modal can be stubbed directly
- Add Python test for _launch_tui exit-code-42 → relaunch branch in main.py

Agent-Logs-Url: https://github.com/NousResearch/hermes-agent/sessions/f6da68cf-e7b1-4b7a-aed6-3d4b0f523bdb

Co-authored-by: austinpickett <260188+austinpickett@users.noreply.github.com>

* fix(cli): polish test fixtures for /update command

- Remove unused _prompt_text_input from SimpleNamespace stub
- Use pytest.fail sentinel in managed-install guard test to catch unexpected modal invocations

Agent-Logs-Url: https://github.com/NousResearch/hermes-agent/sessions/f6da68cf-e7b1-4b7a-aed6-3d4b0f523bdb

Co-authored-by: austinpickett <260188+austinpickett@users.noreply.github.com>

* chore: re-trigger CI after Copilot review fixes

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: austinpickett <260188+austinpickett@users.noreply.github.com>
This commit is contained in:
Austin Pickett 2026-05-18 20:10:46 -04:00 committed by GitHub
parent 378bca1d2f
commit 2ef501e1f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 289 additions and 3 deletions

69
cli.py
View file

@ -2830,6 +2830,11 @@ class HermesCLI:
# process_command() when the user runs /exit --delete or /quit --delete.
# Ported from google-gemini/gemini-cli#19332.
self._delete_session_on_exit = False
# /update: when set, run() executes relaunch() after prompt_toolkit
# has fully exited and cleaned up terminal modes. Set by
# _handle_update_command() so the relaunch happens on the main thread,
# not the background process_loop thread.
self._pending_relaunch: list[str] | None = None
self._last_ctrl_c_time = 0
self._clarify_state = None
self._clarify_freetext = False
@ -7948,6 +7953,9 @@ class HermesCLI:
self._handle_copy_command(cmd_original)
elif canonical == "debug":
self._handle_debug_command()
elif canonical == "update":
if self._handle_update_command():
return False
elif canonical == "paste":
self._handle_paste_command()
elif canonical == "image":
@ -9230,6 +9238,58 @@ class HermesCLI:
args = SimpleNamespace(lines=200, expire=7, local=False)
run_debug_share(args)
def _handle_update_command(self) -> bool:
"""Handle /update — update Hermes Agent to the latest version.
In the classic CLI this exits the session and relaunches as
``hermes update`` so the user sees update output directly and gets
the new version on next launch.
Returns ``True`` when the update was confirmed (caller should trigger
app exit so the relaunch is deferred to the main thread after
prompt_toolkit cleans up terminal modes). Returns ``False`` / falsy
when cancelled.
"""
from hermes_cli.config import is_managed, format_managed_message
if is_managed():
print(f"{format_managed_message('update Hermes Agent')}")
return False
# Use the prompt_toolkit-native modal so the confirmation panel
# renders properly above the composer and avoids raw input() races
# with the prompt_toolkit event loop (same pattern as
# _confirm_destructive_slash).
choices = [
("once", "Update Now", "exit the current session and update Hermes Agent"),
("cancel", "Cancel", "keep the current session"),
]
raw = self._prompt_text_input_modal(
title="⚕ Update Hermes Agent",
detail="This will exit the current session and run `hermes update`.",
choices=choices,
)
if raw is None:
print(" 🟡 /update cancelled.")
return False
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice != "once":
print(" 🟡 /update cancelled.")
return False
print()
print(" ⚕ Launching update...")
print()
# Store the relaunch args so run() can exec them from the main thread
# after prompt_toolkit exits and restores terminal modes. Calling
# relaunch() directly here (from the process_loop daemon thread) would
# skip terminal cleanup on POSIX (execvp replaces the process mid-TUI)
# and only exit the worker thread on Windows (subprocess.run +
# sys.exit inside a non-main thread does not exit the process).
self._pending_relaunch = ["update"]
return True
def _show_usage(self):
"""Show rate limits (if available) and session token usage."""
if not self.agent:
@ -13937,6 +13997,15 @@ class HermesCLI:
_run_cleanup()
self._print_exit_summary()
# Deferred relaunch: /update sets _pending_relaunch so the exec
# happens here — after prompt_toolkit has exited and fully restored
# terminal modes — rather than from the background process_loop
# thread (which would skip terminal cleanup on POSIX and only exit
# the worker thread on Windows).
if getattr(self, '_pending_relaunch', None):
from hermes_cli.relaunch import relaunch
relaunch(self._pending_relaunch, preserve_inherited=False)
# ============================================================================
# Main Entry Point