When a dangerous command is detected and the user is prompted for approval, long commands are truncated (80 chars in fallback, 70 chars in the TUI). Users had no way to see the full command before deciding. This adds a 'View full command' option across all approval interfaces: - CLI fallback (tools/approval.py): [v]iew option in the prompt menu. Shows the full command and re-prompts for approval decision. - CLI TUI (cli.py): 'Show full command' choice in the arrow-key selection panel. Expands the command display in-place and removes the view option after use. - CLI callbacks (callbacks.py): 'view' choice added to the list when the command exceeds 70 characters. - Gateway (gateway/run.py): 'full', 'show', 'view' responses reveal the complete command while keeping the approval pending. Includes 7 new tests covering view-then-approve, view-then-deny, short command fallthrough, and double-view behavior. Closes community feedback about the 80-char cap on dangerous commands.
150 lines
4.7 KiB
Python
150 lines
4.7 KiB
Python
"""Interactive prompt callbacks for terminal_tool integration.
|
|
|
|
These bridge terminal_tool's interactive prompts (clarify, sudo, approval)
|
|
into prompt_toolkit's event loop. Each function takes the HermesCLI instance
|
|
as its first argument and uses its state (queues, app reference) to coordinate
|
|
with the TUI.
|
|
"""
|
|
|
|
import queue
|
|
import time as _time
|
|
|
|
from hermes_cli.banner import cprint, _DIM, _RST
|
|
|
|
|
|
def clarify_callback(cli, question, choices):
|
|
"""Prompt for clarifying question through the TUI.
|
|
|
|
Sets up the interactive selection UI, then blocks until the user
|
|
responds. Returns the user's choice or a timeout message.
|
|
"""
|
|
from cli import CLI_CONFIG
|
|
|
|
timeout = CLI_CONFIG.get("clarify", {}).get("timeout", 120)
|
|
response_queue = queue.Queue()
|
|
is_open_ended = not choices or len(choices) == 0
|
|
|
|
cli._clarify_state = {
|
|
"question": question,
|
|
"choices": choices if not is_open_ended else [],
|
|
"selected": 0,
|
|
"response_queue": response_queue,
|
|
}
|
|
cli._clarify_deadline = _time.monotonic() + timeout
|
|
cli._clarify_freetext = is_open_ended
|
|
|
|
if hasattr(cli, '_app') and cli._app:
|
|
cli._app.invalidate()
|
|
|
|
while True:
|
|
try:
|
|
result = response_queue.get(timeout=1)
|
|
cli._clarify_deadline = 0
|
|
return result
|
|
except queue.Empty:
|
|
remaining = cli._clarify_deadline - _time.monotonic()
|
|
if remaining <= 0:
|
|
break
|
|
if hasattr(cli, '_app') and cli._app:
|
|
cli._app.invalidate()
|
|
|
|
cli._clarify_state = None
|
|
cli._clarify_freetext = False
|
|
cli._clarify_deadline = 0
|
|
if hasattr(cli, '_app') and cli._app:
|
|
cli._app.invalidate()
|
|
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 sudo_password_callback(cli) -> str:
|
|
"""Prompt for sudo password through the TUI.
|
|
|
|
Sets up a password input area and blocks until the user responds.
|
|
"""
|
|
timeout = 45
|
|
response_queue = queue.Queue()
|
|
|
|
cli._sudo_state = {"response_queue": response_queue}
|
|
cli._sudo_deadline = _time.monotonic() + timeout
|
|
|
|
if hasattr(cli, '_app') and cli._app:
|
|
cli._app.invalidate()
|
|
|
|
while True:
|
|
try:
|
|
result = response_queue.get(timeout=1)
|
|
cli._sudo_state = None
|
|
cli._sudo_deadline = 0
|
|
if hasattr(cli, '_app') and cli._app:
|
|
cli._app.invalidate()
|
|
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 = cli._sudo_deadline - _time.monotonic()
|
|
if remaining <= 0:
|
|
break
|
|
if hasattr(cli, '_app') and cli._app:
|
|
cli._app.invalidate()
|
|
|
|
cli._sudo_state = None
|
|
cli._sudo_deadline = 0
|
|
if hasattr(cli, '_app') and cli._app:
|
|
cli._app.invalidate()
|
|
cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}")
|
|
return ""
|
|
|
|
|
|
def approval_callback(cli, command: str, description: str) -> str:
|
|
"""Prompt for dangerous command approval through the TUI.
|
|
|
|
Shows a selection UI with choices: once / session / always / deny.
|
|
When the command is longer than 70 characters, a "view" option is
|
|
included so the user can reveal the full text before deciding.
|
|
"""
|
|
timeout = 60
|
|
response_queue = queue.Queue()
|
|
choices = ["once", "session", "always", "deny"]
|
|
if len(command) > 70:
|
|
choices.append("view")
|
|
|
|
cli._approval_state = {
|
|
"command": command,
|
|
"description": description,
|
|
"choices": choices,
|
|
"selected": 0,
|
|
"response_queue": response_queue,
|
|
}
|
|
cli._approval_deadline = _time.monotonic() + timeout
|
|
|
|
if hasattr(cli, '_app') and cli._app:
|
|
cli._app.invalidate()
|
|
|
|
while True:
|
|
try:
|
|
result = response_queue.get(timeout=1)
|
|
cli._approval_state = None
|
|
cli._approval_deadline = 0
|
|
if hasattr(cli, '_app') and cli._app:
|
|
cli._app.invalidate()
|
|
return result
|
|
except queue.Empty:
|
|
remaining = cli._approval_deadline - _time.monotonic()
|
|
if remaining <= 0:
|
|
break
|
|
if hasattr(cli, '_app') and cli._app:
|
|
cli._app.invalidate()
|
|
|
|
cli._approval_state = None
|
|
cli._approval_deadline = 0
|
|
if hasattr(cli, '_app') and cli._app:
|
|
cli._app.invalidate()
|
|
cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
|
|
return "deny"
|