Files
hermes-agent/hermes_cli/cli_modal_mixin.py
Teknium eb74a00c71 refactor(cli): split HermesCLI into 10 cohesive mixins (cli.py 22,284 -> 9,150)
326 methods lifted by AST (bodies identical; ast.dump-verified) into
hermes_cli/cli_{tui,status_bar,voice,model_switch,session,stream,modal,
terminal,info,loops}_mixin.py. cli.py-internal symbols resolve via lazy
'from cli import ...' inside each method (no import cycle; patch('cli.X')
keeps working). The three 'global' writers (_skill_commands, _cli_wake_owner)
now write the cli module attribute explicitly so the origin's readers still
see them. Dropped imports left unused in cli.py; kept display_hermes_home /
build_welcome_banner as re-exports (mixins + tests resolve them via cli).
Repointed two AST change-detector tests to cli_tui_mixin.py; one test
fixture now keeps 'cli' in sys.modules across its patch.dict scope.
2026-09-02 15:42:24 -07:00

1229 lines
51 KiB
Python

"""Modal overlays for the interactive CLI: clarify, approval, sudo/secret capture, command palette, slash-confirm, external editor
Mixin split out of ``cli.py``; bound onto ``HermesCLI`` via the MRO. cli.py-internal
symbols are imported LAZILY inside each method (``from cli import ...``) — the mixin
never imports ``cli`` at module load time (import cycle).
"""
from __future__ import annotations
import json
import queue
import sys
from hermes_cli.callbacks import prompt_for_secret
from typing import Optional
class CLIModalMixin:
"""Modal overlays for the interactive CLI: clarify, approval, sudo/secret capture, command palette, slash-confirm, external editor"""
def _open_external_editor(self, buffer=None) -> bool:
"""Open the active input buffer in an external editor."""
from cli import _DIM, _RST, _cprint
app = getattr(self, "_app", None)
if not app:
_cprint(f"{_DIM}External editor is only available inside the interactive CLI.{_RST}")
return False
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 getattr(self, "_slash_confirm_state", None) 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)
if target_buffer is None:
_cprint(f"{_DIM}No active input buffer is available for the external editor.{_RST}")
return False
try:
# Inline pastes so the editor (and the draft it submits) sees real
# content; skip flag unconditionally so the editor-close text-change
# doesn't re-collapse it, even when there was nothing to inline.
self._inline_pastes(target_buffer)
self._skip_paste_collapse = True
# 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.
"""
from cli import _DIM, _RST, _cprint, _looks_like_slash_command
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)
# `!<command>` shell mode, checked before slash dispatch — matches the
# Enter path in the input loop so an editor-saved bang command runs
# locally instead of being sent to the agent.
try:
if self.handle_bang_shell(text):
self._reset_input_buffer(buffer)
if app is not None:
app.invalidate()
return
except Exception as exc:
_cprint(f" {_DIM}Shell command failed: {exc}{_RST}")
self._reset_input_buffer(buffer)
if app is not None:
app.invalidate()
return
# 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 _inline_pastes(self, buffer) -> None:
"""Replace collapsed-paste placeholders in ``buffer`` with real content.
A big paste shows as a compact ``[Pasted text #N -> file]`` placeholder,
but history recall and the external editor need the actual text — a bare
reference is useless once the file is gone or on another machine. Inlining
before ``reset(append_to_history=True)`` also lets prompt_toolkit persist
the content through its normal path. Sets ``_skip_paste_collapse`` so the
ensuing text-change doesn't re-collapse it.
"""
from cli import logger
try:
existing = getattr(buffer, "text", "")
expanded = self._expand_paste_references(existing)
if expanded != existing and hasattr(buffer, "text"):
self._skip_paste_collapse = True
buffer.text = expanded
if hasattr(buffer, "cursor_position"):
buffer.cursor_position = len(expanded)
except Exception:
logger.debug("Failed to inline paste placeholders", exc_info=True)
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 _prefill_input_buffer(self, text: str) -> None:
"""Place ``text`` in the active prompt_toolkit buffer, editable."""
from cli import logger
app = getattr(self, "_app", None)
if app is None:
return
try:
buf = app.current_buffer
buf.text = text
if hasattr(buf, "cursor_position"):
buf.cursor_position = len(text)
app.invalidate()
except Exception as e:
logger.debug("undo: prefill buffer failed: %s", e)
def _prompt_text_input(self, prompt_text: str) -> str | None:
"""Prompt for free-text input safely inside or outside prompt_toolkit.
``run_in_terminal`` returns a coroutine that must be awaited by the prompt_toolkit event loop,
which only exists on the main thread. Slash commands are dispatched from
the ``process_loop`` daemon thread (see issue #23185), so calling
``run_in_terminal`` from there orphans the coroutine — ``_ask`` never runs,
and user keystrokes leak into the composer instead. Fall back to a direct
``input()`` when we're off the main thread.
"""
import threading
result = [None]
def _ask():
try:
result[0] = input(prompt_text).strip() or None
except (KeyboardInterrupt, EOFError):
pass
in_main_thread = threading.current_thread() is threading.main_thread()
# Slash-worker guard (#23185 / billing auto-reload hang): when a
# prompt_toolkit app is running but we're on a non-main thread (the
# process_loop / TUI slash-worker daemon thread), stdin is owned by the
# event loop / JSON-RPC pipe. A bare input() there blocks forever until
# the worker's 45s timeout fires. We cannot safely prompt off the main
# thread, so cancel cleanly (None) instead of hanging — mirrors the
# _stdin_fallback discipline in _prompt_text_input_modal.
if self._app and not in_main_thread:
self._invalidate()
return None
if self._app and in_main_thread:
from prompt_toolkit.application import run_in_terminal
was_visible = self._status_bar_visible
self._status_bar_visible = False
self._app.invalidate()
try:
run_in_terminal(_ask)
except Exception:
# WSL / Warp / certain terminal emulators silently drop the
# scheduled coroutine. Fall back to a direct input() so the
# user's keystrokes don't leak into the agent buffer.
try:
_ask()
except Exception:
pass
finally:
self._status_bar_visible = was_visible
self._app.invalidate()
else:
_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.
**Platform note (Windows — issue #33961):**
Earlier code bypassed the modal on ``sys.platform == "win32"`` and fell
back to a raw ``input()`` prompt. When the confirm was triggered from the
``process_loop`` daemon thread (the normal case) that ``input()`` ran off
the main thread and deadlocked against prompt_toolkit's stdin ownership —
the user saw a frozen cursor and Ctrl-C was swallowed (bare ``/reset``
froze; ``/reset now`` worked only because it skips the prompt entirely).
Native Windows now uses the same path as Linux/macOS: the modal is set up
on ``self._app.loop`` via ``call_soon_threadsafe`` and answered by the
normal prompt_toolkit key bindings (the same input channel that already
handles ordinary typing on Windows). The raw ``input()`` fallback is kept
only for the genuinely safe cases: no running app (unit tests /
non-interactive), no resolvable event loop, or a scheduling failure.
"""
import threading
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]: ")
try:
app_loop = self._app.loop
except Exception:
app_loop = None
in_main_thread = threading.current_thread() is threading.main_thread()
def _stdin_fallback() -> str | None:
# On native Windows a raw input() from a non-main thread deadlocks
# against prompt_toolkit's stdin ownership (#33961). With an app
# running we cannot safely prompt off the main thread, so cancel
# cleanly (None) rather than hang the terminal.
if sys.platform == "win32" and not in_main_thread:
self._invalidate()
return None
return self._prompt_text_input("Choice [1/2/3]: ")
if not in_main_thread and app_loop is None:
return _stdin_fallback()
response_queue = queue.Queue()
def _setup_modal() -> None:
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()
def _teardown_modal() -> None:
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._restore_modal_input_snapshot()
self._invalidate()
def _run_on_app_loop(fn) -> bool:
if in_main_thread or app_loop is None:
fn()
return True
ready = threading.Event()
def _wrapped() -> None:
try:
fn()
finally:
ready.set()
try:
app_loop.call_soon_threadsafe(_wrapped)
except Exception:
return False
return ready.wait(timeout=5)
if not _run_on_app_loop(_setup_modal):
return _stdin_fallback()
_last_countdown_refresh = _time.monotonic()
try:
while True:
try:
result = response_queue.get(timeout=1)
_run_on_app_loop(_teardown_modal)
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:
_run_on_app_loop(_teardown_modal)
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 _build_command_palette_entries(self) -> list:
"""Flat list of (command, description) for the Ctrl+P palette.
Sourced from the same COMMAND_REGISTRY that backs /help, filtered to
commands available on this surface, plus installed skill commands.
Selecting an entry inserts the exact command string — never a fuzzy
resolution.
"""
from cli import _ensure_skill_commands
from hermes_cli.commands import COMMANDS_BY_CATEGORY
entries: list[tuple[str, str, str]] = [] # (command, category, desc)
for category, commands in COMMANDS_BY_CATEGORY.items():
for cmd, desc in commands.items():
if not self._command_available(cmd):
continue
entries.append((cmd, category, desc))
try:
for cmd, info in sorted(_ensure_skill_commands().items()):
entries.append((cmd, "Skill", info.get("description", "")))
except Exception:
pass
return entries
def _open_command_palette(self) -> None:
"""Open the Ctrl+P fuzzy command palette modal."""
if getattr(self, "_command_palette_state", None):
return
# Don't stack over other modals.
if (self._model_picker_state or self._clarify_state or self._approval_state
or self._slash_confirm_state or self._sudo_state or self._secret_state):
return
self._capture_modal_input_snapshot()
self._command_palette_state = {
"entries": self._build_command_palette_entries(),
"filter": "",
"selected": 0,
"_scroll_offset": 0,
}
self._invalidate(min_interval=0.0)
def _close_command_palette(self) -> None:
self._command_palette_state = None
self._restore_modal_input_snapshot()
self._invalidate(min_interval=0.0)
def _command_palette_visible_entries(self) -> list:
"""Return (command, category, desc) rows matching the active filter.
Ranked, command-name-focused matching (a bare subsequence over the
whole "cmd category desc" string is uselessly permissive — "steer"
would match 130+ rows via description text). Priority:
0 exact command match
1 command startswith query
2 query substring in command
3 query subsequence in command
4 query substring in description
Rows that match nowhere are dropped. Ties keep registry order.
"""
state = self._command_palette_state or {}
entries = state.get("entries") or []
q = (state.get("filter", "") or "").strip().lower()
if not q:
return list(entries)
def _subseq(needle: str, hay: str) -> bool:
it = iter(hay)
return all(ch in it for ch in needle)
ranked = []
for order, row in enumerate(entries):
cmd, _cat, desc = row
name = cmd.lower().lstrip("/")
qn = q.lstrip("/")
desc_l = (desc or "").lower()
if name == qn:
rank = 0
elif name.startswith(qn):
rank = 1
elif qn in name:
rank = 2
elif _subseq(qn, name):
rank = 3
elif q in desc_l:
rank = 4
else:
continue
ranked.append((rank, order, row))
ranked.sort(key=lambda t: (t[0], t[1]))
return [row for (_r, _o, row) in ranked]
def _handle_command_palette_selection(self) -> None:
"""Insert the selected command into the composer (does not auto-run)."""
from cli import logger
state = self._command_palette_state
if not state:
return
rows = self._command_palette_visible_entries()
selected = state.get("selected", 0)
if not (0 <= selected < len(rows)):
self._close_command_palette()
return
cmd = rows[selected][0] # exact command string, e.g. "/model"
self._close_command_palette()
# Prefill the composer so the user can add args / confirm — never
# auto-execute (a palette pick should be explicit, and many commands
# take arguments).
try:
app = getattr(self, "_app", None)
if app is not None:
buf = app.current_buffer
buf.text = cmd + " "
buf.cursor_position = len(buf.text)
self._invalidate(min_interval=0.0)
except Exception:
logger.debug("command palette prefill failed", exc_info=True)
@classmethod
def _split_destructive_skip(cls, cmd_text: Optional[str]) -> tuple[str, bool]:
"""Split inline-skip tokens out of a destructive slash command.
Returns ``(remainder, skip)`` where ``remainder`` is the original
text with the command word and any recognized skip tokens removed,
and ``skip`` is True iff at least one skip token was found.
Examples:
"/reset now" -> ("", True)
"/reset --yes My title" -> ("My title", True)
"/new My title" -> ("My title", False)
"/clear" -> ("", False)
"""
if not cmd_text:
return "", False
tokens = cmd_text.strip().split()
if not tokens:
return "", False
# Drop leading "/cmd" word — callers pass the full command text.
if tokens[0].startswith("/"):
tokens = tokens[1:]
skip = False
kept: list[str] = []
for tok in tokens:
if tok.lower() in cls._DESTRUCTIVE_SKIP_TOKENS:
skip = True
continue
kept.append(tok)
return " ".join(kept), skip
def _confirm_destructive_slash(
self,
command: str,
detail: str,
cmd_original: Optional[str] = None,
) -> 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.
Inline-skip: if ``cmd_original`` contains ``now``, ``--yes``, or
``-y`` as an argument (e.g. ``/reset now``, ``/new --yes My title``),
the modal is bypassed and ``"once"`` is returned immediately. This is
an escape hatch for non-interactive use and for the degraded path where
the modal can't be marshaled onto the app loop (native Windows itself now
drives the modal normally — see #33961). Callers are responsible
for stripping the skip tokens from any remaining argument parsing
(see :meth:`_split_destructive_skip`).
Returns ``"once"``, ``"always"``, or ``None`` (cancelled). Callers
proceed with the destructive action when the result is non-None.
"""
from cli import load_cli_config, save_config_value
# Inline-skip escape hatch — works regardless of platform/modal state.
# See class-level _DESTRUCTIVE_SKIP_TOKENS for the accepted tokens.
if cmd_original:
_, _skip = self._split_destructive_skip(cmd_original)
if _skip:
return "once"
# 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 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 = self._normalize_slash_confirm_choice(raw, choices)
if choice is None:
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 _ring_bell(self, prompt: bool = False, context: str = "", detail: str = "") -> None:
"""Write a terminal bell (\\a) if the matching display.bell_* flag is on.
``prompt=True`` is the blocking-modal variant (clarify / approval /
sudo / secret capture) gated by ``display.bell_on_prompt``; the default
is the end-of-turn bell gated by ``display.bell_on_complete``. Works
over SSH — the BEL propagates to the user's terminal.
The same flag also emits an OSC 9 desktop notification (Ghostty,
iTerm2, Kitty, WezTerm) and, inside a supporting Warp build, a
``warp://cli-agent`` OSC 777 event — see ``hermes_cli.terminal_notify``.
``context`` is the short notification body (e.g. "approval").
"""
flag = "bell_on_prompt" if prompt else "bell_on_complete"
if not getattr(self, flag, False):
return
try:
sys.stdout.write("\a")
sys.stdout.flush()
except Exception:
pass
try:
from hermes_cli.terminal_notify import notify as _terminal_notify
_terminal_notify(
context or ("input needed" if prompt else "turn complete"),
prompt=prompt,
session_id=getattr(self, "session_id", "") or "",
detail=detail,
)
except Exception:
pass
def _clarify_callback(self, question, choices, multi_select=False, questions=None):
"""
Platform callback for the clarify tool. Called from the agent thread.
Sets up the interactive selection UI (or freetext prompt for open-ended
questions), then blocks until the user responds via the prompt_toolkit
key bindings. If no response arrives within the configured timeout the
question is dismissed and the agent is told to decide on its own.
When ``multi_select`` is True, shows checkboxes and the user can
select multiple options with Space, confirming with Enter.
When ``questions`` is a non-empty list (batch clarify, issue #18450),
the panel switches to the A-compact multi-question layout and the
return value is a dict ``{"answers": {qid: raw_answer}}`` (plus
``"timed_out": True`` when the deadline expired with only partial
answers). The single-question path below is unchanged.
"""
from cli import CLI_CONFIG, _DIM, _RST, _cprint
import time as _time
from tools.clarify_gateway import resolve_clarify_timeout
if questions:
return self._clarify_callback_batch(questions)
# Canonical clarify timeout, shared with the gateway/TUI path. `<= 0`
# means unlimited (never auto-skip mid-think) → a null deadline.
timeout = resolve_clarify_timeout(CLI_CONFIG)
response_queue = queue.Queue()
is_open_ended = not choices
# multi-select support: only active when multi_select is True and choices exist
effective_multi = multi_select and not is_open_ended
self._clarify_state = {
"question": question,
"choices": choices if not is_open_ended else [],
"selected": 0,
# multi-select support
"multi_select": effective_multi,
"selected_indices": set() if effective_multi else None,
"response_queue": response_queue,
}
self._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout
# Open-ended questions skip straight to freetext input
self._clarify_freetext = is_open_ended
self._clarify_multi_base = None
self._ring_bell(prompt=True, context="clarify")
# Trigger an immediate prompt_toolkit repaint from this (non-main)
# thread. Modal prompts must paint at once and must not be gated by the
# _invalidate throttle / resize guard — see _paint_now / _invalidate (#41098).
self._paint_now()
# Poll for the user's response. The countdown in the hint line updates
# on each repaint; refresh it once a second so the timer stays visible
# while we wait. Selection changes (↑/↓) trigger instant repaints via
# the key bindings.
_last_countdown_refresh = _time.monotonic()
while True:
try:
result = response_queue.get(timeout=1)
self._clarify_deadline = None
self._persist_prompt_summary("?", "Clarify", question, str(result))
return result
except queue.Empty:
# None deadline = unlimited: never auto-skip, just keep polling.
if self._clarify_deadline is not None:
remaining = self._clarify_deadline - _time.monotonic()
if remaining <= 0:
break
now = _time.monotonic()
if now - _last_countdown_refresh >= 1.0:
_last_countdown_refresh = now
self._paint_now()
# Timed out — tear down the UI and let the agent decide
self._clarify_state = None
self._clarify_freetext = False
self._clarify_deadline = None
self._clarify_multi_base = None
self._paint_now()
_cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}")
return (
"The user did not provide a response within the time limit. "
"Use your best judgement to make the choice and proceed."
)
def _clarify_batch_set_active(self, state, index) -> None:
"""Point the batch clarify panel at question ``index``.
Mirrors the active question's data into the flat keys the existing
single-question keybindings and renderer read (``question``,
``choices``, ``selected``, ``multi_select``, ``selected_indices``),
so ↑/↓/Space/number keys operate on the active question unchanged.
Open-ended questions drop straight into freetext, matching the
single-question path. Re-visiting an answered question restores the
cursor to the earlier selection (choice answers highlight their row,
an "Other" answer highlights the Other row) so the user can see and
edit what they picked.
"""
questions_list = state["questions"]
index = max(0, min(index, len(questions_list) - 1))
entry = questions_list[index]
state["active"] = index
state["question"] = entry["question"]
state["choices"] = entry["choices"] or []
state["selected"] = 0
state["multi_select"] = bool(entry["multi_select"])
state["selected_indices"] = set() if entry["multi_select"] else None
self._clarify_freetext = not entry["choices"]
self._clarify_multi_base = None
# Restore the earlier answer's cursor/checkbox position on re-visit.
meta = (state.get("answer_meta") or {}).get(entry["qid"])
choices = entry["choices"] or []
if meta is None:
return
if meta.get("kind") == "choice":
answer = state["answers"].get(entry["qid"])
if answer in choices:
state["selected"] = choices.index(answer)
elif meta.get("kind") == "other":
state["selected"] = len(choices)
elif meta.get("kind") == "multi":
checked = set()
for label in meta.get("choices") or []:
if label in choices:
checked.add(choices.index(label))
if meta.get("other_text"):
checked.add(len(choices))
state["selected_indices"] = checked
def _clarify_batch_lock(self, state, answer, meta=None) -> None:
"""Lock ``answer`` for the active batch question and advance.
Overwrites any earlier answer for the same question (locked answers
stay editable until the batch completes). ``meta`` records how the
answer was produced ({"kind": "choice"|"other"|"multi", ...}) so a
re-visit can restore the cursor and prefill an "Other" edit. Advances
``active`` to the next unanswered question; when every question has
an answer, puts the answers dict on the response queue and tears down
the panel.
"""
entry = state["questions"][state["active"]]
state["answers"][entry["qid"]] = answer
state.setdefault("answer_meta", {})[entry["qid"]] = meta or {"kind": "choice"}
self._persist_prompt_summary("?", "Clarify", entry["question"], str(answer))
total = len(state["questions"])
for offset in range(1, total + 1):
candidate = (state["active"] + offset) % total
if state["questions"][candidate]["qid"] not in state["answers"]:
self._clarify_batch_set_active(state, candidate)
return
# Every question answered — resolve the batch.
try:
state["response_queue"].put(dict(state["answers"]))
except Exception:
pass
self._clarify_state = None
self._clarify_freetext = False
self._clarify_multi_base = None
def _clarify_batch_enter(self, state) -> None:
"""Enter in batch choice mode: lock the active question's selection.
Multi-select questions lock a JSON array string of the checked
labels (the tool core parses it via ``_parse_multi_select_response``).
Selecting "Other" switches to freetext; the freetext submit path
locks the typed answer. Entering "Other" on a question whose earlier
answer was typed prefills the composer with that text for editing.
"""
choices = state.get("choices") or []
selected = state.get("selected", 0)
entry = state["questions"][state["active"]]
meta = (state.get("answer_meta") or {}).get(entry["qid"]) or {}
if state.get("multi_select"):
indices = state.get("selected_indices") or set()
sorted_idx = sorted(indices)
selected_choices = [choices[i] for i in sorted_idx if i < len(choices)]
other_checked = len(choices) in sorted_idx
if other_checked:
# Stash the checked real choices (possibly none) so the
# freetext submit appends the typed answer to the array.
self._clarify_multi_base = selected_choices
self._clarify_freetext = True
self._clarify_prefill = meta.get("other_text") or ""
return
self._clarify_batch_lock(
state,
json.dumps(selected_choices, ensure_ascii=False),
meta={"kind": "multi", "choices": selected_choices, "other_text": ""},
)
return
if selected < len(choices):
self._clarify_batch_lock(
state, choices[selected], meta={"kind": "choice"}
)
return
# "Other" highlighted → switch to freetext; prefill an earlier typed
# answer so Enter on an answered Other edits instead of retyping.
self._clarify_freetext = True
self._clarify_prefill = (
meta.get("other_text") or "" if meta.get("kind") == "other" else ""
)
def _clarify_callback_batch(self, questions):
"""Batch clarify panel (A-compact): all questions, one active.
Blocks on the response queue like the single-question path. Returns
``{"answers": {qid: raw_answer}}`` when every question is locked, the
same dict plus ``"timed_out": True`` when the deadline expires with
partial (or zero) answers, and passes a cancel string through
unchanged so the tool core resolves the batch empty.
"""
from cli import CLI_CONFIG, _DIM, _RST, _cprint
import time as _time
from tools.clarify_gateway import resolve_clarify_timeout
timeout = resolve_clarify_timeout(CLI_CONFIG)
response_queue = queue.Queue()
state = {
"questions": list(questions),
"answers": {},
"answer_meta": {},
"active": 0,
"response_queue": response_queue,
# Flat keys mirroring the active question — filled by
# _clarify_batch_set_active below.
"question": "",
"choices": [],
"selected": 0,
"multi_select": False,
"selected_indices": None,
}
self._clarify_state = state
self._clarify_batch_set_active(state, 0)
self._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout
self._ring_bell(prompt=True, context="clarify")
self._paint_now()
_last_countdown_refresh = _time.monotonic()
while True:
try:
result = response_queue.get(timeout=1)
self._clarify_deadline = None
if isinstance(result, dict):
return {"answers": result}
# Cancel path (Ctrl+C teardown) posts a plain string — pass
# it through so the tool core resolves the batch empty.
return result
except queue.Empty:
if self._clarify_deadline is not None:
remaining = self._clarify_deadline - _time.monotonic()
if remaining <= 0:
break
now = _time.monotonic()
if now - _last_countdown_refresh >= 1.0:
_last_countdown_refresh = now
self._paint_now()
# Timed out — keep the answers locked so far and flag the timeout.
partial = dict(state["answers"])
self._clarify_state = None
self._clarify_freetext = False
self._clarify_deadline = None
self._clarify_multi_base = None
self._paint_now()
_cprint(f"\n{_DIM}(clarify timed out after {timeout}s — locked answers returned){_RST}")
return {"answers": partial, "timed_out": True}
def _sudo_password_callback(self) -> str:
"""
Prompt for sudo password through the prompt_toolkit UI.
Called from the agent thread when a sudo command is encountered.
Uses the same clarify-style mechanism: sets UI state, waits on a
queue for the user's response via the Enter key binding.
"""
from cli import _DIM, _RST, _cprint
import time as _time
timeout = 45
response_queue = queue.Queue()
self._capture_modal_input_snapshot()
self._sudo_state = {
"response_queue": response_queue,
}
self._sudo_deadline = _time.monotonic() + timeout
self._ring_bell(prompt=True, context="sudo password")
# Modal prompt — paint immediately, bypassing the throttle/resize guard
# so the prompt can't be dropped and time out unseen (#41098).
self._paint_now()
while True:
try:
result = response_queue.get(timeout=1)
self._sudo_state = None
self._sudo_deadline = 0
self._restore_modal_input_snapshot()
self._paint_now()
if result:
_cprint(f"\n{_DIM} ✓ Password received (cached for session){_RST}")
else:
_cprint(f"\n{_DIM} ⏭ Skipped{_RST}")
return result
except queue.Empty:
remaining = self._sudo_deadline - _time.monotonic()
if remaining <= 0:
break
self._paint_now()
self._sudo_state = None
self._sudo_deadline = 0
self._restore_modal_input_snapshot()
self._paint_now()
_cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}")
return ""
def _approval_callback(self, command: str, description: str,
*, allow_permanent: bool = True,
allow_session: bool = True,
smart_denied: bool = False) -> str:
"""
Prompt for dangerous command approval through the prompt_toolkit UI.
Called from the agent thread. Shows a selection UI similar to clarify
with choices: once / session / always / deny. Smart DENY owner
overrides show only once / deny, as do gates that re-ask every time
(allow_session=False). When allow_permanent is False for another
reason (for example tirith), only 'always' is hidden.
Long commands also get a 'view' option so the full command can be
expanded before deciding.
Uses _approval_lock to serialize concurrent requests (e.g. from
parallel delegation subtasks) so each prompt gets its own turn
and the shared _approval_state / _approval_deadline aren't clobbered.
"""
from cli import CLI_CONFIG, _DIM, _RST, _cprint
import time as _time
with self._approval_lock:
timeout = int(CLI_CONFIG.get("approvals", {}).get("timeout", 300))
response_queue = queue.Queue()
self._approval_state = {
"command": command,
"description": description,
"choices": self._approval_choices(
command,
allow_permanent=allow_permanent,
allow_session=allow_session,
smart_denied=smart_denied,
),
"selected": 0,
"response_queue": response_queue,
}
self._approval_deadline = _time.monotonic() + timeout
self._ring_bell(prompt=True, context="approval", detail=command)
# Modal prompt — paint immediately, bypassing the throttle/resize
# guard. A throttled paint here can be silently dropped (250ms
# window collision or in-flight resize), leaving the panel unseen so
# the command is denied on timeout without the user ever seeing it
# (#41098). The countdown refreshes below paint the same way.
self._paint_now()
_last_countdown_refresh = _time.monotonic()
while True:
try:
result = response_queue.get(timeout=1)
self._approval_state = None
self._approval_deadline = 0
self._paint_now()
_outcome_labels = {
"once": "allowed once",
"session": "allowed for session",
"always": "added to allowlist",
"deny": "denied",
}
self._persist_prompt_summary(
"⚠", "Approval", command,
_outcome_labels.get(result, str(result)),
)
return result
except queue.Empty:
remaining = self._approval_deadline - _time.monotonic()
if remaining <= 0:
break
now = _time.monotonic()
if now - _last_countdown_refresh >= 1.0:
_last_countdown_refresh = now
self._paint_now()
self._approval_state = None
self._approval_deadline = 0
self._paint_now()
_cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
self._persist_prompt_summary(
"⚠", "Approval", command, "timed out (no response)",
)
return "timeout"
def _approval_choices(self, command: str, *, allow_permanent: bool = True,
allow_session: bool = True,
smart_denied: bool = False) -> list[str]:
"""Return approval choices for a dangerous command prompt."""
if smart_denied or not allow_session:
choices = ["once", "deny"]
else:
choices = ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"]
if len(command) > 70:
choices.append("view")
return choices
def _computer_use_approval_callback(self, action: str, args: dict, summary: str) -> str:
"""Adapt the generic approval UI for the computer_use tool.
The computer_use handler expects verdicts of the form
`approve_once` | `approve_session` | `always_approve` | `deny`.
The CLI's built-in approval UI returns `once` | `session` | `always`
| `deny`. Translate between the two.
"""
# Build a command-ish string so the existing UI renders something
# meaningful. `summary` is already a one-line human description.
verdict = self._approval_callback(
command=f"computer_use: {summary}",
description=f"Allow computer_use to perform `{action}`?",
)
return {
"once": "approve_once",
"session": "approve_session",
"always": "always_approve",
"deny": "deny",
"timeout": "timeout",
}.get(verdict, "deny")
def _handle_approval_selection(self) -> None:
"""Process the currently selected dangerous-command approval choice."""
state = self._approval_state
if not state:
return
selected = state.get("selected", 0)
choices = state.get("choices")
if not isinstance(choices, list):
choices = []
if not (0 <= selected < len(choices)):
return
chosen = choices[selected]
if chosen == "view":
state["show_full"] = True
state["choices"] = [choice for choice in choices if choice != "view"]
if state["selected"] >= len(state["choices"]):
state["selected"] = max(0, len(state["choices"]) - 1)
self._invalidate()
return
state["response_queue"].put(chosen)
self._approval_state = None
self._invalidate()
def _secret_capture_callback(self, var_name: str, prompt: str, metadata=None) -> dict:
return prompt_for_secret(self, var_name, prompt, metadata)
def _capture_modal_input_snapshot(self) -> None:
"""Temporarily clear the input buffer and save the user's in-progress draft."""
if self._modal_input_snapshot is not None or not getattr(self, "_app", None):
return
try:
buf = self._app.current_buffer
self._modal_input_snapshot = {
"text": buf.text,
"cursor_position": buf.cursor_position,
}
buf.reset()
except Exception:
self._modal_input_snapshot = None
def _restore_modal_input_snapshot(self) -> None:
"""Restore any draft text that was present before a modal prompt opened."""
snapshot = self._modal_input_snapshot
self._modal_input_snapshot = None
if not snapshot or not getattr(self, "_app", None):
return
try:
buf = self._app.current_buffer
buf.text = snapshot.get("text", "")
buf.cursor_position = min(snapshot.get("cursor_position", 0), len(buf.text))
except Exception:
pass
def _clear_active_overlays_for_interrupt(self) -> None:
"""Drain and clear every input-blocking overlay left by an interrupted agent.
approval/clarify/sudo/secret prompts each block a worker thread on a
``response_queue.get()``. When the agent is interrupted the worker
thread is torn down, but the overlay's state dict stays set — leaving
the CLI input gated (``read_only`` condition + keypress filter) with no
thread servicing the prompt. The result is a frozen terminal until the
prompt's own timeout expires. Push a terminal value onto each queue so
any still-blocked thread unblocks cleanly, then nil the state out and
restore the user's pre-modal draft (#14026).
Safe default per prompt: approval -> "deny", clarify/sudo/secret ->
cancel (None / empty). Each step is wrapped so a dead queue can't
prevent clearing the others.
"""
if self._approval_state:
try:
self._approval_state["response_queue"].put("deny")
except Exception:
pass
self._approval_state = None
if self._clarify_state:
try:
self._clarify_state["response_queue"].put(
"The user cancelled. Use your best judgement to proceed."
)
except Exception:
pass
self._clarify_state = None
self._clarify_freetext = False
self._clarify_multi_base = None
if self._sudo_state:
try:
self._sudo_state["response_queue"].put("")
except Exception:
pass
self._sudo_state = None
self._sudo_deadline = 0
self._restore_modal_input_snapshot()
if self._secret_state:
try:
self._cancel_secret_capture()
except Exception:
self._secret_state = None
def _submit_secret_response(self, value: str) -> None:
if not self._secret_state:
return
self._secret_state["response_queue"].put(value)
self._secret_state = None
self._secret_deadline = 0
# Modal teardown — paint directly so the secret panel clears at once and
# isn't held by the _invalidate throttle/resize guard (#41098).
self._paint_now()
def _cancel_secret_capture(self) -> None:
self._submit_secret_response("")
def _clear_secret_input_buffer(self) -> None:
if getattr(self, "_app", None):
try:
self._app.current_buffer.reset()
except Exception:
pass