Skip to content

fix(terminal): preserve scroll intent across keyboard resize, surface history truncation - #286

Merged
Ark0N merged 5 commits into
masterfrom
fix/terminal-history-scroll
Aug 13, 2026
Merged

fix(terminal): preserve scroll intent across keyboard resize, surface history truncation#286
Ark0N merged 5 commits into
masterfrom
fix/terminal-history-scroll

Conversation

@Ark0N

Copy link
Copy Markdown
Owner

Closes#259, closes#258. Both reported by @DodgyBadger.

Both issues bottom out in the same gap: nothing tracked whether the user was following live output or reading history. Fixing that once makes both cheap, which is why this is one PR rather than two touching the same functions.

#259 mobile scroll position

Three places dragged a reader to the bottom. The keyboard was the reported one; the other two turned up while fixing it.

Keyboard resize.onKeyboardShow/onKeyboardHide passed scrollToBottom: true and the settle timer applied it without checking where the user was. The settle cycle now captures intent on its first event, before fit() reflows anything, and returns to that anchor when the user was reading. Capturing later would read an already-moved viewportY, so there is a test pinning that ordering specifically. The param is renamed restoreScroll to stop the name lying.

Live writes.flushPendingWrites gated viewport preservation on _hasRecentUserScrollUp(), a 1500ms decay window, so a user who scrolled up and then actually read for longer than that lost the protection mid-read and got dragged along by the next repaint. Being scrolled up is the intent however long ago it was expressed, so it now keys off position. The recency window stays where it still earns its place, as a race guard on the sticky scroll-to-bottom.

Backpressure refresh._onSessionNeedsRefresh is server-triggered, so there is no gesture to blame the jump on. It now holds the reader's place. Because the rewrite replaces the buffer, an absolute viewportY is meaningless across it; distance from the bottom is the anchor that survives.

The full-history repull already held the user's place and is unchanged.

Bonus: the refresh was destroying scrollback

Found while verifying the above, and arguably the most serious thing here.

_onSessionNeedsRefresh rebuilt the terminal from a 1MB tail. Measured end to end on a 900-line shell pane: an 869-row buffer came back as 158 rows. The refresh that exists to repair the display was silently discarding most of the scrollback every time SSE backpressure cleared.

It now requests full history, falling back to the tail only when _replayWouldShrinkBuffer refuses the capture, which leaves repaint-mode panes (tmux holds roughly one frame for them) exactly as they were.

#258 history truncation

Truncation was reported by a grey line written into the terminal, so it scrolled away with the output it described, could not be acted on, and read identically whether the rest was one click away or gone forever. Underneath, the route set one truncated boolean at two sites meaning opposite things, and the client discarded fullSize and source entirely.

The route now reports truncationReason (tail = intentional partial replay, the rest is retained; capped = the byte ceiling dropped it) plus retainedBytes, and capped is not downgraded by a later tail cut. The client renders a dismissible banner outside terminal output with three honest states: recoverable (offers Load full history), at-ceiling, and exhausted.

The Load button forces past the scroll cooldown but not past _replayWouldShrinkBuffer, so it still cannot destroy history on a repaint-mode pane.

The banner is an overlay, not a flex child: FitAddon derives rows/cols from the terminal parent's computed height, so occupying real layout space would SIGWINCH the CLI on every truncation-state change.

Verification

Real browser against a live session on an isolated instance:

  • baseY 869 -> 869 across a refresh, where it was 869 -> 158 before
  • a reader 200 lines up stays 200 lines up; a follower stays pinned to the bottom
  • terminal height byte-identical with the banner shown, confirming no PTY resize
  • banner text and button clear 4.5:1 contrast on all 7 skins (worst case 4.97:1)

Two things unit tests could not have caught:

  • The first CSS cut used --bg-elevated and --accent-muted, which do not exist, so the fallbacks painted a hardcoded dark bar and the four light skins would have rendered dark text on it. It now uses only tokens every skin redefines.
  • One skin sweep was vacuous (every row came back daylight-blue because the localStorage seed was not taking). Driving data-skin directly produced 5 distinct backgrounds and a real result.

npm run test:ci: 5061 passed. One pre-existing static guard in terminal-scroll-routing.test.ts anchored on the empty _maybeRefetchFullHistory() signature; its invariant still holds, so the anchor is now arity-independent rather than weakened.

Note for reviewers

test/mobile/keyboard.test.ts coupled to the renamed param at two call sites. That suite is excluded from test:ci, so this would have gone red behind two green checks, the same way #279/#280 landed broken. It is updated here, and the new regression tests live outsidetest/mobile/ so CI can actually see them.

