feat: confirm prompt for destructive slash commands (#4069) (#22687)

/clear, /new, /reset, and /undo now ask the user to confirm before
discarding conversation state — three-option prompt routed through the
existing tools.slash_confirm primitive.

Native yes/no buttons render on Telegram, Discord, and Slack (their
adapters already implement send_slash_confirm); other platforms get a
text-fallback prompt and reply with /approve, /always, or /cancel.

The classic prompt_toolkit CLI uses the same three-option flow via the
established _prompt_text_input pattern (see _confirm_and_reload_mcp).
TUI keeps its existing modal overlay (#12312).

Gated by new config key approvals.destructive_slash_confirm (default
true). Picking 'Always Approve' flips the gate to false so subsequent
destructive commands run silently — matches the established
mcp_reload_confirm UX.

Out of scope: /cron remove (separate domain — scheduled jobs, not
session history). Existing TUI overlay env-var (HERMES_TUI_NO_CONFIRM)
left unchanged; cosmetic unification can come later.

Closes #4069.
This commit is contained in:
Teknium 2026-05-09 11:04:46 -07:00 committed by GitHub
parent 0cafe7d50d
commit b9c001116e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 730 additions and 3 deletions

89
cli.py
View file

@ -6751,6 +6751,12 @@ class HermesCLI:
self._force_full_redraw()
_cprint(f" {_DIM}✓ UI redrawn{_RST}")
elif canonical == "clear":
if self._confirm_destructive_slash(
"clear",
"This clears the screen and starts a new session.\n"
"The current conversation history will be discarded.",
) is None:
return
self.new_session(silent=True)
_clear_output_history()
# Clear terminal screen. Inside the TUI, Rich's console.clear()
@ -6873,6 +6879,12 @@ class HermesCLI:
elif canonical == "new":
parts = cmd_original.split(maxsplit=1)
title = parts[1].strip() if len(parts) > 1 else None
if self._confirm_destructive_slash(
"new",
"This starts a fresh session.\n"
"The current conversation history will be discarded.",
) is None:
return
self.new_session(title=title)
elif canonical == "resume":
self._handle_resume_command(cmd_original)
@ -6890,6 +6902,11 @@ class HermesCLI:
# Re-queue the message so process_loop sends it to the agent
self._pending_input.put(retry_msg)
elif canonical == "undo":
if self._confirm_destructive_slash(
"undo",
"This removes the last user/assistant exchange from history.",
) is None:
return
self.undo_last()
elif canonical == "branch":
self._handle_branch_command(cmd_original)
@ -8307,6 +8324,78 @@ class HermesCLI:
if _reload_thread.is_alive():
print(" ⚠️ MCP reload timed out (30s). Some servers may not have reconnected.")
def _confirm_destructive_slash(self, command: str, detail: str) -> Optional[str]:
"""Prompt the user to confirm a destructive session slash command.
Used by ``/clear``, ``/new``/``/reset``, and ``/undo`` before they
discard conversation state. Three-option prompt:
1. Approve Once proceed this time only
2. Always Approve proceed and persist
``approvals.destructive_slash_confirm: false`` so future
destructive commands run without confirmation
3. Cancel abort
Gated by ``approvals.destructive_slash_confirm`` (default on). If the
gate is off the function returns ``"once"`` immediately without
prompting.
Returns ``"once"``, ``"always"``, or ``None`` (cancelled). Callers
proceed with the destructive action when the result is non-None.
"""
# Gate check — respects prior "Always Approve" clicks.
try:
cfg = load_cli_config()
approvals = cfg.get("approvals") if isinstance(cfg, dict) else None
confirm_required = True
if isinstance(approvals, dict):
confirm_required = bool(approvals.get("destructive_slash_confirm", True))
except Exception:
confirm_required = True
if not confirm_required:
return "once"
# Render warning + prompt — single-line composer prompt, mirrors
# ``_confirm_and_reload_mcp``.
print()
print(f"⚠️ /{command} — destroys conversation state")
print()
for line in detail.splitlines():
print(f" {line}")
print()
print(" [1] Approve Once — proceed this time only")
print(" [2] Always Approve — proceed and silence this prompt permanently")
print(" [3] Cancel — keep current conversation")
print()
raw = self._prompt_text_input("Choice [1/2/3]: ")
if raw is None:
print(f"🟡 /{command} cancelled (no input).")
return None
choice_raw = raw.strip().lower()
if choice_raw in ("1", "once", "approve", "yes", "y", "ok"):
choice = "once"
elif choice_raw in ("2", "always", "remember"):
choice = "always"
elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""):
choice = "cancel"
else:
print(f"🟡 Unrecognized choice '{raw}'. /{command} cancelled.")
return None
if choice == "cancel":
print(f"🟡 /{command} cancelled. Conversation unchanged.")
return None
if choice == "always":
if save_config_value("approvals.destructive_slash_confirm", False):
print("🔒 Future /clear, /new, /reset, and /undo will run without confirmation.")
print(" Re-enable via `approvals.destructive_slash_confirm: true` in config.yaml.")
else:
print("⚠️ Couldn't persist opt-out — proceeding once.")
return choice
def _confirm_and_reload_mcp(self, cmd_original: str = "") -> None:
"""Interactive /reload-mcp — confirm with the user, then reload.