fix: use TUI modal for slash confirmations

This commit is contained in:
zhengyuna 2026-05-11 12:14:41 +08:00 committed by Teknium
parent e155f2aca9
commit 054f568578
3 changed files with 391 additions and 67 deletions

371
cli.py
View file

@ -2558,6 +2558,8 @@ class HermesCLI:
self._approval_state = None
self._approval_deadline = 0
self._approval_lock = threading.Lock()
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._model_picker_state = None
self._secret_state = None
self._secret_deadline = 0
@ -3715,7 +3717,7 @@ class HermesCLI:
if self._command_running:
_cprint(f"{_DIM}Wait for the current command to finish before opening the editor.{_RST}")
return False
if self._sudo_state or self._secret_state or self._approval_state or self._clarify_state:
if self._sudo_state or self._secret_state or self._approval_state or self._slash_confirm_state or self._clarify_state:
_cprint(f"{_DIM}Finish the active prompt before opening the editor.{_RST}")
return False
target_buffer = buffer or getattr(app, "current_buffer", None)
@ -6059,6 +6061,194 @@ class HermesCLI:
_ask()
return result[0]
def _prompt_text_input_modal(
self,
*,
title: str,
detail: str,
choices: list[tuple[str, str, str]],
timeout: float = 120,
) -> str | None:
"""Prompt through the prompt_toolkit composer instead of raw input().
This is for CLI slash-command confirmations. The old raw input() path
fought prompt_toolkit's active stdin ownership: in some terminals the
prompt appeared above the TUI, choices were redrawn later, and Enter
could be interpreted as EOF/exit. A first-class modal state keeps the
choices visible and lets the normal Enter key binding submit the typed
or highlighted choice.
"""
import time as _time
if not choices:
return None
# If prompt_toolkit is not running (unit tests / non-interactive calls),
# keep the simple stdin fallback.
if not getattr(self, "_app", None):
return self._prompt_text_input("Choice [1/2/3]: ")
response_queue = queue.Queue()
self._capture_modal_input_snapshot()
self._slash_confirm_state = {
"title": title,
"detail": detail,
"choices": choices,
"selected": 0,
"response_queue": response_queue,
}
self._slash_confirm_deadline = _time.monotonic() + timeout
self._invalidate()
_last_countdown_refresh = _time.monotonic()
try:
while True:
try:
result = response_queue.get(timeout=1)
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._restore_modal_input_snapshot()
self._invalidate()
return result
except queue.Empty:
remaining = self._slash_confirm_deadline - _time.monotonic()
if remaining <= 0:
break
now = _time.monotonic()
if now - _last_countdown_refresh >= 5.0:
_last_countdown_refresh = now
self._invalidate()
finally:
if self._slash_confirm_state is not None:
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._restore_modal_input_snapshot()
self._invalidate()
return None
def _submit_slash_confirm_response(self, value: str | None) -> None:
state = self._slash_confirm_state
if not state:
return
state["response_queue"].put(value)
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._invalidate()
def _normalize_slash_confirm_choice(
self,
raw: str | None,
choices: list[tuple[str, str, str]],
) -> str | None:
if raw is None:
return None
choice_raw = raw.strip().lower()
if not choice_raw:
return None
aliases = {
"1": "once",
"once": "once",
"approve": "once",
"yes": "once",
"y": "once",
"ok": "once",
"2": "always",
"always": "always",
"remember": "always",
"3": "cancel",
"cancel": "cancel",
"nevermind": "cancel",
"no": "cancel",
"n": "cancel",
}
allowed = {choice[0] for choice in choices}
normalized = aliases.get(choice_raw)
if normalized in allowed:
return normalized
if choice_raw in allowed:
return choice_raw
return None
def _get_slash_confirm_display_fragments(self):
"""Render the /new-/clear-style confirmation panel."""
state = self._slash_confirm_state
if not state:
return []
title = state.get("title") or "Confirm action"
detail = state.get("detail") or ""
choices = state.get("choices") or []
selected = state.get("selected", 0)
def _panel_box_width(title_text: str, content_lines: list[str], min_width: int = 56, max_width: int = 86) -> int:
term_cols = shutil.get_terminal_size((100, 20)).columns
longest = max([len(title_text)] + [len(line) for line in content_lines] + [min_width - 4])
inner = min(max(longest + 4, min_width - 2), max_width - 2, max(24, term_cols - 6))
return inner + 2
def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "") -> list[str]:
wrapped = textwrap.wrap(
text,
width=max(8, width),
replace_whitespace=False,
drop_whitespace=False,
subsequent_indent=subsequent_indent,
)
return wrapped or [""]
def _append_panel_line(lines, border_style: str, content_style: str, text: str, box_width: int) -> None:
inner_width = max(0, box_width - 2)
lines.append((border_style, ""))
lines.append((content_style, text.ljust(inner_width)))
lines.append((border_style, "\n"))
def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:
lines.append((border_style, "" + (" " * box_width) + "\n"))
preview_lines = []
for line in detail.splitlines():
preview_lines.extend(_wrap_panel_text(line, 72))
for idx, (_value, label, desc) in enumerate(choices):
marker = "" if idx == selected else " "
preview_lines.extend(_wrap_panel_text(f"{marker} [{idx + 1}] {label}{desc}", 72, subsequent_indent=" "))
preview_lines.append("Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.")
box_width = _panel_box_width(title, preview_lines)
inner_text_width = max(8, box_width - 2)
detail_wrapped = []
for line in detail.splitlines():
detail_wrapped.extend(_wrap_panel_text(line, inner_text_width))
choice_wrapped: list[tuple[int, str]] = []
for idx, (_value, label, desc) in enumerate(choices):
marker = "" if idx == selected else " "
for wrapped in _wrap_panel_text(f"{marker} [{idx + 1}] {label}{desc}", inner_text_width, subsequent_indent=" "):
choice_wrapped.append((idx, wrapped))
term_rows = shutil.get_terminal_size((100, 24)).lines
reserved_below = 6
chrome_full = 6
available = max(0, term_rows - reserved_below)
max_detail_rows = max(1, available - chrome_full - len(choice_wrapped))
max_detail_rows = min(max_detail_rows, 8)
if len(detail_wrapped) > max_detail_rows:
keep = max(1, max_detail_rows - 1)
detail_wrapped = detail_wrapped[:keep] + ["… (detail truncated)"]
lines = []
lines.append(('class:approval-border', '' + ('' * box_width) + '\n'))
_append_panel_line(lines, 'class:approval-border', 'class:approval-title', title, box_width)
_append_blank_panel_line(lines, 'class:approval-border', box_width)
for wrapped in detail_wrapped:
_append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width)
_append_blank_panel_line(lines, 'class:approval-border', box_width)
for idx, wrapped in choice_wrapped:
style = 'class:approval-selected' if idx == selected else 'class:approval-choice'
_append_panel_line(lines, 'class:approval-border', style, wrapped, box_width)
_append_blank_panel_line(lines, 'class:approval-border', box_width)
_append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', 'Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.', box_width)
lines.append(('class:approval-border', '' + ('' * box_width) + '\n'))
return lines
def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:
"""Open prompt_toolkit-native /model picker modal."""
self._capture_modal_input_snapshot()
@ -8577,30 +8767,24 @@ class HermesCLI:
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]: ")
# Render a prompt_toolkit-native confirmation panel. This keeps option
# labels visible above the composer and avoids raw input()/EOF races with
# the running TUI.
choices = [
("once", "Approve Once", "proceed this time only"),
("always", "Always Approve", "proceed and silence this prompt permanently"),
("cancel", "Cancel", "keep current conversation"),
]
raw = self._prompt_text_input_modal(
title=f"⚠️ /{command} — destroys conversation state",
detail=detail,
choices=choices,
)
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:
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice is None:
print(f"🟡 Unrecognized choice '{raw}'. /{command} cancelled.")
return None
@ -8645,32 +8829,28 @@ class HermesCLI:
self._reload_mcp()
return
# Render warning + prompt. Use a single-line prompt so the user
# sees the warning as output and types a response into the composer.
print()
print("⚠️ /reload-mcp — Prompt cache invalidation warning")
print()
print(" Reloading MCP servers rebuilds the tool set for this session and")
print(" invalidates the provider prompt cache. The next message will")
print(" re-send full input tokens (can be expensive on long-context or")
print(" high-reasoning models).")
print()
print(" [1] Approve Once — reload now")
print(" [2] Always Approve — reload now and silence this prompt permanently")
print(" [3] Cancel — leave MCP tools unchanged")
print()
raw = self._prompt_text_input("Choice [1/2/3]: ")
# Render warning + prompt. Use the same prompt_toolkit-native composer
# modal as destructive slash confirmations so choices stay visible.
choices = [
("once", "Approve Once", "reload now"),
("always", "Always Approve", "reload now and silence this prompt permanently"),
("cancel", "Cancel", "leave MCP tools unchanged"),
]
raw = self._prompt_text_input_modal(
title="⚠️ /reload-mcp — Prompt cache invalidation warning",
detail=(
"Reloading MCP servers rebuilds the tool set for this session and\n"
"invalidates the provider prompt cache. The next message will\n"
"re-send full input tokens (can be expensive on long-context or\n"
"high-reasoning models)."
),
choices=choices,
)
if raw is None:
print("🟡 /reload-mcp cancelled (no input).")
return
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:
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice is None:
print(f"🟡 Unrecognized choice '{raw}'. /reload-mcp cancelled.")
return
@ -10594,6 +10774,8 @@ class HermesCLI:
return _state_fragment("class:sudo-prompt", "🔑")
if self._approval_state:
return _state_fragment("class:prompt-working", "")
if self._slash_confirm_state:
return _state_fragment("class:prompt-working", "")
if self._clarify_freetext:
return _state_fragment("class:clarify-selected", "")
if self._clarify_state:
@ -10660,6 +10842,7 @@ class HermesCLI:
sudo_widget,
secret_widget,
approval_widget,
slash_confirm_widget,
clarify_widget,
model_picker_widget=None,
spinner_widget=None,
@ -10684,6 +10867,7 @@ class HermesCLI:
sudo_widget,
secret_widget,
approval_widget,
slash_confirm_widget,
clarify_widget,
model_picker_widget,
spinner_widget,
@ -10846,6 +11030,13 @@ class HermesCLI:
self._approval_deadline = 0
self._approval_lock = threading.Lock() # serialize concurrent approval prompts (delegation race fix)
# Destructive slash-command confirmation state (/new, /clear, /undo).
# These prompts are answered through the prompt_toolkit composer, not
# raw input(), so the option labels stay visible and Enter does not EOF
# the whole app.
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
# Slash command loading state
self._command_running = False
self._command_status = ""
@ -10937,6 +11128,20 @@ class HermesCLI:
event.app.invalidate()
return
# --- Slash-command confirmation: submit typed or highlighted choice ---
if self._slash_confirm_state:
text = event.app.current_buffer.text.strip()
choices = self._slash_confirm_state.get("choices") or []
choice = self._normalize_slash_confirm_choice(text, choices) if text else None
if choice is None:
selected = self._slash_confirm_state.get("selected", 0)
if 0 <= selected < len(choices):
choice = choices[selected][0]
self._submit_slash_confirm_response(choice or "cancel")
event.app.current_buffer.reset()
event.app.invalidate()
return
# --- /model picker modal ---
if self._model_picker_state:
try:
@ -11197,6 +11402,20 @@ class HermesCLI:
self._approval_state["selected"] = min(max_idx, self._approval_state["selected"] + 1)
event.app.invalidate()
# --- Slash-command confirmation: arrow-key navigation ---
@kb.add('up', filter=Condition(lambda: bool(self._slash_confirm_state)))
def slash_confirm_up(event):
if self._slash_confirm_state:
self._slash_confirm_state["selected"] = max(0, self._slash_confirm_state.get("selected", 0) - 1)
event.app.invalidate()
@kb.add('down', filter=Condition(lambda: bool(self._slash_confirm_state)))
def slash_confirm_down(event):
if self._slash_confirm_state:
max_idx = len(self._slash_confirm_state.get("choices") or []) - 1
self._slash_confirm_state["selected"] = min(max_idx, self._slash_confirm_state.get("selected", 0) + 1)
event.app.invalidate()
# --- /model picker: arrow-key navigation ---
@kb.add('up', filter=Condition(lambda: bool(self._model_picker_state)))
def model_picker_up(event):
@ -11237,12 +11456,26 @@ class HermesCLI:
_idx = 9 if _num == 0 else _num - 1
kb.add(str(_num), filter=Condition(lambda: bool(self._approval_state)))(_make_approval_number_handler(_idx))
# Number keys for quick slash-confirm selection (1-9, 0 for 10th item)
def _make_slash_confirm_number_handler(idx):
def handler(event):
if self._slash_confirm_state and idx < len(self._slash_confirm_state.get("choices") or []):
choice = self._slash_confirm_state["choices"][idx][0]
self._submit_slash_confirm_response(choice)
event.app.current_buffer.reset()
event.app.invalidate()
return handler
for _num in range(10):
_idx = 9 if _num == 0 else _num - 1
kb.add(str(_num), filter=Condition(lambda: bool(self._slash_confirm_state)))(_make_slash_confirm_number_handler(_idx))
# --- History navigation: up/down browse history in normal input mode ---
# The TextArea is multiline, so by default up/down only move the cursor.
# Buffer.auto_up/auto_down handle both: cursor movement when multi-line,
# history browsing when on the first/last line (or single-line input).
_normal_input = Condition(
lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state and not self._model_picker_state
lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state and not self._model_picker_state
)
@kb.add('up', filter=_normal_input)
@ -11318,6 +11551,13 @@ class HermesCLI:
event.app.invalidate()
return
# Cancel slash confirmation prompt
if self._slash_confirm_state:
self._submit_slash_confirm_response("cancel")
event.app.current_buffer.reset()
event.app.invalidate()
return
# Cancel /model picker
if self._model_picker_state:
self._close_model_picker()
@ -11412,6 +11652,13 @@ class HermesCLI:
event.app.invalidate()
return
# Cancel slash confirmation prompt
if self._slash_confirm_state:
self._submit_slash_confirm_response("cancel")
event.app.current_buffer.reset()
event.app.invalidate()
return
# Cancel /model picker
if self._model_picker_state:
self._close_model_picker()
@ -11460,7 +11707,7 @@ class HermesCLI:
event.app.exit()
_modal_prompt_active = Condition(
lambda: bool(self._secret_state or self._sudo_state)
lambda: bool(self._secret_state or self._sudo_state or self._slash_confirm_state)
)
@kb.add('escape', filter=_modal_prompt_active, eager=True)
@ -11476,6 +11723,11 @@ class HermesCLI:
self._sudo_state = None
event.app.invalidate()
return
if self._slash_confirm_state:
self._submit_slash_confirm_response("cancel")
event.app.current_buffer.reset()
event.app.invalidate()
return
@kb.add('c-z')
def handle_ctrl_z(event):
@ -11558,7 +11810,7 @@ class HermesCLI:
# Guard: don't START recording during agent run or interactive prompts
if cli_ref._agent_running:
return
if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state:
if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state or cli_ref._slash_confirm_state:
return
# Guard: don't start while a previous stop/transcribe cycle is
# still running — recorder.stop() holds AudioRecorder._lock and
@ -11845,6 +12097,8 @@ class HermesCLI:
return "type secret (hidden), Enter to submit · ESC to skip"
if cli_ref._approval_state:
return ""
if cli_ref._slash_confirm_state:
return "type 1/2/3, or use ↑/↓ then Enter"
if cli_ref._clarify_freetext:
return "type your answer here and press Enter"
if cli_ref._clarify_state:
@ -11887,6 +12141,13 @@ class HermesCLI:
('class:clarify-countdown', f' ({remaining}s)'),
]
if cli_ref._slash_confirm_state:
remaining = max(0, int(cli_ref._slash_confirm_deadline - time.monotonic()))
return [
('class:hint', ' type 1/2/3, or ↑/↓ to select, Enter to confirm'),
('class:clarify-countdown', f' ({remaining}s)'),
]
if cli_ref._clarify_state:
remaining = max(0, int(cli_ref._clarify_deadline - time.monotonic()))
countdown = f' ({remaining}s)' if cli_ref._clarify_deadline else ''
@ -11909,7 +12170,7 @@ class HermesCLI:
return []
def get_hint_height():
if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._clarify_state or cli_ref._command_running:
if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._slash_confirm_state or cli_ref._clarify_state or cli_ref._command_running:
return 1
# Keep a spacer while the agent runs on roomy terminals, but reclaim
# the row on narrow/mobile screens where every line matters.
@ -12213,6 +12474,17 @@ class HermesCLI:
filter=Condition(lambda: cli_ref._approval_state is not None),
)
def _get_slash_confirm_display():
return cli_ref._get_slash_confirm_display_fragments()
slash_confirm_widget = ConditionalContainer(
Window(
FormattedTextControl(_get_slash_confirm_display),
wrap_lines=True,
),
filter=Condition(lambda: cli_ref._slash_confirm_state is not None),
)
# --- /model picker: display widget ---
def _get_model_picker_display():
state = cli_ref._model_picker_state
@ -12358,6 +12630,7 @@ class HermesCLI:
sudo_widget=sudo_widget,
secret_widget=secret_widget,
approval_widget=approval_widget,
slash_confirm_widget=slash_confirm_widget,
clarify_widget=clarify_widget,
model_picker_widget=model_picker_widget,
spinner_widget=spinner_widget,