4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
224 lines
8.0 KiB
Python
224 lines
8.0 KiB
Python
"""Interactive prompt helpers (Escape to cancel, etc.)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sys
|
|
import time
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import questionary.question
|
|
from prompt_toolkit.key_binding import KeyBindings, KeyBindingsBase, merge_key_bindings
|
|
from prompt_toolkit.keys import Keys
|
|
from rich.console import Console
|
|
|
|
from platform.terminal.theme import DIM
|
|
|
|
_escape_patch_installed: list[bool] = [False]
|
|
_ctrl_c_patch_installed: list[bool] = [False]
|
|
_last_ctrl_c: list[float | None] = [None]
|
|
_handling_ctrl_c: list[bool] = [False]
|
|
|
|
CTRL_C_DOUBLE_PRESS_WINDOW_S: float = 2.0
|
|
_CTRL_C_EXIT_WINDOW: float = CTRL_C_DOUBLE_PRESS_WINDOW_S
|
|
|
|
|
|
class _HardQuitInterrupt(KeyboardInterrupt):
|
|
"""Raised by explicit quit keys (Ctrl+Q) to bypass the Ctrl+C double-exit guard."""
|
|
|
|
|
|
def _with_escape_cancel(question: questionary.question.Question) -> questionary.question.Question:
|
|
"""Prepend Escape handling so it wins over questionary's catch-all bindings."""
|
|
extra = KeyBindings()
|
|
|
|
@extra.add(Keys.Escape, eager=True)
|
|
def _escape(event: Any) -> None:
|
|
event.app.exit(result=None)
|
|
|
|
app = question.application
|
|
existing: KeyBindingsBase = app.key_bindings or KeyBindings()
|
|
app.key_bindings = merge_key_bindings([extra, existing])
|
|
return question
|
|
|
|
|
|
def _wrap_question_prompt(
|
|
orig: Callable[..., questionary.question.Question],
|
|
) -> Callable[..., questionary.question.Question]:
|
|
def wrapped(*args: Any, **kwargs: Any) -> questionary.question.Question:
|
|
return _with_escape_cancel(orig(*args, **kwargs))
|
|
|
|
wrapped.__name__ = orig.__name__
|
|
wrapped.__doc__ = orig.__doc__
|
|
wrapped.__qualname__ = getattr(orig, "__qualname__", orig.__name__)
|
|
return wrapped
|
|
|
|
|
|
def install_questionary_escape_cancel() -> None:
|
|
"""Make Escape cancel questionary prompts (returns None), consistent across the CLI."""
|
|
if _escape_patch_installed[0]:
|
|
return
|
|
|
|
import questionary
|
|
import questionary.prompts.checkbox as checkbox_mod
|
|
import questionary.prompts.confirm as confirm_mod
|
|
import questionary.prompts.password as password_mod
|
|
import questionary.prompts.path as path_mod
|
|
import questionary.prompts.select as select_mod
|
|
import questionary.prompts.text as text_mod
|
|
|
|
select_mod.select = _wrap_question_prompt(select_mod.select)
|
|
checkbox_mod.checkbox = _wrap_question_prompt(checkbox_mod.checkbox)
|
|
confirm_mod.confirm = _wrap_question_prompt(confirm_mod.confirm)
|
|
text_mod.text = _wrap_question_prompt(text_mod.text)
|
|
path_mod.path = _wrap_question_prompt(path_mod.path)
|
|
password_mod.password = _wrap_question_prompt(password_mod.password)
|
|
|
|
questionary.select = select_mod.select
|
|
questionary.checkbox = checkbox_mod.checkbox
|
|
questionary.confirm = confirm_mod.confirm
|
|
questionary.text = text_mod.text
|
|
questionary.path = path_mod.path
|
|
questionary.password = password_mod.password
|
|
|
|
_escape_patch_installed[0] = True
|
|
|
|
|
|
def handle_ctrl_c_press() -> None:
|
|
"""Handle Ctrl+C from the SIGINT signal handler (between prompts)."""
|
|
if _handling_ctrl_c[0]:
|
|
return
|
|
_handling_ctrl_c[0] = True
|
|
try:
|
|
now = time.monotonic()
|
|
if _last_ctrl_c[0] is not None and now - _last_ctrl_c[0] <= _CTRL_C_EXIT_WINDOW:
|
|
print("\nGoodbye!", flush=True)
|
|
sys.exit(0)
|
|
_last_ctrl_c[0] = now
|
|
print("\n(Press Ctrl+C again to exit)", flush=True)
|
|
finally:
|
|
_handling_ctrl_c[0] = False
|
|
|
|
|
|
def _with_ctrl_c_double_exit(
|
|
question: questionary.question.Question,
|
|
) -> questionary.question.Question:
|
|
"""Add Ctrl+C double-exit handling to a questionary prompt."""
|
|
|
|
def _patched_ask(*args: Any, **kwargs: Any) -> Any:
|
|
while True:
|
|
try:
|
|
try:
|
|
asyncio.get_running_loop()
|
|
in_event_loop = True
|
|
except RuntimeError:
|
|
in_event_loop = False
|
|
if in_event_loop:
|
|
result = question.application.run(in_thread=True)
|
|
else:
|
|
result = question.unsafe_ask(*args, **kwargs)
|
|
_last_ctrl_c[0] = None
|
|
return result
|
|
except KeyboardInterrupt as exc:
|
|
if isinstance(exc, _HardQuitInterrupt):
|
|
raise
|
|
now = time.monotonic()
|
|
if _last_ctrl_c[0] is not None and now - _last_ctrl_c[0] <= _CTRL_C_EXIT_WINDOW:
|
|
print("\nGoodbye!", flush=True)
|
|
sys.exit(0)
|
|
_last_ctrl_c[0] = now
|
|
print("\n(Press Ctrl+C again to exit)", flush=True)
|
|
except (OSError, KeyError) as exc:
|
|
import logging
|
|
|
|
logging.getLogger(__name__).debug(
|
|
"interactive prompt selector error: %s", exc, exc_info=True
|
|
)
|
|
print(
|
|
"\nThe interactive prompt could not be displayed due to a "
|
|
"terminal I/O error on this platform.",
|
|
flush=True,
|
|
)
|
|
sys.exit(1)
|
|
|
|
question.ask = _patched_ask # type: ignore[method-assign]
|
|
return question
|
|
|
|
|
|
def _wrap_question_ctrl_c(
|
|
orig: Callable[..., questionary.question.Question],
|
|
) -> Callable[..., questionary.question.Question]:
|
|
def wrapped(*args: Any, **kwargs: Any) -> questionary.question.Question:
|
|
return _with_ctrl_c_double_exit(orig(*args, **kwargs))
|
|
|
|
wrapped.__name__ = orig.__name__
|
|
wrapped.__doc__ = orig.__doc__
|
|
wrapped.__qualname__ = getattr(orig, "__qualname__", orig.__name__)
|
|
return wrapped
|
|
|
|
|
|
def repl_reset_ctrl_c_gate() -> None:
|
|
_last_ctrl_c[0] = None
|
|
|
|
|
|
def cli_invocation_name() -> str:
|
|
"""Return the basename of the current CLI launcher (for example ``opensre`` or ``o``)."""
|
|
argv0 = sys.argv[0].strip() if sys.argv else ""
|
|
if not argv0:
|
|
return "opensre"
|
|
name = Path(argv0).name
|
|
return name or "opensre"
|
|
|
|
|
|
def print_session_resume_hint(console: Console, session_id: str) -> None:
|
|
"""Print REPL and CLI commands that restore ``session_id``."""
|
|
cli_name = cli_invocation_name()
|
|
console.print(f"[{DIM}]Resume this session with:[/]")
|
|
console.print(f"[{DIM}]/resume {session_id}[/]")
|
|
console.print(f"[{DIM}]{cli_name} --resume {session_id}[/]")
|
|
|
|
|
|
def repl_prompt_note_ctrl_c(console: Console, session_id: str | None = None) -> bool:
|
|
now = time.monotonic()
|
|
if _last_ctrl_c[0] is not None and now - _last_ctrl_c[0] <= _CTRL_C_EXIT_WINDOW:
|
|
console.print()
|
|
if session_id:
|
|
print_session_resume_hint(console, session_id)
|
|
console.print(f"[{DIM}]Goodbye![/]")
|
|
_last_ctrl_c[0] = None
|
|
return True
|
|
_last_ctrl_c[0] = now
|
|
console.print(f"[{DIM}](Press Ctrl+C again to exit)[/]")
|
|
return False
|
|
|
|
|
|
def install_questionary_ctrl_c_double_exit() -> None:
|
|
"""Make Ctrl+C show a hint on first press and exit on second press within 2 s."""
|
|
if _ctrl_c_patch_installed[0]:
|
|
return
|
|
|
|
import questionary
|
|
import questionary.prompts.checkbox as checkbox_mod
|
|
import questionary.prompts.confirm as confirm_mod
|
|
import questionary.prompts.password as password_mod
|
|
import questionary.prompts.path as path_mod
|
|
import questionary.prompts.select as select_mod
|
|
import questionary.prompts.text as text_mod
|
|
|
|
select_mod.select = _wrap_question_ctrl_c(select_mod.select)
|
|
checkbox_mod.checkbox = _wrap_question_ctrl_c(checkbox_mod.checkbox)
|
|
confirm_mod.confirm = _wrap_question_ctrl_c(confirm_mod.confirm)
|
|
text_mod.text = _wrap_question_ctrl_c(text_mod.text)
|
|
path_mod.path = _wrap_question_ctrl_c(path_mod.path)
|
|
password_mod.password = _wrap_question_ctrl_c(password_mod.password)
|
|
|
|
questionary.select = select_mod.select
|
|
questionary.checkbox = checkbox_mod.checkbox
|
|
questionary.confirm = confirm_mod.confirm
|
|
questionary.text = text_mod.text
|
|
questionary.path = path_mod.path
|
|
questionary.password = password_mod.password
|
|
|
|
_ctrl_c_patch_installed[0] = True
|