Shrinking the terminal permanently discards buffer content on the line the cursor is on. Every other line survives a narrowing because reflow re-wraps it; the cursor's line does not, and growing the terminal back does not bring the characters back — they are gone from the buffer.
This is not the cursor position problem from #5295, and not the stale isWrapped flag from #3482. It is content loss, and it reproduces with no DOM, no pty and no shell involved.
Details
- Browser and browser version: none — reproduced in Node 26 with
@xterm/headless (also visible in Electron 43.2.0 / Chromium 150.0.7871.129)
- OS version: macOS 26.6.2, darwin arm64 (the code path is platform-independent)
- xterm.js version: 6.0.0 (
latest) and 6.1.0-beta.301 (beta) — identical results on both
Steps to reproduce
npm i @xterm/headless && node repro.cjs
// repro.cjs
const { Terminal } = require('@xterm/headless')
// allowProposedApi is required by 6.0.0 to read term.buffer; harmless on 6.1 betas.
const term = new Terminal({ cols: 90, rows: 24, scrollback: 1000, allowProposedApi: true })
// How much of the payload the buffer still holds, counted row by row.
const stored = () => {
const buffer = term.buffer.active
let total = 0
for (let y = 0; y < buffer.length; y++) {
const line = buffer.getLine(y)
if (line) total += (line.translateToString(false).match(/x/g) || []).length
}
return total
}
// 109 columns of text with no trailing newline, so the cursor stays on this line.
const written = new Promise((resolve) => term.write('x'.repeat(109), resolve))
// Resize *after* the write has been parsed, and outside the write callback:
// calling resize() from inside that callback re-enters the parser and the
// payload is written a second time.
written.then(() => {
console.log('written, at 90 cols:', stored())
term.resize(45, 24)
console.log('shrunk to 45 cols: ', stored())
term.resize(90, 24)
console.log('back at 90 cols: ', stored(), ' <- expected 109')
})
Output, on both versions:
written, at 90 cols: 109
shrunk to 45 cols: 64
back at 90 cols: 64 <- expected 109
45 of the 109 characters are gone.
What the rows do
Counting the payload characters per physical row makes the mechanism visible:
|
rows |
total |
| at 90 columns |
90 + 19 |
109 |
shrunk to 45, reflowCursorLine off (default) |
45 + 19 |
64 |
shrunk to 45, reflowCursorLine on |
45 + 45 + 19 |
109 |
shrunk to 45, same text above the cursor, reflowCursorLine off |
45 + 45 + 19 |
109 |
The first row is cut from 90 cells to 45 in place; the second row (19, already under the new width) survives. The last two rows of the table are the controls: the reflow machinery does the right thing both when it is allowed to touch the cursor's line and when the text is not on it.
Why
Buffer.resize() truncates first and reflows second:
-
Buffer.ts#L252-L257 — when this._cols > newCols, every BufferLine is resized down to newCols, which drops the cells past the new width.
-
_reflowSmaller, Buffer.ts#L372-L378 — the wrapped group containing the cursor is then skipped:
if (!reflowCursorLine) {
// If these lines contain the cursor don't touch them, the program will handle fixing up
// wrapped lines with the cursor
For every other line, the redistribution into new rows restores what the truncation removed. For the cursor's group the redistribution never runs, so the truncation is the final state.
That is the part worth separating from the existing discussion: skipping the re-wrap is a deliberate contract, but the content is destroyed before the skip is reached, so the skip is not neutral. #5810 (closed, unmerged) touched this same branch and said explicitly that "content on the cursor line is deliberately left alone" — that is true of the flag it was fixing, but on the shrink path the content has already been cut.
Where "the program will handle fixing up" does not hold
The contract assumes something will redraw that line. Cases where nothing will:
- A program that writes a long line without a trailing newline and exits —
printf 'x%.0s' {1..200} in a shell, then resize. Nothing owns that line any more.
- An embedder that writes into the terminal itself (logs, a REPL transcript, a diff viewer) rather than proxying a pty.
- The repro above, where there is no program at all.
A shell does redraw its own prompt line, which is why this is easy to miss interactively — but that redraw is what hides the loss, not what repairs it.
Prior art I could find
Possible directions
I have no attachment to any of these, and the second may be the only one consistent with the existing contract:
- Skip the truncation for the cursor's group as well when reflow is going to skip it, so the cells survive off-screen and reappear when the terminal is widened.
- Erase the cursor's logical line rather than silently keeping a truncated copy of it — the leftover is currently indistinguishable from real content to anything reading the buffer.
- Make
reflowCursorLine default to on for embedders that are not proxying a pty. Not obviously right, since the double-draw with a shell is the reason it defaults off.
Happy to test a patch against the repro, and to open a PR for whichever direction you prefer.
Found while building a terminal app on xterm.js 6 + node-pty; the measurements above were taken with the harness in the repro rather than through the app, so nothing in them depends on our code.
Shrinking the terminal permanently discards buffer content on the line the cursor is on. Every other line survives a narrowing because reflow re-wraps it; the cursor's line does not, and growing the terminal back does not bring the characters back — they are gone from the buffer.
This is not the cursor position problem from #5295, and not the stale
isWrappedflag from #3482. It is content loss, and it reproduces with no DOM, no pty and no shell involved.Details
@xterm/headless(also visible in Electron 43.2.0 / Chromium 150.0.7871.129)latest) and 6.1.0-beta.301 (beta) — identical results on bothSteps to reproduce
Output, on both versions:
45 of the 109 characters are gone.
What the rows do
Counting the payload characters per physical row makes the mechanism visible:
90 + 19reflowCursorLineoff (default)45 + 19reflowCursorLineon45 + 45 + 19reflowCursorLineoff45 + 45 + 19The first row is cut from 90 cells to 45 in place; the second row (19, already under the new width) survives. The last two rows of the table are the controls: the reflow machinery does the right thing both when it is allowed to touch the cursor's line and when the text is not on it.
Why
Buffer.resize()truncates first and reflows second:Buffer.ts#L252-L257— whenthis._cols > newCols, everyBufferLineis resized down tonewCols, which drops the cells past the new width._reflowSmaller,Buffer.ts#L372-L378— the wrapped group containing the cursor is then skipped:For every other line, the redistribution into new rows restores what the truncation removed. For the cursor's group the redistribution never runs, so the truncation is the final state.
That is the part worth separating from the existing discussion: skipping the re-wrap is a deliberate contract, but the content is destroyed before the skip is reached, so the skip is not neutral. #5810 (closed, unmerged) touched this same branch and said explicitly that "content on the cursor line is deliberately left alone" — that is true of the flag it was fixing, but on the shrink path the content has already been cut.
Where "the program will handle fixing up" does not hold
The contract assumes something will redraw that line. Cases where nothing will:
printf 'x%.0s' {1..200}in a shell, then resize. Nothing owns that line any more.A shell does redraw its own prompt line, which is why this is easy to miss interactively — but that redraw is what hides the loss, not what repairs it.
Prior art I could find
reflowCursorLineoption, which does avoid the loss. It defaults to off, and turning it on has its own cost with a real shell (the shell redraws the line as well, so both copies end up complete) plus the caveat documented in Note in API that reflowCursorLine will not move the cursor #5522.isWrappedflag on the grow path — adjacent code, different symptom.Possible directions
I have no attachment to any of these, and the second may be the only one consistent with the existing contract:
reflowCursorLinedefault to on for embedders that are not proxying a pty. Not obviously right, since the double-draw with a shell is the reason it defaults off.Happy to test a patch against the repro, and to open a PR for whichever direction you prefer.
Found while building a terminal app on xterm.js 6 + node-pty; the measurements above were taken with the harness in the repro rather than through the app, so nothing in them depends on our code.