mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user