Skip to content

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

Open
lulu-loopp wants to merge 2 commits 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 2 commits 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 stop reducing the anchor's column in place
and to recover it from the physical cursor instead.

Two commits:

  1. Fix the input anchor column when the buffer is narrowed and then widened.
    Derive the column from _initialPromptCells, a width-independent quantity, rather
    than reducing _initialX modulo the new width.
  2. Recover the edit anchor from the cursor when the buffer width changes.
    _initialPromptCells is itself seeded from a column the console reported, so it is
    already reduced whenever ReadLine starts on a buffer narrower than the prompt.
    The cursor is the only observation that survives a reflow, so the anchor is taken
    to be the column that would put the cursor where the console says it is, with the
    derived column demoted to a starting point and a fallback.

The second commit supersedes the modulo arithmetic introduced by the first for the
cases the cursor can speak to, and keeps it for the cases it cannot.

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 first 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.

A second defect the first fix missed

_initialPromptCells is seeded from _initialX, which is the column the console
reported when ReadLine started. That column is the prompt's cell width already
reduced
modulo the width in effect at the time. So when ReadLine starts on a buffer
narrower than the prompt, the seed is not the prompt's width but the reduced column,
and the derivation above reproduces the reduced column forever:

prompt cells buffer width _initialPromptCells derived column correct
ReadLine starts 82 62 82 % 62 = 20 -- 20
widen 82 82 20 20 % 82 = 20 82

This is the case listed as not covered in the first version of this PR. It turns out to
be reachable without the terminal ever being resized by hand -- opening a split pane, a
preview pane, or any window that starts narrow puts ReadLine on a buffer narrower
than the prompt -- and its symptom does not look like a resize bug, because nothing
renders while the input is empty.

Recipe

A prompt of 82 cells, ReadLine entered at a buffer width of 62, widened to 82, then
one history entry of 36 characters recalled with Up and blanked with
Down. No further resize and no typing.

The first render of the recalled entry starts at column 20 instead of column 82, and
Down then blanks exactly that span, carving a hole out of the middle of the
prompt with its head and tail intact:

first commit only  PPPPPPPPPPPPPPPPPPPP                                    PPPPPPPPPPPPPPPPPPPPPPPPPP
both commits       PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP

and the emitted sequences say the same thing -- CUP to row 3 column 21 versus CUP
to row 3 column 82:

