From 1012894f0dffe3a74e9804e98a90de4dd08c78d9 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 18 Jul 2026 18:41:00 +0800 Subject: [PATCH] core/cli: multi-line boxed tool confirm prompts Echo a terminal-width box with wrapped reason (argv / -c code) before a short Allow? y/N prompt so shell confirms are not jammed on one line. --- src/plyngent/cli/limits.py | 91 +++++++++++++++++++++++++++++++++-- src/plyngent/tools/danger.py | 16 +++--- tests/test_cli/test_limits.py | 18 ++++++- 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/src/plyngent/cli/limits.py b/src/plyngent/cli/limits.py index 49e83a5..795964e 100644 --- a/src/plyngent/cli/limits.py +++ b/src/plyngent/cli/limits.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shutil from typing import TYPE_CHECKING, Literal from plyngent.cli.interrupt import pause_task_cancel_for_prompt @@ -13,6 +14,7 @@ from plyngent.prompting import ( configure_prompting, confirm, confirm_async, + get_prompt_backend, ) from plyngent.tools.process.pty_session import PtyManager @@ -21,6 +23,78 @@ if TYPE_CHECKING: type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"] +_BOX_MIN_WIDTH = 40 +_BOX_MAX_WIDTH = 100 +_BOX_PAD = 2 # spaces inside left/right borders + + +def _terminal_width() -> int: + try: + return max(_BOX_MIN_WIDTH, min(_BOX_MAX_WIDTH, shutil.get_terminal_size(fallback=(80, 24)).columns)) + except OSError: + return 80 + + +def _wrap_line(text: str, width: int) -> list[str]: + """Hard-wrap a single logical line to *width* (no word-break library).""" + width = max(width, 8) + if not text: + return [""] + if len(text) <= width: + return [text] + out: list[str] = [] + rest = text + while rest: + if len(rest) <= width: + out.append(rest) + break + # Prefer break at last space in the window. + window = rest[:width] + break_at = window.rfind(" ") + if break_at >= width // 2: + out.append(rest[:break_at]) + rest = rest[break_at + 1 :] + else: + out.append(rest[:width]) + rest = rest[width:] + return out + + +def format_tool_confirm_box(name: str, reason: str) -> str: + """Multi-line boxed confirm body (header + reason lines). + + Printed with :meth:`PromptBackend.echo` before a short ``confirm()`` prompt + so terminals keep newlines (unlike a single jammed readline line). + """ + term = _terminal_width() + inner = max(20, term - 4) # room for ``│ `` + `` │`` + header = f"confirm · tool {name!r}" + body_lines: list[str] = [] + for raw in reason.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + body_lines.extend(_wrap_line(raw, inner - _BOX_PAD)) + content = [header, "─" * min(inner, max(len(header), 12)), *body_lines] + width = min(inner, max(len(line) for line in content) + _BOX_PAD) + width = max(width, min(inner, len(header) + _BOX_PAD)) + top = "┌" + "─" * (width + 2) + "┐" + bottom = "└" + "─" * (width + 2) + "┘" + rows = [top] + for line in content: + # Second line is a separator drawn with box dashes already in content. + if line.startswith("─") and set(line) <= {"─"}: + rows.append("├" + "─" * (width + 2) + "┤") + continue + padded = line[:width].ljust(width) + rows.append(f"│ {padded} │") + rows.append(bottom) + return "\n".join(rows) + + +def _echo_tool_confirm(name: str, reason: str) -> None: + backend = get_prompt_backend() + backend.echo() + backend.secho(format_tool_confirm_box(name, reason), fg="yellow") + backend.echo() + def _prompt_continue_limit_sync(reason: str) -> bool: try: @@ -50,8 +124,8 @@ def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str """True allow; False deny; non-empty str = deny with comment for the model.""" del args try: - prompt = f"[confirm] tool {name!r}:\n{reason}\nAllow this tool call?" - allowed = confirm(prompt, default=False) + _echo_tool_confirm(name, reason) + allowed = confirm("Allow this tool call?", default=False) except NonInteractiveError: return False if allowed: @@ -69,7 +143,8 @@ def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool | str: """Ask whether to allow a dangerous tool call (TTY). Default is deny. - On deny, optionally collect a free-text comment for the model. + Prints a multi-line boxed summary, then a short y/N prompt. On deny, + optionally collect a free-text comment for the model. """ with pause_task_cancel_for_prompt(): return _prompt_confirm_tool_sync(name, args, reason) @@ -79,8 +154,14 @@ async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reaso """Async confirm: True allow, False deny, str = deny with user comment.""" del args try: - prompt = f"[confirm] tool {name!r}:\n{reason}\nAllow this tool call?" - allowed = await confirm_async(prompt, default=False) + # Box is printed inside the worker thread via confirm's backend. + def _run() -> bool: + _echo_tool_confirm(name, reason) + return confirm("Allow this tool call?", default=False) + + from plyngent.prompting import run_prompt_async + + allowed = await run_prompt_async(_run) except NonInteractiveError: return False if allowed: diff --git a/src/plyngent/tools/danger.py b/src/plyngent/tools/danger.py index 862ad92..0aa7fd4 100644 --- a/src/plyngent/tools/danger.py +++ b/src/plyngent/tools/danger.py @@ -7,8 +7,8 @@ from plyngent.tools.workspace import WorkspaceError, resolve_path if TYPE_CHECKING: from collections.abc import Mapping, Sequence -_DISPLAY_ARGV_MAX = 200 -_CODE_PREVIEW_MAX = 120 +_DISPLAY_ARGV_MAX = 400 +_CODE_PREVIEW_MAX = 240 _SHELL_BASENAMES: frozenset[str] = frozenset( { @@ -81,7 +81,11 @@ def _find_dash_c_code(argv: Sequence[str]) -> str | None: def _shell_or_dash_c_reason(argv: Sequence[str], *, via: str) -> str | None: - """Confirm interactive shells/REPLs and ``-c`` one-liners so the user can inspect argv.""" + """Confirm interactive shells/REPLs and ``-c`` one-liners so the user can inspect argv. + + Multi-line reason (shown inside the CLI confirm box). ``via`` is a short + label (tool name), not repeated on every line. + """ if not argv: return None base = _basename(argv[0]) @@ -92,14 +96,14 @@ def _shell_or_dash_c_reason(argv: Sequence[str], *, via: str) -> str | None: code = _find_dash_c_code(argv) if code is not None: preview = code if len(code) <= _CODE_PREVIEW_MAX else code[:_CODE_PREVIEW_MAX] + "…" - return f"{via}: {base} -c (review code before allow)\n argv: {display}\n -c: {preview!r}" + return f"{via}: {base} -c (review code before allow)\nargv:\n {display}\n-c code:\n {preview}" if base in _SHELL_BASENAMES and len(argv) == 1: - return f"{via}: interactive {base!r} (review before allow)\n argv: {display}" + return f"{via}: interactive {base!r} (review before allow)\nargv:\n {display}" # e.g. python -i, bash --login without -c still needs a glance. if base in _SHELL_BASENAMES: - return f"{via}: shell/runtime {base!r} (review before allow)\n argv: {display}" + return f"{via}: shell/runtime {base!r} (review before allow)\nargv:\n {display}" return None diff --git a/tests/test_cli/test_limits.py b/tests/test_cli/test_limits.py index d82f774..aa0d522 100644 --- a/tests/test_cli/test_limits.py +++ b/tests/test_cli/test_limits.py @@ -1,6 +1,11 @@ from __future__ import annotations -from plyngent.cli.limits import install_cli_limit_hooks, prompt_confirm_tool, prompt_continue_limit +from plyngent.cli.limits import ( + format_tool_confirm_box, + install_cli_limit_hooks, + prompt_confirm_tool, + prompt_continue_limit, +) from plyngent.prompting import get_prompt_backend, temporary_backend from plyngent.tools.process.pty_session import PtyManager from tests.test_prompting import ScriptedBackend @@ -24,6 +29,17 @@ def test_prompt_confirm_tool_default_deny() -> None: assert prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'") is False +def test_format_tool_confirm_box_multiline() -> None: + reason = "run_command: python3 -c (review code before allow)\nargv:\n python3 -c print(1)\n-c code:\n print(1)" + box = format_tool_confirm_box("run_command", reason) + assert "┌" in box and "└" in box + assert "confirm · tool 'run_command'" in box + assert "python3 -c" in box + assert "print(1)" in box + # Distinct lines, not one jammed row + assert box.count("\n") >= 4 + + def test_install_cli_limit_hooks() -> None: install_cli_limit_hooks() assert callable(getattr(PtyManager, "_limit_continue", None))