Wrap prompt messages to the terminal width#499
Open
andrzej-pomirski-yohana wants to merge 1 commit into
Open
Conversation
5642cfb to
9e0ee77
Compare
When a prompt message contains a newline, prompt-toolkit splits the message at the last newline (in _split_multiline_prompt) and renders everything before that newline in a Window whose wrap_lines is unset. Only the input buffer window honours wrap_lines, so long help text from callers like Copier — which appends a newline plus space to push the cursor onto the next line — gets truncated at the terminal edge instead of wrapping. See tmbo#398 and copier-org/copier#1764. Pre-wrap each newline-separated paragraph of the message to the current terminal width before constructing the formatted text. The rendered lines are then already short enough that prompt-toolkit's missing wrap on the above-input window doesn't matter. The terminal width is queried from the running Application via get_app_or_none().output.get_size() so SIGWINCH resizes pick up the new width on the next render. Falls back to shutil.get_terminal_size outside a prompt session (tests, etc.), and finally to 80. A helper format_question_tokens in prompts/common.py owns the prefix-width arithmetic and the qmark + question token assembly, so the seven prompt types that format a user-provided message (text, select, checkbox, confirm, autocomplete, path, press_any_key_to_continue) share one call site instead of each duplicating the wrap and formatting logic. password and rawselect delegate to text and select and inherit the fix transparently. The low-level wrap function stays in utils.py for callers building custom prompts on top of questionary's primitives. Non-string messages (already-formatted tuples and lists) pass through unchanged so callers passing pre-styled prompts are not disturbed.
9e0ee77 to
bcb5e9c
Compare
Author
|
Hi, this change was Claude Code-aided, but it was not fully AI generated, I did read all the code and make fixes to it. Hopefully it passes your quality bar |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #398 — long prompt messages stop wrapping to terminal width when the message contains a newline.
This is the questionary side of copier-org/copier#1764, which was closed pointing at #398. Copier appends
"\n "to every prompt's message to push the cursor onto the next line, and the resulting newline trips the wrap-disabling code path described below.Root cause
In
prompt_toolkit/shortcuts/prompt.py,_split_multiline_promptsplits the prompt at the last newline in the message. Everything before that newline is rendered in a separate above-inputWindow. That window is constructed with the defaultwrap_lines=False; only the input-buffer window receiveswrap_lines=True:So when a caller passes a message like
"long help text\n ", prompt-toolkit puts "long help text" into the non-wrapping window and the long line overflows the terminal.Fix
Pre-wrap each
\n-separated paragraph of the message to the current terminal width before constructing the formatted text. The rendered lines are then already short enough that prompt-toolkit's missing wrap on the above-input window doesn't matter.Two internal helpers handle this:
_wrap_message_to_terminal_width(message, prefix_width)inquestionary/utils.py— the low-level primitive. Splits on\n, wraps each paragraph withtextwrap.fill(no breaking of long words, no break-on-hyphens, preserves whitespace), returns the rewrapped string. Non-string messages (already-formatted tuples / lists) pass through unchanged so callers passing pre-styled prompts are not disturbed.format_question_tokens(qmark, message)inquestionary/prompts/common.py— builds the leading[("class:qmark", qmark), ("class:question", " message ")]tuple list used by every built-in prompt. Owns theprefix_width = len(qmark) + 1arithmetic so prompt files don't have to know about it. Withqmark=Noneit omits the qmark token and reduces to a question-only prompt.Each prompt's
get_prompt_tokensbecomes a one-line call toformat_question_tokens. Applied across the seven prompt types that format a user-provided message:text,select,checkbox,confirm,autocomplete,path,press_any_key_to_continue.passwordandrawselectdelegate totext/selectand inherit the fix transparently.Terminal width source
_current_terminal_width()queries the activeApplicationviaget_app_or_none().output.get_size()first — that value updates on SIGWINCH, so subsequent re-renders pick up the new width. Falls back toshutil.get_terminal_size()outside a prompt session (e.g. unit tests), and finally toDEFAULT_TERMINAL_WIDTH(80, defined inquestionary/constants.py).Known limitation: rapid resize artifacts
This fix makes the prompt height variable with terminal width (1 line when wide, several when narrow). That exposes a pre-existing prompt-toolkit limitation around resize handling:
Application._on_resizecallsrenderer.erase()then_request_absolute_cursor_position()then_redraw()— but the redraw fires before the async CPR response, so rapid SIGWINCH bursts can leave stale prior renders in the scrollback. The final render at any width is correct; only the intermediate frames during rapid resize are affected.Without this fix the issue is hidden because every render is a single (cropped) line, so erase + redraw at the same height stays clean.
The proper fix for the resize race is upstream in prompt-toolkit's renderer / CPR handling; this PR doesn't attempt it.
Tests
17 new unit tests.
tests/test_utils.py(14 tests on_wrap_message_to_terminal_widthand_current_terminal_width):\nwraps (regression for Newline\nin message breaks line wrap in terminal #398)prefix_widthreserves space on the first lineFormattedTexttuple case caught early in development)textwrapwithbreak_long_words=False)_current_terminal_widthfalls back toshutilon no-app, then toDEFAULT_TERMINAL_WIDTHonOSError_current_terminal_widthprefers the active app's size overshutil_current_terminal_widthfalls through toshutilwhen the app reportscolumns=0_current_terminal_widthreturnsDEFAULT_TERMINAL_WIDTHwhenshutilreportscolumns=0tests/prompts/test_common.py(3 tests onformat_question_tokens):[qmark, question]token pair with the expected style classesqmark=NoneAll 180 tests pass (163 pre-existing + 17 new).
Contributor checklist
make test— 180 tests passmake lint— pre-commit hooks pass (autoflake, black, isort, flake8)make types— mypy clean, no issues in 19 source filesManual verification
End-to-end test with copier (which is what surfaced the bug originally):
copier copyagainst a template with long help strings in a 60-col terminal.