mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
fix: use TUI modal for slash confirmations
This commit is contained in:
parent
e155f2aca9
commit
054f568578
3 changed files with 391 additions and 67 deletions
371
cli.py
371
cli.py
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ don't have to construct a full HermesCLI (which requires extensive setup).
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
|
@ -17,10 +18,17 @@ def _bound(fn, instance):
|
|||
|
||||
def _make_self(prompt_response):
|
||||
"""Build a minimal stand-in 'self' for _confirm_destructive_slash."""
|
||||
return SimpleNamespace(
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_app=None,
|
||||
_prompt_text_input=lambda _prompt: prompt_response,
|
||||
_prompt_text_input_modal=lambda **_kw: prompt_response,
|
||||
)
|
||||
self_._normalize_slash_confirm_choice = _bound(
|
||||
HermesCLI._normalize_slash_confirm_choice, self_,
|
||||
)
|
||||
return self_
|
||||
|
||||
|
||||
def test_gate_off_returns_once_without_prompting():
|
||||
|
|
@ -117,7 +125,6 @@ def test_gate_on_choice_always_persists_and_returns_always():
|
|||
self_ = _make_self(prompt_response="2")
|
||||
|
||||
saves = []
|
||||
|
||||
def _fake_save(key, value):
|
||||
saves.append((key, value))
|
||||
return True
|
||||
|
|
@ -150,3 +157,55 @@ def test_gate_default_true_when_config_missing():
|
|||
# treated as on despite the config error. If the gate had been off
|
||||
# this would have returned 'once' without consulting the prompt.
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_slash_confirm_modal_number_selection_submits_without_raw_input():
|
||||
"""Pressing 2 in the TUI modal should resolve to Always Approve directly."""
|
||||
from cli import HermesCLI
|
||||
|
||||
q = queue.Queue()
|
||||
self_ = SimpleNamespace(
|
||||
_slash_confirm_state={
|
||||
"choices": [
|
||||
("once", "Approve Once", "proceed once"),
|
||||
("always", "Always Approve", "persist opt-out"),
|
||||
("cancel", "Cancel", "abort"),
|
||||
],
|
||||
"selected": 0,
|
||||
"response_queue": q,
|
||||
},
|
||||
_slash_confirm_deadline=123,
|
||||
_invalidate=lambda: None,
|
||||
)
|
||||
|
||||
_bound(HermesCLI._submit_slash_confirm_response, self_)("always")
|
||||
|
||||
assert q.get_nowait() == "always"
|
||||
assert self_._slash_confirm_state is None
|
||||
assert self_._slash_confirm_deadline == 0
|
||||
|
||||
|
||||
def test_slash_confirm_display_fragments_include_choice_mapping():
|
||||
"""The modal itself must show what 1/2/3 mean, not only 'Choice [1/2/3]'."""
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_slash_confirm_state={
|
||||
"title": "⚠️ /new — destroys conversation state",
|
||||
"detail": "This starts a fresh session.",
|
||||
"choices": [
|
||||
("once", "Approve Once", "proceed once"),
|
||||
("always", "Always Approve", "persist opt-out"),
|
||||
("cancel", "Cancel", "abort"),
|
||||
],
|
||||
"selected": 1,
|
||||
},
|
||||
)
|
||||
|
||||
fragments = _bound(HermesCLI._get_slash_confirm_display_fragments, self_)()
|
||||
rendered = "".join(fragment for _style, fragment in fragments)
|
||||
|
||||
assert "[1] Approve Once" in rendered
|
||||
assert "[2] Always Approve" in rendered
|
||||
assert "[3] Cancel" in rendered
|
||||
assert "Type 1/2/3" in rendered
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
"""Tests for ``HermesCLI._prompt_text_input`` thread-safe input dispatch.
|
||||
|
||||
Slash commands (``/clear``, ``/new``, ``/undo``, ``/reload-mcp``) are dispatched
|
||||
from the ``process_loop`` daemon thread. ``prompt_toolkit.run_in_terminal``
|
||||
returns a coroutine that only the main-thread event loop can drive; calling it
|
||||
from a daemon thread orphans the coroutine, ``_ask`` never runs, and user
|
||||
keystrokes leak into the composer instead of the confirmation prompt
|
||||
(see issue #23185).
|
||||
|
||||
The fix mirrors ``_run_curses_picker``: when off the main thread, fall back to
|
||||
a direct ``input()`` call so the prompt actually renders and consumes
|
||||
keystrokes.
|
||||
Raw ``input()`` prompts can race with prompt_toolkit when called from the TUI.
|
||||
The normal slash confirmations now use a prompt_toolkit-native modal, but
|
||||
``_prompt_text_input`` remains as a fallback for non-interactive calls and edge
|
||||
cases.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
|
@ -17,7 +11,7 @@ from unittest.mock import MagicMock, patch
|
|||
|
||||
|
||||
def _make_cli():
|
||||
"""Minimal HermesCLI shell exposing ``_prompt_text_input``."""
|
||||
"""Minimal HermesCLI shell exposing prompt fallback helpers."""
|
||||
import cli as cli_mod
|
||||
|
||||
obj = object.__new__(cli_mod.HermesCLI)
|
||||
|
|
@ -33,7 +27,7 @@ class TestPromptTextInputThreadSafety:
|
|||
|
||||
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
|
||||
patch("builtins.input", return_value="2"):
|
||||
result = cli._prompt_text_input("Choice: ")
|
||||
cli._prompt_text_input("Choice: ")
|
||||
|
||||
# run_in_terminal was invoked; the _ask closure passed to it would
|
||||
# call input() when driven by the event loop. We assert dispatch path,
|
||||
|
|
@ -43,10 +37,8 @@ class TestPromptTextInputThreadSafety:
|
|||
def test_background_thread_falls_back_to_direct_input(self):
|
||||
"""On a daemon thread, skip run_in_terminal and call input() directly.
|
||||
|
||||
This is the bug from issue #23185: process_loop dispatches slash
|
||||
commands on a daemon thread, so run_in_terminal's coroutine is
|
||||
orphaned. The fallback must drive input() itself so user keystrokes
|
||||
don't leak into the agent buffer.
|
||||
This preserves the fallback for any prompt that still runs off the main
|
||||
UI thread: run_in_terminal's coroutine would otherwise be orphaned.
|
||||
"""
|
||||
cli = _make_cli()
|
||||
captured = {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue