Skip to content

fix(web): stop terminal scrollback from jumping to the live prompt - #6104

Open
fuggysense wants to merge 1 commit into
pingdotgg:mainfrom
fuggysense:fix/terminal-scrollback-jump
Open

fix(web): stop terminal scrollback from jumping to the live prompt#6104
fuggysense wants to merge 1 commit into
pingdotgg:mainfrom
fuggysense:fix/terminal-scrollback-jump

Conversation

@fuggysense

@fuggysense fuggysense commented Aug 11, 2026

Copy link
Copy Markdown

Summary

Fixes #6096 — desktop integrated terminal (Cmd+J / terminal.toggle) could force the viewport back to the live prompt while the user was reading scrollback.

What changed

  1. Scroll anchor capture/restore on the Ghostty surface (write, resetAndWrite, fit) so mutations that re-pin or rewrite the screen put the user back where they were when they had scrolled into history.
  2. Smarter buffer feed plan — head-trim of the client history ring is treated as an append of the new tail instead of resetAndWrite of the whole buffer (which always landed at the bottom).
  3. Tests — unit coverage for anchors + write plans; Ghostty WASM ABI regression that scroll + write/resize keep VIEWPORT_ACTIVE false.

Why this approach

Upstream Ghostty already keeps scroll place across pure VT writes/resizes when unpinned (covered by the ABI test). The jump came from the web layer replaying history via full resets and from fit/write paths not restoring a non-following viewport. Small, focused change — no product-scope expansion.

Test plan

  • vp run --filter @t3tools/client-runtime test (includes new write-plan cases)
  • vp test run --project unit src/terminal/ghostty/ (surface + runtimeAbi)
  • Manual: open terminal (mod+j), generate scrollback (seq 1 200), scroll up, keep a shell producing output / resize drawer — viewport should stay put until you return to the bottom
  • Manual: confirm following still works when staying at the bottom (new output remains visible)

Closes #6096


Note

Medium Risk
Touches terminal rendering and buffer sync paths used on every PTY update; behavior change is localized but user-visible if anchor restore is wrong.

Overview
Fixes #6096 — the integrated terminal could snap back to the live prompt while you were reading scrollback.

Ghostty surface now captures and restores scroll position around write, resetAndWrite, and fit when the viewport is not pinned to the bottom, including proportional restore after scrollback is trimmed.

Client buffer sync uses terminalBufferWritePlan so updates prefer terminal.write append (including after the history ring head-trim) instead of a full resetAndWrite, which was re-pinning the viewport.

Tests cover write-plan cases, scroll-anchor math, and a WASM ABI check that output and resize keep the viewport unpinned after scrolling into history.

Reviewed by Cursor Bugbot for commit ca060ae. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Fix terminal scrollback jumping to the live prompt on write, resize, or buffer replay

  • Introduces TerminalScrollAnchor in surface.ts to snapshot the user's scroll position before terminal operations and restore it after, preventing writes, full buffer replays, and resizes from re-pinning the viewport to the bottom.
  • Adds terminalBufferWritePlan in terminalSession.ts to decide between appending a suffix or doing a full buffer replace when the terminal buffer updates, reducing unnecessary full rewrites.
  • Updates ThreadTerminalDrawer to use the write plan instead of a startsWith-based comparison, so appends are correctly identified even after scrollback ring head-trims.
  • Behavioral Change: terminal writes and resizes no longer re-pin users who have scrolled into history; viewport re-pins to bottom only when the user was already following.
📊 Macroscope summarized ca060ae. 3 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

