mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
fix(cli): truncate multi-line shell commands in HITL approval (#3314)
Closes #1095 --- The existing 120-char truncation in the approval prompt only collapsed horizontally long commands. Multi-line commands where each line was short (e.g. a `python3 -c` one-liner with embedded newlines) bypassed the per-line threshold and rendered untruncated — pushing the approve/reject options far down the terminal. Adds a line-count threshold (`_SHELL_COMMAND_TRUNCATE_LINES = 5`) alongside the existing character threshold and applies both inside the same `press 'e' to expand` affordance, so the user experience is unchanged for short commands and consistent (one ellipsis, one expand hint) for any command that exceeds either dimension. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: Mason Daugherty <61371264+mdrxy@users.noreply.github.com> Co-authored-by: Mason Daugherty <mason@langchain.dev>
This commit is contained in:
@@ -36,10 +36,52 @@ from deepagents_cli.widgets.tool_renderers import get_renderer
|
||||
|
||||
# Max length for truncated shell command display
|
||||
_SHELL_COMMAND_TRUNCATE_LENGTH: int = 120
|
||||
# Max number of lines for truncated shell command display
|
||||
_SHELL_COMMAND_TRUNCATE_LINES: int = 5
|
||||
_WARNING_PREVIEW_LIMIT: int = 3
|
||||
_WARNING_TEXT_TRUNCATE_LENGTH: int = 220
|
||||
|
||||
|
||||
def _is_command_too_long(command: str) -> bool:
|
||||
"""Whether a shell command exceeds the display thresholds (char or line).
|
||||
|
||||
Args:
|
||||
command: The shell command string to check.
|
||||
|
||||
Returns:
|
||||
`True` if the command is longer than `_SHELL_COMMAND_TRUNCATE_LENGTH`
|
||||
characters or has more than `_SHELL_COMMAND_TRUNCATE_LINES` lines.
|
||||
"""
|
||||
if len(command) > _SHELL_COMMAND_TRUNCATE_LENGTH:
|
||||
return True
|
||||
return command.count("\n") + 1 > _SHELL_COMMAND_TRUNCATE_LINES
|
||||
|
||||
|
||||
def _truncate_command(command: str) -> str:
|
||||
"""Truncate a shell command for compact display.
|
||||
|
||||
Applies line truncation first (keeping at most `_SHELL_COMMAND_TRUNCATE_LINES`
|
||||
lines), then character truncation, so multi-line commands collapse before
|
||||
long single lines are cut. A single ellipsis is appended at the end.
|
||||
|
||||
Args:
|
||||
command: The shell command string to truncate.
|
||||
|
||||
Returns:
|
||||
The truncated command string, with a trailing ellipsis if any truncation
|
||||
was applied; otherwise the original command unchanged.
|
||||
"""
|
||||
ellipsis = get_glyphs().ellipsis
|
||||
lines = command.split("\n")
|
||||
truncated = len(lines) > _SHELL_COMMAND_TRUNCATE_LINES
|
||||
if truncated:
|
||||
command = "\n".join(lines[:_SHELL_COMMAND_TRUNCATE_LINES])
|
||||
if len(command) > _SHELL_COMMAND_TRUNCATE_LENGTH:
|
||||
command = command[:_SHELL_COMMAND_TRUNCATE_LENGTH]
|
||||
truncated = True
|
||||
return command + ellipsis if truncated else command
|
||||
|
||||
|
||||
class ApprovalMenu(Container):
|
||||
"""Approval menu using standard Textual patterns.
|
||||
|
||||
@@ -143,7 +185,7 @@ class ApprovalMenu(Container):
|
||||
if req.get("name", "") not in SHELL_TOOL_NAMES:
|
||||
return False
|
||||
command = str(req.get("args", {}).get("command", ""))
|
||||
return len(command) > _SHELL_COMMAND_TRUNCATE_LENGTH
|
||||
return _is_command_too_long(command)
|
||||
|
||||
def _get_command_display(self, *, expanded: bool) -> Content:
|
||||
"""Get the command display content (truncated or full).
|
||||
@@ -165,14 +207,13 @@ class ApprovalMenu(Container):
|
||||
command = strip_dangerous_unicode(command_raw)
|
||||
issues = detect_dangerous_unicode(command_raw)
|
||||
|
||||
if expanded or len(command) <= _SHELL_COMMAND_TRUNCATE_LENGTH:
|
||||
too_long = _is_command_too_long(command)
|
||||
if expanded or not too_long:
|
||||
command_display = command
|
||||
else:
|
||||
command_display = (
|
||||
command[:_SHELL_COMMAND_TRUNCATE_LENGTH] + get_glyphs().ellipsis
|
||||
)
|
||||
command_display = _truncate_command(command)
|
||||
|
||||
if not expanded and len(command) > _SHELL_COMMAND_TRUNCATE_LENGTH:
|
||||
if not expanded and too_long:
|
||||
display = Content.from_markup(
|
||||
"[bold]$cmd[/bold] [dim](press 'e' to expand)[/dim]",
|
||||
cmd=command_display,
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
from deepagents_cli.config import get_glyphs
|
||||
from deepagents_cli.widgets.approval import (
|
||||
_SHELL_COMMAND_TRUNCATE_LENGTH,
|
||||
_SHELL_COMMAND_TRUNCATE_LINES,
|
||||
ApprovalMenu,
|
||||
)
|
||||
|
||||
@@ -59,6 +60,22 @@ class TestCheckExpandableCommand:
|
||||
menu = ApprovalMenu({"name": "shell", "args": {}})
|
||||
assert menu._has_expandable_command is False
|
||||
|
||||
def test_multiline_command_over_line_threshold_is_expandable(self) -> None:
|
||||
"""Multi-line commands that exceed the line threshold are expandable.
|
||||
|
||||
Each line stays well under the character threshold, so this regresses
|
||||
only if the line-count check is missing.
|
||||
"""
|
||||
command = "\n".join(["echo line"] * (_SHELL_COMMAND_TRUNCATE_LINES + 1))
|
||||
menu = ApprovalMenu({"name": "shell", "args": {"command": command}})
|
||||
assert menu._has_expandable_command is True
|
||||
|
||||
def test_multiline_command_at_line_threshold_not_expandable(self) -> None:
|
||||
"""Commands at exactly the line threshold are not expandable."""
|
||||
command = "\n".join(["echo line"] * _SHELL_COMMAND_TRUNCATE_LINES)
|
||||
menu = ApprovalMenu({"name": "shell", "args": {"command": command}})
|
||||
assert menu._has_expandable_command is False
|
||||
|
||||
|
||||
class TestGetCommandDisplay:
|
||||
"""Tests for `ApprovalMenu._get_command_display`."""
|
||||
@@ -137,6 +154,29 @@ class TestGetCommandDisplay:
|
||||
assert "U+202E" in display.plain
|
||||
assert "raw:" in display.plain
|
||||
|
||||
def test_multiline_command_truncated_to_max_lines(self) -> None:
|
||||
"""Multi-line commands collapse to the line cap with an expand hint."""
|
||||
lines = [f"echo {i}" for i in range(_SHELL_COMMAND_TRUNCATE_LINES + 3)]
|
||||
command = "\n".join(lines)
|
||||
menu = ApprovalMenu({"name": "shell", "args": {"command": command}})
|
||||
display = menu._get_command_display(expanded=False)
|
||||
plain = display.plain
|
||||
assert get_glyphs().ellipsis in plain
|
||||
assert "press 'e' to expand" in plain
|
||||
for kept in lines[:_SHELL_COMMAND_TRUNCATE_LINES]:
|
||||
assert kept in plain
|
||||
assert lines[_SHELL_COMMAND_TRUNCATE_LINES] not in plain
|
||||
|
||||
def test_multiline_command_shows_full_when_expanded(self) -> None:
|
||||
"""Expanded multi-line commands show every line."""
|
||||
lines = [f"echo {i}" for i in range(_SHELL_COMMAND_TRUNCATE_LINES + 3)]
|
||||
command = "\n".join(lines)
|
||||
menu = ApprovalMenu({"name": "shell", "args": {"command": command}})
|
||||
display = menu._get_command_display(expanded=True)
|
||||
for line in lines:
|
||||
assert line in display.plain
|
||||
assert "press 'e' to expand" not in display.plain
|
||||
|
||||
|
||||
class TestToggleExpand:
|
||||
"""Tests for `ApprovalMenu.action_toggle_expand`."""
|
||||
|
||||
Reference in New Issue
Block a user