fix(painter): trust the measured cursor row over a short terminal size - #1206
Open
bhouse-nexthop wants to merge 1 commit into
Open
fix(painter): trust the measured cursor row over a short terminal size#1206bhouse-nexthop wants to merge 1 commit into
bhouse-nexthop wants to merge 1 commit into
Conversation
bhouse-nexthop
force-pushed
the
fix-1205-prompt-climb-short-winsize
branch
2 times, most recently
from
September 6, 2026 15:01
778eb7f to
4e23aba
Compare
When the kernel's winsize is shorter than the window actually attached, the cursor can sit below the row reedline believes is the last one. The anchor was stored outside the believed screen, so `remaining_lines()` saturated to 0, every repaint read as "out of room" and scrolled, and the prompt walked up one row per keystroke until it reached the believed bottom. A terminal only ever reports a cursor row it actually has, so a measured row is hard evidence of a floor on the screen height. Raise the believed height to include it wherever the cursor is measured and the reported size may be stale. When the reported size is right this is a no-op, since the cursor is always inside the screen. The growth can land mid-paint, so `repaint_buffer` now reads the height after the stale-anchor reconcile rather than before it. Reading it earlier split a paint between two screens: `remaining_lines` described the grown one while `large_buffer` judged against the old, short one and reset the anchor to row 0. The reconcile also grows to fit the anchor it settles on, which is not always the row it measured. Test writers can now be given a cursor position to answer with, and the size `initialize_prompt_position` works from is passed in, so the tests drive the real measuring paths rather than adjusting the height by hand. Signed-off-by: Brad House <bhouse@nexthop.ai>
bhouse-nexthop
force-pushed
the
fix-1205-prompt-climb-short-winsize
branch
from
September 6, 2026 15:17
4e23aba to
2306c6f
Compare
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
terminal::size()isn't always the truth. When the kernel's winsize is shorter than the window actually attached, the cursor can sit below the row reedline believes is the last one — and the prompt then climbs one row per keystroke.A terminal only ever reports a cursor row it actually has, so a measured row is hard evidence of a floor on the screen height. This raises the believed height to include the measured row, wherever the cursor is read and the reported size may be stale.
Where the short winsize comes from:
agetty0x0→ reedline assumes 80x24expect/pexpectwrappersSIGWINCHnever reached the ptyPublic API: unchanged.
Observable behavior when the reported size is correct: unchanged. The cursor is always inside the screen, so the floor is already met and the adjustment is a no-op.
Fixes #1205
Before
Kernel says 10 rows, real window is 50, cursor measured at row 35. Anchor 35 is outside the believed screen, so
remaining_lines()saturates to0and every repaint reads as "out of room":remaining_lines()extrashowThe prompt walks up one row per character, leaving blank rows below it, and stops once it reaches row 10 — so it reads as intermittent.
After
The measured row 35 raises the believed height to 36, which puts the anchor back inside the screen:
remaining_lines()extrashowRepro from the issue, which no longer climbs:
What changed
measure_cursor_position(new)initialize_prompt_position,repaint_bufferreconcile,print_external_messagerepaint_bufferscreen_heightafter the reconcile; grows to fit the anchor it settles on, which is not always the row it measuredhandle_resizeW::Sinkinitialize_prompt_positionterminal::size()needs a ttyAdditional notes
remaining_linesdescribes the grown screen whilelarge_bufferjudges against the old, short one, reports a buffer taller than the screen and resets the anchor to row 0. That is the resize path (row 3 of the table above), so it is the normal case, not a corner.handle_resizestill reads the cursor directly. Its size came from the resize event, so it needs no correction, and the read can beat the terminal's own clamping of the cursor into a possibly shorter screen. This only defers the growth by one turn:handle_resizeleaves the anchorStale, so the next paint goes through the reconcile and grows there if needed.initialize_prompt_positionkeeps==instead of>=. With the floor appliednew_rowis at most one past the height, so equality is exhaustive for that arm. TheUseExistingPromptarm skips the guard but is bounded too, sinceselect_prompt_rowonly re-uses a range that contains the measured row. No anchor lands outside the screen.read_line. Nothing lowers it until the nextterminal::size()— the nextread_line, or a resize event.screen_height()ispuband reaches third-partyMenuimpls, so its doc now says it is a lower bound rather than a reading. Signatures are unchanged. Flagging it in case you'd rather bound the growth.initialize_prompt_positiononly substitutes a default for exactly(0, 0), andhandle_resize(0, 0)has no fallback at all, so a believed height of0was reachable and made every paint take the large-buffer reset. A measured cursor row now repairs that.width >= col + 1by the same argument, but the column is 0 at nearly every measurement point, so it proves nothing useful — and an over-wide belief mis-wraps rather than failing safe.Scope — what this does not fix
The grown height is a floor (
measured row + 1), not the real height, soremaining_lines()is 1 rather than the real 15 in the example above. A single-row entry is then stable, as the table shows, but anything taller still takes the scroll branch once:show35, 35, 35, 3534, 34, 34, 34So a multi-row entry or an open menu still jumps up once and then holds, instead of climbing every keystroke. Closing that needs the real height, which nothing reports. Related: menus size themselves in
engine.rsbeforerepaint_bufferruns, so on the frame the height grows they use the pre-growth value — one frame, and in the safe direction (menu smaller than the screen can hold).Tests
0, andu16::MAXsaturation.Every production change is independently pinned — reverting any one of these five fails at least one test:
initialize_prompt_positioncall siterepaint_bufferreconcile call siteprint_external_messagecall sitescreen_heightafter the reconcilecargo fmt --all -- --check,cargo clippy --locked --all-targets --all-features, andcargo test --all --all-features(1714 passing) are all clean, at--all-features, default, and--no-default-features.