The banner currently shows on phones as well as desktop, since #258 asks for an accessible persistent indicator and phones suffer truncation most. Easy to gate if that is not wanted.

No changeset, on the assumption this gets folded into a COM.

… history truncation
Closes#259, closes#258. Both bottom out in the same gap: nothing tracked
whether the user was following live output or reading history.
#259 — the keyboard path forced the terminal to the bottom unconditionally
(onKeyboardShow/onKeyboardHide passed scrollToBottom:true, applied with no
check), so opening the keyboard while scrolled up yanked the user down. The
settle cycle now captures intent on its FIRST event, before any fit() has
reflowed the buffer, and returns to that anchor when the user was reading.
A later capture would read an already-moved viewportY, which is why the
capture point matters. The param is renamed restoreScroll to match.
Separately, flushPendingWrites gated viewport preservation on
_hasRecentUserScrollUp(), a 1500ms decay window, so a user who scrolled up and
then actually READ for longer lost protection mid-read. Being scrolled up IS
the intent however long ago it was expressed, so it now keys off position.
The recency window stays as a race guard on the sticky scroll-to-bottom.
The full-history repull already held the user's place and is unchanged.
#258 — truncation was reported by a grey line written INTO the terminal
("earlier output truncated"), which scrolls away with the output it describes,
cannot be acted on, and said the same thing whether the rest was one click away
or gone forever. The server set one `truncated` boolean at two sites meaning
opposite things, and the client discarded fullSize and source entirely.
The route now reports truncationReason ('tail' = intentional partial replay,
the rest is retained; 'capped' = the byte ceiling dropped it) plus
retainedBytes, and 'capped' is not downgraded by a later tail cut. The client
renders a dismissible banner outside terminal output with three honest states:
recoverable (offers Load full history), at-ceiling, and exhausted. The Load
button forces past the scroll cooldown but NOT past _replayWouldShrinkBuffer,
which still refuses a downgrade for repaint-mode panes.
The banner is an overlay, not a flex child: FitAddon derives rows/cols from the
terminal parent's computed height, so occupying real layout space would SIGWINCH
the CLI on every truncation-state change.
Verified in a real browser on the 7 skins: banner text and button clear 4.5:1
contrast on all of them, and terminal height is byte-identical with the banner
shown. The first cut used --bg-elevated and --accent-muted, which do not exist,
so light skins rendered a hardcoded dark bar under dark text; it now uses only
tokens every skin redefines.
test/terminal-scroll-intent.test.ts lives outside test/mobile/ deliberately —
that suite is excluded from test:ci, so a guard placed there is invisible to CI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e buffer
Two further instances of the same root cause, both in _onSessionNeedsRefresh,
which is SERVER-triggered (it fires after SSE backpressure clears) so the user
has no gesture to blame the result on.
1. It ended in an unconditional scrollToBottom, so a user quietly reading
scrollback was dropped to the live output by a background event. It now
holds their place. The rewrite REPLACES the buffer, so an absolute viewportY
captured beforehand is meaningless afterwards; distance from the bottom is
the anchor that survives, via computeRewriteScrollLine().
2. It rebuilt the terminal from a 1MB TAIL. Measured end to end on a 900-line
shell pane: an 869-row buffer came back as 158 rows, so the refresh meant to
REPAIR the display was destroying most of the scrollback every time it ran.
It now asks for full history, and falls back to the tail only when
_replayWouldShrinkBuffer refuses the capture, which keeps repaint-mode panes
(tmux holds roughly one frame for them) exactly as they were.
Also records truncation state here, so the #258 banner stops describing the
pre-refresh buffer.
Verified in a real browser against a live session: baseY 869 -> 869 where it
used to be 869 -> 158, a reader 200 lines up stays 200 lines up, and a follower
stays pinned to the bottom.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The refresh can now issue two fetches (full history, then the tail as a
downgrade fallback), which widens an existing window where the user switches
tabs mid-flight and this session's history gets painted into the terminal they
are now looking at. Guard it the way _maybeRefetchFullHistory already does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…history
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Ark0N
Ark0N merged commit 2d2a455 into masterAug 13, 2026
2 checks passed
@Ark0N
Ark0N deleted the fix/terminal-history-scroll branch August 13, 2026 23:16
Sign up for freeto 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.

fix(mobile): preserve terminal scroll position across keyboard resize and replay fix(terminal): make history truncation visible and recoverable

2 participants

@Ark0N@claude