first commit only  ESC[A  ESC[6n ESC[3;82H ESC[?25l ESC[3;21H ESC[93m SSSS...(36) ESC[3;57H ESC[?25h
both commits       ESC[A  ESC[6n ESC[3;82H ESC[?25l ESC[3;82H ESC[93m SSSS...(36) ESC[4;36H ESC[?25h

The second fix: recover the anchor from the cursor

The anchor is not observable after a resize -- the terminal reflowed the screen and
reported nothing about where it moved the prompt to -- but the cursor is, and the
terminal moved the two together. So the anchor is taken to be at the column that would
put the cursor where the console says the cursor is, and at the row that many physical
lines above the cursor's.

private void RecomputeInitialCoordsFromCursor(Func<int, Point> cursorPointFrom)

cursorPointFrom is where the cursor would be drawn if the anchor were at the given
column of line 0, which is what the two branches of RecomputeInitialCoords already
computed: ConvertOffsetToPoint(_current) when the text buffer still describes what is
on the screen, and ConvertRenderDataOffsetToPoint(...) when it does not.

With an empty input this makes the anchor the cursor itself, which is exactly what
capturing the initial coordinates would have given had ReadLine been entered at the
new width -- including the case above, which no arithmetic on _initialX can recover.
The assumption that the cursor still points at the same character of the input after a
reflow is the one _initialY is already recovered from today.

The multi-line pitfall that shaped it

The cursor does not always tell the columns apart, and reading it as an unconditional
answer is worse than the defect it fixes.

A newline in the input moves the rendering to the continuation prompt's column no
matter where the anchor is: ConvertOffsetToPoint resets the column at every logical
line break, so once the cursor is past one, every candidate column produces the same
cursor point. Measuring the offset from column 0 and subtracting it from the cursor
therefore yields column 0 for any multi-line input, on every resize -- the same
overwritten prompt this PR is about, newly inflicted on prompts that always fitted the
buffer. Separately, a double width character pushed whole onto the next physical line
leaves a cell of slack, so two neighbouring columns can agree with the cursor.

So the column derived from _initialPromptCells is where the search starts and is the
answer whenever the cursor agrees with it; only when the cursor disagrees does the
search step outward for the nearest column that agrees, and if none does, the derived
column stands. A candidate that would place the cursor above the anchor is rejected,
since that is not a state ReadLine can be in. This makes the second commit a
refinement of the first rather than a replacement for it, and leaves every input the
cursor says nothing about rendering exactly as it does today.

_initialPromptCells is deliberately left as it is once the anchor is known. What it
holds over _initialX is how many physical lines the prompt spans at the width it was
captured at, which the cursor never reveals, so rewriting it from a column observed at
a different width would mix two units.

Behavior boundaries

  • Narrowing is unchanged. For the first width change the old and new 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 already wider than the buffer when ReadLine was entered is now covered
    as long as the cursor can distinguish the column, which it can whenever the cursor is
    on the first logical line of the input. It is not covered for a cursor past a
    newline, where every column agrees with the cursor and the derived column, still
    reduced, is the best available answer.
  • A prompt that exactly fills a physical line leaves one cell of residual error:
    the console reports a wrap-pending cursor at the last column rather than at column 0
    of the next line, and the Console API does not expose the wrap-pending bit. That is
    the same reading ReadLine makes when it captures the initial coordinates in the
    first place, so the recovered anchor has exactly the fidelity a fresh ReadLine at
    the new width would have had.

Verification

Unit tests

Six tests in test/ResizingTest.cs, five of them added by the second commit. They
drive RecomputeInitialCoords directly across a chain of buffer widths, setting the
test console's cursor at each width to where a terminal that reflowed the screen would
report it, and assert the recovered anchor against where the reflow left it:

test covers
..._ShouldRecoverInitialXWhenBufferGetsWider 36-cell prompt over 100, 35, 60, 25, 100
..._ShouldRecoverInitialXWhenThePromptDidNotFitTheInitialBuffer 82-cell prompt entered at 62, then widened
..._ShouldRecoverInitialXWithTextOnTheInputLine the same with the cursor off the anchor, both directions
..._ShouldKeepWorkingWhenThePromptFitsTheBuffer a prompt that always fitted, empty and non-empty
..._ShouldRecoverInitialXWithAMultiLineInput the column-0 overshoot described above
..._ShouldRecoverInitialXFromRenderDataWhenTheInputChanged the isTextBufferUnchanged: false branch

The rendering the tests place the cursor from is the forward direction of the
calculation under test, which makes them a test of the inverse -- given a rendering and
where it ended up on the screen, where does the anchor have to be -- rather than a
restatement of it.

Three of the six fail on the first commit alone. The multi-line one fails on a version
of the second commit that reads the cursor without checking it against the derived
column, which is what put the check there.

Full suite on this branch (./build.ps1 -Test -Configuration Release, net8.0):

xUnitTestResults.en-US.xml          total=298 passed=296 failed=0 skipped=2
xUnitTestResults.screen-reader.xml  total=298 passed=16  failed=0 skipped=282

Only the en-US layout is installed on my machine, so the rest of the layout-gated
SkippableFacts are skipped here; CI covers those.

Against a real terminal

The unit tests cannot show that PSReadLine then renders where it says it will, so both
commits were also measured end to end through a ConPTY pseudoconsole, on Windows 11
26200.

For the first commit, a probe 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. The last column is the first commit 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. 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 + first commit
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. The last row is the second defect above, and is what the second commit
addresses.

For the second commit, the recipe under "A second defect the first fix missed" was run
against a real pwsh.exe over ConPTY, five runs per arm, comparing the child's own
console text buffer read back through the console API rather than the emitted sequences:

first commit only     the prompt is carved  5 / 5
both commits          the prompt is carved  0 / 5

The instrumented recovery, one line per call:

enter unchanged=False handle=True initial=(20,4) promptCells=20 cursor=(81,2) buf=82x20 prev=62x20 bufferLen=36 current=36
  fromCursor width=82 cursor=(81,2) believedX=20 believedPt=(20,0) -> (81,2)

One cell of the prompt's trailing space is still blanked, which is the wrap-pending
reading described under "Behavior boundaries" -- one cell, against 36 carved cells
before.

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 prompt string itself is available in InvokePrompt and in the prompt-reprint
recovery paths, so _initialPromptCells could be measured there directly instead of
being seeded from _initialX. That would make the derived column correct on those two
entry paths even where the cursor cannot distinguish it, and would leave only the normal
entry path relying on the cursor. It is not folded in here because it would make the
field mean one thing on some paths and another on the rest; happy to add it if you would
like it handled in this PR.

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. The second defect surfaced there as a
history recall carving a hole in the prompt with no resize in sight, which is why the
recipe above is written in terms of Up and Down rather than a
window drag.

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 reads CursorLeft,
      CursorTop and BufferWidth and has no platform-specific part.
  • User-facing changes
    • Not Applicable

Related to #3637

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

'RecomputeInitialCoords' recovers the anchor's column with

    _initialX = _initialPromptCells % _console.BufferWidth;

and '_initialPromptCells' is seeded from '_initialX', which is the column
the console reported when 'ReadLine' started. That column is the prompt's
cell width already reduced modulo the width in effect at the time, so when
'ReadLine' starts on a buffer narrower than the prompt the seed is the
reduced column and not the prompt's width: a prompt of 82 cells entered at
a width of 62 seeds 20, and widening to 82 recovers 20 % 82 == 20 rather
than the anchor. Nothing shows while the input is empty. The first render
of a non-empty input is then written 62 cells into the prompt, and the
next one blanks exactly that span. Reducing '_initialX' in place, which is
what this replaced, is wrong in the same way and for the same reason.

The anchor is not observable after a resize - the terminal reflowed the
screen and reported nothing about where it moved the prompt to - but the
cursor is, and the terminal moved the two together. So take the anchor to
be at the column that would put the cursor where the console says the
cursor is, and at the row that many physical lines above the cursor's.
With an empty input that is the cursor itself, which is exactly what
capturing the initial coordinates would have given had 'ReadLine' been
entered at the new width; the assumption that the cursor still points at
the same character of the input after a reflow is the one '_initialY' is
already recovered from.

The cursor does not always tell the columns apart. A newline in the input
moves the rendering to the continuation prompt's column no matter where
the anchor is, so past one, every column agrees with the cursor; and a
double width character pushed whole onto the next physical line leaves a
cell of slack, so two neighbouring columns can agree. The column derived
from '_initialPromptCells' is therefore where the search starts and the
answer whenever the cursor agrees with it, which leaves every input the
cursor says nothing about rendering as it does today, and makes this a
refinement of that derivation rather than a replacement for it. Taking the
cursor's own column as the offset to subtract, without that check, would
put the anchor at column 0 for every multi-line input on every resize -
the same overwritten prompt, on prompts that always fitted the buffer.

'_initialPromptCells' is left alone once the anchor is known. What it
holds over '_initialX' is how many physical lines the prompt spans at the
width it was captured at, which the cursor never reveals, so rewriting it
from a column observed at a different width would mix two units.

Related to PowerShell#3637
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