Skip to content

Fix the input anchor column when the buffer is narrowed and then widened - #5191

Open
lulu-loopp wants to merge 1 commit into
PowerShell:masterfrom
lulu-loopp:fix/resize-anchor-quotient-loss
Open

Fix the input anchor column when the buffer is narrowed and then widened#5191
lulu-loopp wants to merge 1 commit into
PowerShell:masterfrom
lulu-loopp:fix/resize-anchor-quotient-loss

Conversation

@lulu-loopp

@lulu-loopp lulu-loopp commented Aug 12, 2026

Copy link
Copy Markdown

PR Summary

When the terminal is made narrower than the prompt and then wider again, PSReadLine
draws the input on top of the prompt and leaves the text it drew at the narrow width
behind on the screen. The edit anchor is never restored for the rest of that
ReadLine call, so every subsequent keystroke re-renders in the wrong column.

This changes RecomputeInitialCoords to derive the anchor's column from a
width-independent quantity instead of reducing the column in place.

Repro

# 36 cells wide
function prompt { 'PSRL-ANCHOR-PROBE-0123456789012345> ' }
  1. Size the window to 100 columns and press Enter to get a fresh prompt.
  2. Type something -- for example Get-ChildItem -Recurse -Filter *.rs | Select-Object FullName -- and do not press Enter.
  3. Narrow the window to 35 columns, which is narrower than the prompt, and type one character.
    The input wraps onto the following rows and is rendered correctly.
  4. Widen the window back to 100 columns and type one more character.

Expected: the input is rendered starting at column 36, right after the prompt.

Actual: the input is rendered starting at column 1, over the prompt:

Screen after step 4, on 2.4.5:

PGet-ChildItem -Recurse -Filter *.rs | Select-Object FullNameXY
 Get-ChildItem -Recurse -Filter *.r
s | Select-Object FullNameX

and with this change:

PSRL-ANCHOR-PROBE-0123456789012345> Get-ChildItem -Recurse -Filter *.rs | Select-Object FullNameXY
 Get-ChildItem -Recurse -Filter *.r
s | Select-Object FullNameX

Rows 2 and 3 are what was drawn while the window was narrow. They are not cleaned up
in either case -- the prompt on row 1 is what this change is about.

Root cause

RecomputeInitialCoords recovers the anchor column after a buffer width change with
a single statement, in both of its branches:

// Recompute X from the buffer width:
_initialX %= _console.BufferWidth;

_initialX is the anchor's column at the width that was in effect before the
resize
, so it already is the prompt's cell width reduced modulo that width.
Reducing it a second time is correct the first time the buffer is narrowed past the
prompt, but it discards the quotient, and the quotient is the only record of how many
physical lines the prompt spans. Once it is gone the prompt's true width cannot be
reconstructed:

step buffer width prompt cells _initialX before _initialX after correct
start 100 36 -- 36 36
narrow 35 36 36 36 % 35 = 1 1
widen 100 36 1 1 % 100 = 1 36

The statement dates back to b2979d1 ("Fix rendering after buffer resize", 2017) and is
present unchanged in every release from 2.0.0 through 2.4.5.

The fix

Keep the width-independent quantity. _initialPromptCells is the cell width of the
prompt's last logical line, measured from column 0 of the physical line where that
logical line starts. It is captured wherever the anchor is captured -- input
initialization in ReadLine.cs, InvokePrompt, and the two prompt-reprint recovery
paths in Render.cs -- and is never modified afterwards. _initialX is then derived
from it on every buffer width change:

_initialX = _initialPromptCells % _console.BufferWidth;

While the prompt fits in the buffer, _initialPromptCells == _initialX and the new
expression is the identity, so nothing changes for the common case.

Behavior boundaries

  • Narrowing is unchanged. For the first width change the two expressions are
    equal by definition, and for later ones the new expression is what the old one was
    trying to compute.
  • Widening now restores the anchor, including across several successive resizes
    (100 -> 35 -> 60 -> 25 -> 100 was measured).
  • A prompt that was already wider than the buffer when ReadLine was entered is not
    covered.
    CursorLeft is the only observation available at that point and it is
    already reduced, so the prompt's width is not recoverable without re-invoking the
    user's prompt function. That case behaves exactly as it did before this change;
    see "Remaining work" below.

Verification

Unit test

RecomputeInitialCoords_ShouldRecoverInitialXWhenBufferGetsWider in
test/ResizingTest.cs walks a 36-cell prompt through the buffer widths
100, 35, 60, 25, 100 and checks the initial column after each change. It fails on the
current code at the third width (initial column is 1 but should be 36) and passes
with this change. It only checks the column: recovering the row relies on the terminal
having reflowed the screen buffer, which TestConsole does not do, so a resizable
console fixture would not make that half meaningful.

The full suite passes on this branch (dotnet test, net8.0). Note that most of the
suite is SkippableFact gated on the active keyboard layout and skipped on my
machine; CI covers those.

Against a real terminal

