#245

Terminal scrolls to empty space after clear when focused with deep scrollback

Openbuntecopened 11d ago

Summary

After focusing a terminal with deep scrollback, clear (ESC[3J ESC[H ESC[2J) leaves the terminal scrolled to empty space below the rows. Typing and new output keep it blank; the next focus (click) restores it.

Cause

The input textarea is absolutely positioned in scroll-content coordinates, but InputHandler.syncInputPosition() only moves it while composing or on touch devices. Otherwise it keeps the offset from the last focus() (positionTextarea).

  1. Focus with deep scrollback: the textarea is placed at the cursor, e.g. top: 6800px.
  2. ESC[3J drops the scrollback, but the stale textarea still sets scrollHeight.
  3. _doRender is stuck to the bottom, so _scrollToBottom() scrolls to the textarea. The !hasScrollback reset to scrollTop = 0 is in the else if branch and doesn't run.
  4. Every later render scrolls to the same bottom. Only focus() repositions the textarea.

Repro (@wterm/dom 0.5.4, @wterm/ghostty core, Chrome)

const el = document.createElement("div");
el.style.cssText = "position:fixed;inset:0 auto auto 0;width:800px;height:300px";
document.body.appendChild(el);
const term = new WTerm(el, { core: await GhosttyCore.load(), cols: 80, rows: 10 });
await term.init();
term.write(Array.from({ length: 300 }, (_, i) => `line ${i}`).join("\r\n"));
await new Promise(requestAnimationFrame);
term.focus();
term.write("\x1b[3J\x1b[H\x1b[2J$ ");
await new Promise(requestAnimationFrame);
await new Promise(requestAnimationFrame);
console.log(el.scrollTop); // ~6500; expected 0, "$ " is out of view

Possible fix

Position the textarea in syncInputPosition unconditionally (it already runs once per render), or before _scrollToBottom() reads scrollHeight. We work around it by replacing syncInputPosition with positionTextarea, which fixes it.

Activity

1
samohovets7d ago

Note: this was found by my AI agent while integrating wterm into Treeport. I don't know whether the fix it proposes is correct.

+1. Reproduced on @wterm/dom 0.5.5 in Chrome, using a custom libghostty-vt TerminalCore. Besides clear, it also happens:

  • When a TUI redraws with ESC[3J. Pi (a coding agent TUI) does this on every resize, so it hits whenever a terminal is refit, for example when switching to it from another device.
  • When entering the alternate screen (less, vim) from deep scrollback.
sequenceDiagram
    participant App as TUI / shell
    participant Core
    participant Scroller as .wterm (scroll container)
    participant TA as textarea (position: absolute)
    Note over TA: top ≈ 107,000px, set at last focus()
    App->>Core: ESC[3J ESC[2J, redraw
    Core->>Scroller: grid shrinks to 765px
    Note over Scroller: scrollHeight stays ≈ 107,000px (pinned by TA)
    Scroller->>Scroller: _scrollToBottom() sets scrollTop ≈ 106,400
    Note over Scroller: viewport shows empty space until next focus/keydown

Measured after clear with 100k lines of scrollback (800px viewport):

scrollTopscrollHeightgrid heighttextarea top
0.5.5106,423107,203765107,202
with workaround078076517

We work around it from outside with a ResizeObserver on .term-grid that moves the textarea back to the cursor row whenever the grid height changes. The fix suggested in this issue looks like the right one inside wterm:

 syncInputPosition(): void {
-  if (this.composing || this.touchPrimary) this.positionTextarea();
+  this.positionTextarea();
 }

One caveat: positionTextarea() calls getBoundingClientRect() twice, so this adds layout reads to every render. We haven't measured the cost.