Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2484,10 +2484,20 @@ def _panel_box_width(title: str, content_lines: list[str], min_width: int = 46,


def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "", *, keep_ws: bool = False) -> list[str]:
"""Wrap panel text; ``keep_ws`` preserves whitespace (command/detail previews)."""
"""Wrap panel text; ``keep_ws`` preserves whitespace (command/detail previews).

Split on newlines first, then wrap each line. ``textwrap.wrap`` treats
embedded ``\\n`` as ordinary whitespace, so a multi-line command (e.g. a
heredoc pending approval) would otherwise collapse into a few unreadable
long lines and push approve/deny choices off-screen (#72580).
"""
kw = dict(replace_whitespace=False, drop_whitespace=False) if keep_ws else dict(break_long_words=False, break_on_hyphens=False)
wrapped = textwrap.wrap(text, width=max(8, width), subsequent_indent=subsequent_indent, **kw)
return wrapped or [""]
result: list[str] = []
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
for line in normalized.split("\n"):
wrapped = textwrap.wrap(line, width=max(8, width), subsequent_indent=subsequent_indent, **kw)
result.extend(wrapped or [""])
return result or [""]


_wrap_panel_text_keep_ws = functools.partial(_wrap_panel_text, keep_ws=True)
Expand Down
88 changes: 88 additions & 0 deletions tests/cli/test_wrap_panel_text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Regression tests for #72580: approval panel multi-line text wrapping.

``_wrap_panel_text`` must split on newlines before wrapping. textwrap.wrap
treats embedded \\n as ordinary whitespace, so a multi-line command (e.g.
a heredoc pending approval) previously collapsed into a few long lines
and pushed the approve/deny choices off-screen.
"""

from cli import _wrap_panel_text, _wrap_panel_text_keep_ws


HEREDOC = (
"python3 << 'EOF'\n"
"import sqlite3\n"
"conn = sqlite3.connect('db')\n"
"cur.execute('SELECT * FROM sessions')\n"
"print(cur.fetchall())\n"
"conn.close()\n"
"EOF"
)


def test_multiline_command_keeps_one_display_line_per_source_line():
lines = _wrap_panel_text_keep_ws(HEREDOC, width=60)
assert lines == HEREDOC.split("\n")


def test_no_embedded_newlines_in_output():
lines = _wrap_panel_text_keep_ws(HEREDOC, width=20)
assert all("\n" not in line for line in lines)
assert len(lines) > len(HEREDOC.split("\n"))


def test_empty_lines_are_preserved():
assert _wrap_panel_text("a\n\nb", width=60) == ["a", "", "b"]
assert _wrap_panel_text_keep_ws("a\n\nb", width=60) == ["a", "", "b"]


def test_all_line_ending_styles_are_normalized_without_losing_empty_lines():
expected = ["a", "", "b", ""]
for text in ("a\n\nb\n", "a\r\n\r\nb\r\n", "a\r\rb\r"):
lines = _wrap_panel_text_keep_ws(text, width=60)
assert lines == expected
assert all("\r" not in line and "\n" not in line for line in lines)


def test_single_line_behaviour_unchanged():
assert _wrap_panel_text("short", width=60) == ["short"]
long_line = "x" * 130
lines = _wrap_panel_text_keep_ws(long_line, width=60)
assert lines == ["x" * 60, "x" * 60, "x" * 10]


def test_empty_input_returns_single_empty_line():
assert _wrap_panel_text("", width=60) == [""]


def test_subsequent_indent_applies_to_continuations_only():
lines = _wrap_panel_text("aa bb cc dd ee", width=4, subsequent_indent="> ")
assert lines[0].startswith("aa")
assert all(line.startswith("> ") for line in lines[1:])
assert all(len(line) <= 8 for line in lines)


def test_narrow_width_floor():
lines = _wrap_panel_text_keep_ws("x" * 20, width=2)
assert lines == ["x" * 8, "x" * 8, "x" * 4]


def test_clarify_panel_prose_does_not_break_words():
word = "supercalifragilisticexpialidocious"
lines = _wrap_panel_text(f"hello {word} world", width=10)
assert any(word in line for line in lines)


def test_clarify_splits_multiline_question():
question = "Which database should I use?\nContext:\nthe sessions table is large"
lines = _wrap_panel_text(question, width=72)
assert lines == question.split("\n")
assert all("\n" not in line for line in lines)


def test_clarify_preserves_empty_lines():
assert _wrap_panel_text("question?\n\nmore context", width=72) == [
"question?",
"",
"more context",
]
Loading