fix(terminal): preserve scroll intent across keyboard resize, surface 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>
This commit is contained in:
Codeman maintainer
2026-08-14 00:00:59 +02:00
parent 4bbe2b7ff6
commit 9a0e665f72
12 changed files with 699 additions and 22 deletions
+55 -9
View File
@@ -214,8 +214,13 @@ const KeyboardHandler = {
keyboardVisible: false,
initialViewportHeight: 0,
_viewportSettleTimer: null,
_settleScrollToBottom: false,
_settleRestoreScroll: false,
_settlePending: false,
// Scroll intent captured at the start of a settle cycle (#259). `true` =
// following live output, `false` = reading history and _settleAnchorY holds
// the top visible line to return to.
_settleFollowing: true,
_settleAnchorY: null,
/** Initialize keyboard handling */
init() {
@@ -284,8 +289,10 @@ const KeyboardHandler = {
clearTimeout(this._viewportSettleTimer);
this._viewportSettleTimer = null;
}
this._settleScrollToBottom = false;
this._settleRestoreScroll = false;
this._settlePending = false;
this._settleFollowing = true;
this._settleAnchorY = null;
},
/** Handle viewport resize (keyboard show/hide) */
@@ -427,7 +434,7 @@ const KeyboardHandler = {
// visualViewport emits multiple heights throughout the OS animation.
// Re-schedule on every event and fit only after the final height settles.
this._scheduleViewportSettle({ scrollToBottom: true });
this._scheduleViewportSettle({ restoreScroll: true });
// Reposition subagent windows to stack from bottom (above keyboard)
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
@@ -442,7 +449,7 @@ const KeyboardHandler = {
this.resetLayout();
this._scheduleViewportSettle({ scrollToBottom: true });
this._scheduleViewportSettle({ restoreScroll: true });
// Reposition subagent windows to stack from top (below header)
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
@@ -459,12 +466,46 @@ const KeyboardHandler = {
* fit against it resizes the PTY to transient dims and the SIGWINCH thrash
* garbles the transcript.
*/
_scheduleViewportSettle({ scrollToBottom = false } = {}) {
this._settleScrollToBottom = this._settleScrollToBottom || scrollToBottom;
_scheduleViewportSettle({ restoreScroll = false } = {}) {
// Capture scroll intent on the FIRST event of a settle cycle, BEFORE any
// fit() has reflowed the buffer — a later capture reads an already-moved
// viewportY. Issue #259: this path used to force scrollToBottom
// unconditionally, so opening the keyboard yanked a user who was reading
// history down to the live output.
if (!this._settlePending) this._captureTerminalScrollIntent();
this._settleRestoreScroll = this._settleRestoreScroll || restoreScroll;
this._settlePending = true;
this._armViewportSettleTimer();
},
/**
* Record whether the terminal is following live output, and if not, the top
* visible line to return to. `_settleFollowing` defaults to true so a
* terminal we cannot read keeps the historical scroll-to-bottom behavior.
*/
_captureTerminalScrollIntent() {
this._settleFollowing = true;
this._settleAnchorY = null;
if (typeof app === 'undefined' || !app.terminal?.buffer?.active) return;
this._settleFollowing = app.isTerminalAtBottom();
if (!this._settleFollowing) this._settleAnchorY = app.terminal.buffer.active.viewportY;
},
/**
* Return to the captured anchor after the keyboard reflow. Reflow can rewrap
* lines, so the anchor is approximate by construction; it is clamped to the
* post-reflow buffer rather than trusted blindly.
*/
_restoreTerminalScrollIntent() {
const term = typeof app !== 'undefined' ? app.terminal : null;
const anchor = this._settleAnchorY;
if (typeof anchor !== 'number' || typeof term?.scrollToLine !== 'function' || !term.buffer?.active) {
term?.scrollToBottom?.();
return;
}
term.scrollToLine(Math.max(0, Math.min(anchor, term.buffer.active.baseY)));
},
/** Push a pending settle back while the viewport is still animating; no-op otherwise. */
_deferViewportSettle() {
if (!this._settlePending) return;
@@ -476,8 +517,8 @@ const KeyboardHandler = {
this._viewportSettleTimer = setTimeout(() => {
this._viewportSettleTimer = null;
this._settlePending = false;
const shouldScrollToBottom = this._settleScrollToBottom;
this._settleScrollToBottom = false;
const shouldRestoreScroll = this._settleRestoreScroll;
this._settleRestoreScroll = false;
if (typeof app !== 'undefined' && app.terminal) {
if (app.fitAddon) {
@@ -486,7 +527,12 @@ const KeyboardHandler = {
} catch {}
}
if (this.keyboardVisible) this._shrinkPaddingToFit();
if (shouldScrollToBottom) app.terminal.scrollToBottom();
// Following live output → bottom, as before. Reading history → back to
// the pre-reflow anchor instead of being yanked down (#259).
if (shouldRestoreScroll) {
if (this._settleFollowing === false) this._restoreTerminalScrollIntent();
else app.terminal.scrollToBottom();
}
app._syncMobileHelperTextareaToCursor?.();
app._localEchoOverlay?.rerender?.();
this._sendTerminalResize();