Preserve the user's place in integrated-terminal history when output,
reflow, or a history-ring head-trim would otherwise re-pin or rewrite the
Ghostty surface back to the bottom (fixes pingdotgg#6096).

- Capture/restore scroll anchors across write, resetAndWrite, and fit
- Prefer append plans after head-trim instead of full resetAndWrite
- Regression coverage for anchors, buffer write plans, and Ghostty ABI
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8b467999-40fd-4dd4-8b9e-b19b18ca3607

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 11, 2026
Comment on lines +146 to +162
if (previous.length === 0) {
return { kind: "replace", data: current };
}

// Head-trim of the history ring: longest suffix of `previous` that prefixes `current`.
// Only runs when the cheap startsWith path failed (near the byte cap), so a linear
// scan over the overlap is acceptable. Require a minimum overlap so unrelated
// rewrites (clear → new prompt) do not accidentally append on a short shared tail.
const maxOverlap = Math.min(previous.length, current.length);
const minOverlap = Math.min(16, maxOverlap);
for (let len = maxOverlap; len >= minOverlap; len -= 1) {
if (current.startsWith(previous.slice(previous.length - len))) {
return { kind: "append", data: current.slice(len) };
}
}

return { kind: "replace", data: current };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High state/terminalSession.ts:146

terminalBufferWritePlan misclassifies unrelated shorter replacement buffers as head-trim appends. When current equals a suffix of previous and is under 16 characters (including the empty string), it returns { kind: "append", data: "" } instead of { kind: "replace" }. This causes the terminal surface to retain stale content — for example, a cleared event that should erase all output leaves the old buffer visible because no write is applied.

The fallback loop at lines 156-160 compares the longest suffix of previous against the start of current down to a minimum overlap of Math.min(16, maxOverlap). When current is empty, both maxOverlap and minOverlap are 0, so current.startsWith(previous.slice(previous.length)) matches "" and the function returns append with empty data. The same happens for any short current that coincidentally equals a suffix of previous (e.g. a restart snapshot with just a prompt). Consider treating zero or negligible overlap as a replace rather than an append, and raise the minimum overlap floor so short coincidental matches do not suppress a full rewrite.

  if (previous.length === 0) {
    return { kind: "replace", data: current };
  }

  // Head-trim of the history ring: longest suffix of `previous` that prefixes `current`.
  // Only runs when the cheap startsWith path failed (near the byte cap), so a linear
  // scan over the overlap is acceptable. Require a minimum overlap so unrelated
  // rewrites (clear → new prompt) do not accidentally append on a short shared tail.
  const maxOverlap = Math.min(previous.length, current.length);
  const minOverlap = Math.min(16, maxOverlap);
+ if (current.length === 0) {
+   return { kind: "replace", data: current };
+ }
  for (let len = maxOverlap; len >= minOverlap; len -= 1) {
    if (current.startsWith(previous.slice(previous.length - len))) {
      return { kind: "append", data: current.slice(len) };
    }
  }

  return { kind: "replace", data: current };
Also found in 1 other location(s)

apps/web/src/components/ThreadTerminalDrawer.tsx:814

The new terminalBufferWritePlan path at line 814 can misclassify a short replacement buffer as a head-trim append. Its overlap threshold is Math.min(16, maxOverlap), so when current.length < 16 and current equals the suffix of previous (for example a reset/restart snapshot containing only the same short prompt), it returns append with empty data. The surface then retains all old terminal contents instead of applying the replacement snapshot, leaving stale output visible until a later unrelated rewrite.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/terminalSession.ts around lines 146-162:

`terminalBufferWritePlan` misclassifies unrelated shorter replacement buffers as head-trim appends. When `current` equals a suffix of `previous` and is under 16 characters (including the empty string), it returns `{ kind: "append", data: "" }` instead of `{ kind: "replace" }`. This causes the terminal surface to retain stale content — for example, a `cleared` event that should erase all output leaves the old buffer visible because no write is applied.

The fallback loop at lines 156-160 compares the longest suffix of `previous` against the start of `current` down to a minimum overlap of `Math.min(16, maxOverlap)`. When `current` is empty, both `maxOverlap` and `minOverlap` are 0, so `current.startsWith(previous.slice(previous.length))` matches `""` and the function returns `append` with empty data. The same happens for any short `current` that coincidentally equals a suffix of `previous` (e.g. a restart snapshot with just a prompt). Consider treating zero or negligible overlap as a `replace` rather than an `append`, and raise the minimum overlap floor so short coincidental matches do not suppress a full rewrite.

Also found in 1 other location(s):
- apps/web/src/components/ThreadTerminalDrawer.tsx:814 -- The new `terminalBufferWritePlan` path at line 814 can misclassify a short replacement buffer as a head-trim append. Its overlap threshold is `Math.min(16, maxOverlap)`, so when `current.length < 16` and `current` equals the suffix of `previous` (for example a reset/restart snapshot containing only the same short prompt), it returns `append` with empty `data`. The surface then retains all old terminal contents instead of applying the replacement snapshot, leaving stale output visible until a later unrelated rewrite.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

export function terminalScrollDeltaToRestore(

fit() restores the scroll position using the old absolute offset after a column-count change, so narrowing the terminal moves the viewport to earlier history instead of keeping the content the user was reading. When columns decrease, lines above the viewport wrap into more rows and total grows; terminalScrollDeltaToRestore then keeps anchor.offset unchanged (because total >= anchor.total and anchor.offset <= maxOffset), pointing the viewport at a row that is now further back in scrollback. Consider computing the restore target from the screen-row position of the current viewport rather than an absolute offset, so reflow keeps the same content in view.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/terminal/ghostty/surface.ts around line 244:

`fit()` restores the scroll position using the old absolute offset after a column-count change, so narrowing the terminal moves the viewport to earlier history instead of keeping the content the user was reading. When columns decrease, lines above the viewport wrap into more rows and `total` grows; `terminalScrollDeltaToRestore` then keeps `anchor.offset` unchanged (because `total >= anchor.total` and `anchor.offset <= maxOffset`), pointing the viewport at a row that is now further back in scrollback. Consider computing the restore target from the screen-row position of the current viewport rather than an absolute offset, so reflow keeps the same content in view.


let target: number;
const priorMax = Math.max(0, anchor.total - anchor.len);
if (priorMax > 0 && (total < anchor.total || anchor.offset > maxOffset)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium ghostty/surface.ts:256

terminalScrollDeltaToRestore misinterprets a viewport enlargement as scrollback trimming. When a resize increases len so that the old anchor.offset exceeds the new maxOffset, the function proportionally rescales the offset instead of clamping the original absolute offset. For {total: 100, len: 20, offset: 60} becoming {total: 100, len: 60, offset: 40}, it targets offset 30 (scrolling up 10 rows) when offset 40 is the closest valid position to the original anchor — so enlarging the terminal pushes users reading near the bottom farther into history. The proportional branch should only run when the buffer itself shrank (total < anchor.total), not merely because len grew.

Suggested change
if (priorMax > 0 && (total < anchor.total || anchor.offset > maxOffset)) {
if (priorMax > 0 && total < anchor.total) {
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/terminal/ghostty/surface.ts around line 256:

`terminalScrollDeltaToRestore` misinterprets a viewport enlargement as scrollback trimming. When a resize increases `len` so that the old `anchor.offset` exceeds the new `maxOffset`, the function proportionally rescales the offset instead of clamping the original absolute offset. For `{total: 100, len: 20, offset: 60}` becoming `{total: 100, len: 60, offset: 40}`, it targets offset 30 (scrolling up 10 rows) when offset 40 is the closest valid position to the original anchor — so enlarging the terminal pushes users reading near the bottom farther into history. The proportional branch should only run when the buffer itself shrank (`total < anchor.total`), not merely because `len` grew.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ca060ae. Configure here.

for (let len = maxOverlap; len >= minOverlap; len -= 1) {
if (current.startsWith(previous.slice(previous.length - len))) {
return { kind: "append", data: current.slice(len) };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear treated as empty append

High Severity

terminalBufferWritePlan sets minOverlap to Math.min(16, maxOverlap), so when current is empty (the cleared attach event) or shorter than 16 code units, overlap length 0 or other short suffix matches are accepted and the plan becomes append with empty data. The drawer then skips the write, so Ghostty never gets resetAndWrite and the screen stays on stale scrollback instead of clearing or replacing.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ca060ae. Configure here.

@macroscopeapp

macroscopeapp Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

3 blocking correctness issues found. This bug fix introduces new scroll anchor capture/restore logic with 246 additions. Multiple HIGH severity findings identify that terminalBufferWritePlan misclassifies empty or short replacement buffers as appends, which would leave stale terminal content visible on clear events. These edge cases need addressing before merge.

You can customize Macroscope's approvability policy. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Desktop terminal (Cmd+J) forces viewport to bottom — cannot scroll scrollback without jump

1 participant