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.
This commit is contained in:
2026-07-18 18:41:00 +08:00
parent 020e9215bf
commit 1012894f0d
3 changed files with 113 additions and 12 deletions
+86 -5
View File
@@ -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:
+10 -6
View File
@@ -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
+17 -1
View File
@@ -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))