8000 fix(terminal): keep xterm and ConPTY agreeing about the cursor row by derekneely · Pull Request #216 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content

fix(terminal): keep xterm and ConPTY agreeing about the cursor row - #216

Merged
amirlehmam merged 1 commit into
amirlehmam:masterfrom
derekneely:pr/conpty-row-growth
Sep 4, 2026
Merged

amirlehmam merged 1 commit into
amirlehmam:masterfrom
derekneely:pr/conpty-row-growth

Conversation

@derekneely
Copy link
Copy Markdown
Contributor

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. clear looks 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 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:

ESC[8;40;100t ESC[H line 42…ESC[K CRLF …40 rows… ESC[20;35H

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 in
whitespace" heuristic — correct for those older ConPTYs, and what every other
xterm-based Windows terminal ships. An unparseable os.release() deliberately
yields a block with no build number rather than a guessed one: backend
alone 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: 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, and this is the part worth
writing down rather than rediscovering. 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 to re-fit first.

That is not theoretical. Instrumenting a real pane close caught it:

snapshot-taken  rows=28 len=249 baseY=221 cursorY=27 cursorAbs=248
replay-resized  r
8000
ows=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.

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.

ReplayHold is that latch. It is 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.

Scope and compatibility

  • No behaviour change on a pane that does not grow, and none for the shrink path.
  • 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. Same shape as the existing homeDir.
  • One 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 before merging.
  • Windows 10 builds below 21376 now get reflow disabled and the whitespace
    wrapping heuristic. That is what xterm intends for those ConPTYs, and it is
    a behaviour change for anyone on e.g. 19045.

Verification

  • tsc --noEmit clean on both tsconfig.json and tsconfig.node.json.
  • vitest run: 2707 tests, 173 files. pipe-client-latency and
    orchestration-status-vocab fail only under full parallel load (subprocess
    timing budgets) and pass in isolation; neither touches the renderer.
  • Verified live on Windows 11 26200 by reproducing the original case — split
    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.ts uses: losing either half reintroduces the bug in full.

🤖 Generated with Claude Code

https://claude.ai/code/session_014BjbqmJNha7mDaDbB2oSsy

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
amirlehmam merged commit 673ea26 into amirlehmam:master Sep 4, 2026
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>
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.

2 participants

0