The unit test cannot show that PSReadLine then renders where it says it will, so the
change was also measured end to end: a ConPTY pseudoconsole is driven by a probe that
writes keys, resizes the pseudoconsole, and parses the emitted CUP sequences to read
back the column PSReadLine actually renders at. Prompt = 36 cells, input = 60
characters, on Windows 11 26200. The "patched" module in the tables is this change
applied on top of the v2.4.5 tag, so that it could be loaded next to the shipped
module for a like-for-like comparison; the code is the same as on this branch.

Each case starts at the first width listed, resizes as listed, and types one character
at each width.

case widths 5.1 + 2.0.0 7.6.4 + 2.4.5 7.6.4 + patched
prompt wider than the narrowed buffer, then restored 100, 35, 100 FAIL FAIL PASS
prompt spans 2 rows while narrow 100, 20, 100 FAIL FAIL PASS
prompt spans 4 rows while narrow 100, 10, 100 FAIL FAIL PASS
prompt still fits after narrowing (sanity) 100, 50, 100 PASS PASS PASS
several successive resizes 100, 35, 60, 25, 100 FAIL FAIL PASS
short prompt, never wraps (sanity) 100, 35, 100 PASS PASS PASS
prompt already wrapped when ReadLine started 30, 100 FAIL FAIL FAIL
2 / 7 2 / 7 6 / 7

Windows PowerShell 5.1 with 2.4.5 side-loaded measures the same as the two baseline
columns above. For the first case, 2.4.5 restores the anchor to column 1 instead of
36 and the patched build restores it to 36. The last case is the one described under
"Behavior boundaries".

Editing regression matrix

Fourteen resize-then-edit scenarios (type, repeated type, Escape, Backspace,
Home, Home then type, LeftArrow then type, UpArrow, multi-line input; each
narrowing and widening; short and medium prompts) were run against official 2.4.5 and
against the patched build. The two transcripts -- render columns, cursor moves, and
final screen contents -- are identical byte for byte, including the three
scenarios that already drifted before this change (LeftArrow then type, and the two
multi-line cases). Those three are separate pre-existing problems and are untouched
here.

Relationship to #3074

#3074 (f46f15d, first released in
v2.2.0-beta5) rewrote RecomputeInitialCoords around the render data, and fixed the
cases where the text buffer had changed across the resize. It carried this statement
forward unchanged -- it only reformatted _initialX = _initialX % ... to _initialX %= ... and duplicated it into the new else branch -- so the anchor column has had
the same defect before and after that work. That is consistent with 2.0.0 and 2.4.5
measuring identically in the table above.

Remaining work

The uncovered case (prompt already wrapped when ReadLine starts) needs the prompt's
width from a source other than CursorLeft. The prompt string is available in
InvokePrompt and in the prompt-reprint recovery paths, so _initialPromptCells
could be measured directly there; for the normal entry path it would require either
invoking the prompt function again or reading the screen buffer, neither of which
seemed appropriate to fold into this fix. Happy to follow up if you would like it
handled here.

Notes

Found while building a terminal on Windows, where this was reproducible against the
inbox PSReadLine 2.0.0 as well as current 2.4.5.

PR Checklist

  • PR has a meaningful title
    • Use the present tense and imperative mood when describing your changes
  • Summarized changes
  • Make sure you've added one or more new tests
  • Make sure you've tested these changes in terminals that PowerShell is commonly used in (i.e. conhost.exe, Windows Terminal, Visual Studio Code Integrated Terminal, etc.)
    • Measured through a ConPTY pseudoconsole, which is the path Windows Terminal and
      the VS Code integrated terminal take, on both Windows PowerShell 5.1 and pwsh
      7.6.4. I have not measured on macOS or Linux; the change is arithmetic on the
      buffer width and has no platform-specific part.
  • User-facing changes
    • Not Applicable

Related to #3637

Microsoft Reviewers: Open in CodeFlow

When the buffer width changes, 'RecomputeInitialCoords' recovers the
column of the edit anchor with

    _initialX %= _console.BufferWidth;

'_initialX' is the anchor's column at the width that was in effect
before the resize, so it is already the prompt's cell width reduced
modulo that width. Reducing it a second time gives the right answer the
first time the buffer is narrowed past the prompt, but it discards how
many physical lines the prompt spans, and that is never recovered: a
36-cell prompt narrowed to a width of 35 leaves '_initialX' at 1, and
widening back to 100 computes 1 % 100 == 1. Every subsequent render of
a non-empty input is then written one column into the prompt,
overwriting it, and the text drawn at the narrow width is left behind on
the screen.

Keep the width-independent quantity instead. '_initialPromptCells' is
captured wherever the anchor is captured and is never modified
afterwards, and '_initialX' is derived from it on every buffer width
change. Narrowing behaves exactly as before, and widening now restores
the anchor, including across several successive resizes.

This does not cover a prompt that was already wider than the buffer when
'ReadLine' was entered. 'CursorLeft' is the only observation available
in that case and it is already reduced, so the prompt's width cannot be
recovered without re-invoking the user's prompt function. That case
behaves as it did before.

Related to PowerShell#3637
@lulu-loopp

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant