Skip to content

Shrinking the terminal discards content on the cursor's line: rows are truncated before reflow is skipped #6130

Description

@htcom-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 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 exitsprintf '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:

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions