Repository navigation
fix(terminal): keep xterm and ConPTY agreeing about the cursor row - #216
Merged
Merged
Conversation
Growing a pane — closing the pane below it, resizing the window — could leave the prompt stranded in the middle of old output, with typing overwriting whatever was on that row. `clear` looked like a fix only because it resynced both sides. Two independent halves, either of which reproduces it on its own. ## xterm was not told its PTY is ConPTY Without `windowsPty`, xterm grows rows the POSIX way: it pulls scrollback back DOWN into the viewport (`ybase--`) and keeps the cursor on the same ABSOLUTE buffer line. ConPTY does the opposite — verified against a live one, it appends blank rows at the bottom, re-homes to `ESC[H`, repaints its whole viewport and issues one absolute CUP that keeps the cursor on the same row FROM THE TOP. Grow by K rows and the two disagree by exactly K. This is the case xterm's own typings describe; the option exists for it. The build number is not decoration: xterm gates reflow on `buildNumber >= 21376`, and below that also turns on the "wrapped unless the line ends in whitespace" heuristic, which is correct for those older ConPTYs and is what every other xterm-based Windows terminal ships. An unparseable `os.release()` yields a block with no build number rather than a guessed one — that still fixes the resize behaviour, and leaves reflow ON, which is the safer half to lose. ## A remount must replay its snapshot at the snapshot's size, and HOLD it A split-tree change disposes and recreates the terminal, and the buffer snapshot (amirlehmam#49) was replayed into xterm's 80x24 default before the rAF `fit()` ran. The snapshot now carries the size it was taken at: SerializeAddon restores the cursor to its VIEWPORT row, so that is the size at which the replayed screen and ConPTY's screen agree about where the cursor is. Setting the size is necessary and not sufficient, which is the part that is easy to get wrong and expensive to rediscover. `terminal.write()` is asynchronous, so a resize immediately before it pins only the size the bytes are QUEUED at — and a remount is triggered by a split-tree change, i.e. precisely when the pane's size changed, so the PTY-attach continuation, the ResizeObserver and the visibility effect are all racing xterm's write buffer. Instrumenting a real pane close caught it: snapshot-taken rows=28 len=249 baseY=221 cursorY=27 cursorAbs=248 replay-resized rows=28 replay-drained rows=60 len=249 baseY=189 cursorY=59 cursorAbs=248 `scheduleInitialResize`'s `fit()` reached the terminal before the parse did, so the replay was laid out at 60 rows and the restored cursor landed on row 59 of 60 while ConPTY still had it on row 27 of 28 — stranded by exactly the 32 rows the pane had grown. Nothing resyncs that: from ConPTY's side nothing happened, so it never repaints. So the size is held for the duration of the parse. `ReplayHold` is that latch, gated inside `fit()` rather than at each caller because `fit()` is reached from four independent effects and one ungated path loses the whole property. The PTY is refused alongside it — a resize landing while xterm is pinned produces the same divergence from the other direction. Whatever asked while held is paid back once, in the write callback: a single growth applied to both sides in step, which is the case `windowsPty` makes correct. ## Visible change A pane that grows now shows blank rows at the bottom instead of pulling old scrollback down into view. That is ConPTY's own behaviour and what Windows Terminal does, but it is a change worth knowing about. `os.release()` is exposed on the preload's `system` block as a plain string rather than an IPC round-trip, because the value is needed at Terminal CONSTRUCTION time — an async answer arrives after the terminal already exists. The latch is pure and unit-tested; the hook needs a DOM the node-env suite does not have, so its wiring is pinned at the source level, the shape `resources-sync.test.ts` uses.
amirlehmam
pushed a commit
that referenced
this pull request
Sep 29, 2026
…ands (#256) Moving the window to another monitor — or back — could still leave the prompt stranded in the middle of old output, with typing overwriting whatever was on that row, after #216 fixed the same symptom for a pane that grows. The trigger this time is a change of COLUMNS: a different monitor means a different width, and a different width means a reflow. ## xterm and ConPTY anchor a reflow at opposite ends ConPTY's buffer is exactly the viewport; it has no scrollback. When a resize reflows it, the viewport's TOP line stays put: a widen that unwraps a long line pulls everything below it up, leaves a blank row at the bottom and moves the cursor up with the content; a narrow re-wraps into the blank rows under the cursor before anything scrolls off the top. xterm anchors the BOTTOM whenever it has scrollback to trade: `_reflowLargerAdjustViewport` answers every row a widen removes with `ybase--`, pulling a scrollback line down into view, and `_reflowSmaller` answers every row a narrow adds with `ybase++`. The cursor stays on its viewport row. So the two disagree by one row per line that wrapped or unwrapped inside the viewport. `windowsPty` covers the equivalent mismatch for a change of ROWS, not reflow. Nothing corrects it afterwards: the bundled conpty.dll sends zero bytes after a resize (measured), so the disagreement lasts until a `clear`. ## Measured, not reasoned Through node-pty and the bundled ConPTY, driving the same xterm options as the app, typing a marker after each resize and reading which row it landed on: widen, narrow, widen→narrow, narrow→widen, rows+columns together, a drag, and a monitor-style round trip (shrink both, grow both, back). Without this change 9 of 16 scenarios strand the prompt; with it 15 land on the prompt, and the 16th — a 2-column pane — fails identically before and after: ConPTY wraps the cursor's own line there and xterm does not reflow it. ## The fix Before a resize that changes the column count, drop a marker on the viewport's top line; markers ride through reflow onto the line that now holds that content. After it, move the viewport so that line is on top again — or just far enough to keep the cursor on screen — which is ConPTY's layout. Moving content up is a real scroll (the lines go back into scrollback); moving it down only ever drops BLANK rows below the cursor, the ones ConPTY spent, so no output is lost. Gated inside `fit()`, the same funnel ReplayHold uses and for the same reason: four effects reach it. The top line can be the tail of a wrapped line whose head is in scrollback — after a narrow has scrolled it off ConPTY's top, it always is. ConPTY has no head for it and reflows the tail as its own line from then on, while xterm rejoins it; a one-off offset for that was measured to compound across a round trip. So the tail is orphaned in xterm too: its wrap flag is cleared before the resize. The visible cost is that that one long line, now in scrollback, splits where the viewport began — which is what ConPTY's copy of it already is. This reaches into xterm's buffer service (there is no public API that pulls scrollback back down or clears a wrap flag, and escape sequences would queue behind output not yet parsed). Every internal is feature-checked, and any mismatch leaves xterm's own layout in place — the old behaviour, not a crash. xterm is pinned at 6.0.0; the premise is pinned too, by a test that fails if xterm ever starts anchoring a reflow the ConPTY way itself. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
amirlehmam
added a commit
that referenced
this pull request
Oct 24, 2026
…ounts (#346) A split-tree change (split, closing a sibling, zoom, dragging a tab to another pane) remounted the panes it moved, and a remount rebuilt their xterm. Every piece of emulator state then had to be replayed by hand (buffer #49, mouse modes #164, ConPTY cursor row #216/#256, alternate screen #345), and prompt markers, cursor style and the scroll region were lost. TerminalHost, mounted once in App, now portals one TerminalPane per terminal surface into an element owned by terminal-keepalive.ts, and a pane renders a KeptAliveTerminal slot the element is moved into. A terminal whose surface still exists but has no slot (the other panes of a zoomed workspace) is parked off-screen at its last pixel size, hidden, and disposed only once its surface leaves the store. - React events from a portalled terminal bubble through the host, so the leaf's click-to-focus is a native listener on the slot (onActivate). - A same-commit move changes no prop; attachGeneration re-runs the show path (refit, repaint, refocus) since removing the element blurred its textarea. - The snapshot/replay path stays as the fallback for a genuine rebuild. Verified against real xterm 6 + the WebGL addon in wmux's Chromium: moving the element between slots and through the parking root kept the alternate screen, the buffer and the size, with no context loss. Closes #346 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Growing a pane — closing the pane below it, resizing the window — can leave the
prompt stranded in the middle of old output, with typing overwriting whatever
was on that row.
clearlooks like a fix only because it resyncs both sides.Two independent halves, either of which reproduces it on its own.
xterm is not told its PTY is ConPTY
Without
windowsPty, xterm grows rows the POSIX way: it pulls scrollback backDOWN into the viewport (
ybase--) and keeps the cursor on the same ABSOLUTEbuffer line. ConPTY does the opposite. Verified against a live one — it appends
blank rows at the bottom, re-homes to
ESC[H, repaints its whole viewport andissues one absolute CUP that keeps the cursor on the same row from the top:
Grow by K rows and the two sides disagree about the cursor row by exactly K, and
every repaint ConPTY sends afterwards lands K rows above the content it belongs
to. This is the case xterm's own typings describe; the option exists for it.
The build number is not decoration. xterm gates reflow on
buildNumber >= 21376, and below that also turns on the "wrapped unless the line ends inwhitespace" heuristic — correct for those older ConPTYs, and what every other
xterm-based Windows terminal ships. An unparseable
os.release()deliberatelyyields a block with no build number rather than a guessed one:
backendalone fixes the resize behaviour, and an absent build number leaves reflow ON,
which is the safer half of the trade to lose.
A remount must replay its snapshot at the snapshot's size — and hold it
A split-tree restructure disposes and recreates the terminal, and the buffer
snapshot (#49) was replayed into xterm's 80x24 default before the rAF
fit()ran. The snapshot now carries the size it was taken at:
SerializeAddonrestores the cursor to its viewport row, so that is the size at which the
replayed screen and ConPTY's screen agree about where the cursor is.
Setting the size is necessary and not sufficient, and this is the part worth
writing down rather than rediscovering.
terminal.write()is asynchronous, so aresize immediately before it pins only the size the bytes are queued at — and
a remount is triggered by a split-tree change, i.e. precisely when the pane's
size changed, so the PTY-attach continuation, the
ResizeObserverand thevisibility effect are all racing xterm's write buffer to re-fit first.
That is not theoretical. Instrumenting a real pane close caught it:
scheduleInitialResize'sfit()reached the terminal before the parse did, sothe replay was laid out at 60 rows and the restored cursor landed on row 59 of
60 while ConPTY still had it on row 27 of 28 — stranded by exactly the 32 rows
the pane had grown.
The failure mode that would otherwise be silent
Nothing resyncs that divergence. ConPTY re-asserts its whole screen only on a
resize it is told about, and here it was never told anything — from its side
nothing happened. So the two sides sit at different cursor rows indefinitely,
and the only feedback the user gets is that typing appears in the wrong place.
That is why the fix is a hold rather than an ordered pair of calls: any single
ungated resize path silently restores the bug, with no test failing and no error
anywhere.
ReplayHoldis that latch. It is gated insidefit()rather than at eachcaller, because
fit()is reached from four independent effects and one ungatedpath loses the whole property. The PTY is refused alongside it — a resize
landing while xterm is pinned produces the same divergence from the other
direction. Whatever asked while held is paid back once, in the write callback: a
single growth applied to both sides in step, which is the case
windowsPtymakes correct.
Scope and compatibility
os.release()is exposed on the preload'ssystemblock as a plain stringrather than an IPC round-trip, because the value is needed at Terminal
construction time — an async answer arrives after the terminal already
exists. Same shape as the existing
homeDir.instead of pulling old scrollback down into view. That is ConPTY's own
behaviour and what Windows Terminal does, but it is a change worth knowing
about before merging.
wrapping heuristic. That is what xterm intends for those ConPTYs, and it is
a behaviour change for anyone on e.g. 19045.
Verification
tsc --noEmitclean on bothtsconfig.jsonandtsconfig.node.json.vitest run: 2707 tests, 173 files.pipe-client-latencyandorchestration-status-vocabfail only under full parallel load (subprocesstiming budgets) and pass in isolation; neither touches the renderer.
horizontally, fill the top pane, close the bottom — before and after.
The latch is pure and unit-tested. The hook needs a DOM the node-env suite does
not have, so its wiring is pinned at the source level, the shape
resources-sync.test.tsuses: losing either half reintroduces the bug in full.🤖 Generated with Claude Code
https://claude.ai/code/session_014BjbqmJNha7mDaDbB2oSsy