Merge pull request #681: fix(terminal): load tmux history instead of paging Claude after a hollow tab switch

This commit is contained in:
Codeman maintainer committed 2026-10-24 15:19:16 +02:00
commit d38044e2ac
9 files changed
+312 -18

No files matched your search

+43 -9
View File
@@ -3384,11 +3384,15 @@ class CodemanApp {
headersReceivedAt = capture.headersAt;
data = capture.json?.data ?? {};
}
this._notePaneHistory?.(sessionId, data);
// Bail on a tab switch mid-fetch: writing here would paint this session's
// history into the terminal the user is now looking at. The window is two
// fetches wide in the fallback case, so this guard is not optional.
// fetches wide in the fallback case, so this guard is not optional. The
// pane count is recorded only past it: a refresh that lost its owner is
// older than whatever the switch back read (it would have overwritten a
// fresh 0 with a stale count and held the paging fallback off a hollow
// pane), and an error envelope is not a capture at all.
if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return false;
if (capture.json?.success !== false) this._notePaneHistory?.(sessionId, data);
if (data.terminalBuffer) {
// This refresh is SERVER-triggered, so a user quietly reading scrollback
// did not ask for it and must not be dragged to the bottom by it (#259).
@@ -9300,10 +9304,23 @@ class CodemanApp {
// `force` is the user pressing "Load full history" (#258): they asked once,
// explicitly, so the scroll-gesture cooldown does not apply. The downgrade
// guard below still does — a forced pull must not destroy history either.
const cooldown = this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
// A refused pull latches 60 s because the browser then held MORE than tmux
// (the downgrade guard below). A tab switch that replayed a hollow tail has
// voided that premise: one screen in the browser while the pane holds
// scrollback, the state `_maybePageCliTranscript` hands to this pull instead
// of paging the CLI. Hold such a buffer to the normal cooldown only, or the
// gesture is dead for up to a minute.
const unloaded =
(this._paneHistoryLines?.get(sessionId) ?? 0) > 0 && (this.terminal?.buffer?.active?.baseY ?? 0) === 0;
const cooldown = !unloaded && this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
if (!force && now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return;
this._fullHistoryRepullAt.set(sessionId, now);
this._fullHistoryRepullInFlight = true;
// The session-id check below cannot see a tab switch that came BACK during
// the fetch (A -> B -> A): the id matches again while `selectSession` is
// replaying its own capture, and the reset below would land in the middle
// of that write. The select generation moves on every switch, so it can.
const selectGen = this._selectGeneration;
try {
const requestStartedAt = performance.now();
const capture = await this._fetchTerminalCapture(
@@ -9315,7 +9332,13 @@ class CodemanApp {
const headersReceivedAt = capture.headersAt;
const payload = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, payload);
// A completion that outlived a tab switch is older than whatever that
// switch recorded about the pane (a switch back re-reads it): keep its
// count out of the memory, or it can overwrite a fresh count with a stale one.
// An error envelope (a JSON 5xx, `success: false`) is not a capture:
// recording its empty data would forget the count and hand the next
// gesture back to the paging fallback, the bug this pull exists to avoid.
if (selectGen === this._selectGeneration && capture.json?.success !== false) this._notePaneHistory?.(sessionId, payload);
const buffer = payload.terminalBuffer;
const timing = {
trigger: force ? 'full-history-button' : 'full-history-scroll',
@@ -9331,8 +9354,9 @@ class CodemanApp {
refused: false,
};
// Bail on a tab switch mid-fetch: writing here would paint another session's
// history into the terminal the user is now looking at.
if (!buffer || this.activeSessionId !== sessionId) return;
// history into the terminal the user is now looking at, or this session's
// over the load a switch back has started.
if (!buffer || this.activeSessionId !== sessionId || selectGen !== this._selectGeneration) return;
const windowRows = this._estimateReplayRows(buffer, this.terminal.cols);
// A bounded window no longer than the browser's buffer buys nothing, and
// resetting to rewrite it would jump the viewport on every scroll that
@@ -9404,7 +9428,7 @@ class CodemanApp {
this._bufferLoadFinishOpts(payload, headersReceivedAt)
);
timing.resetAndParseMs = parsedAt - replayStartedAt;
if (!completed || this.activeSessionId !== sessionId) return;
if (!completed || this.activeSessionId !== sessionId || selectGen !== this._selectGeneration) return;
// Keep shell tab restores bounded too. A user-triggered full-history pull
// may be tens of MB; caching it would replay that whole payload again on
// the next tab switch before the normal 1MB tail fetch replaces it.
@@ -9426,6 +9450,10 @@ class CodemanApp {
// reached only from a scroll-up gesture, so being dragged down is the
// exact opposite of what the user asked for.
this._syncStickyScrollBaseline();
// The prompt row moved with the rewrite: re-position the local-echo
// overlay there, as the refresh path does (a phone swipe reaches this
// pull with unsent text on the overlay).
this._localEchoOverlay?.rerender();
timing.totalMs = performance.now() - requestStartedAt;
this._recordTerminalLoadTiming(timing);
} catch {
@@ -9473,7 +9501,10 @@ class CodemanApp {
/**
* Remember how much scrollback tmux holds for a session's pane, from any
* terminal response. Called whether or not the response was written, since
* it describes the PANE, not the payload. A response without the field (a
* it describes the PANE, not the payload. Three responses are skipped, since
* none is the newest word on the pane: an error envelope (`success: false`)
* on any path, a refresh that lost its owner, and a history pull whose select
* generation moved during the fetch. A response without the field (a
* byte-history fallback, an older server) forgets it, so the next tab switch
* goes back to the bounded tail: unknown never counts as empty.
*/
@@ -10005,7 +10036,10 @@ class CodemanApp {
}
const data = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, data);
// An error envelope (a JSON 5xx: _fetchTerminalCapture does not check
// `res.ok`) is not a capture: recording its empty data would forget the
// count the pane last reported, as in the refresh and the history pull.
if (capture.json?.success !== false) this._notePaneHistory?.(sessionId, data);
// How this load must end, decided here because `chunkedTerminalWrite` is
// what actually ends it for a non-empty buffer. A tmux pane capture is a
// point-in-time frame, so nothing that reached the browser after the
+4
View File
@@ -1176,6 +1176,10 @@
// names no CLI. The gesture rules (first-event page, notch accumulation,
// pinch and sideways-swipe consume) are the shared pure
// CodemanTerminalInput.pageKeysForGesture, run on this tile's own state.
// The primary pane's unloaded-scrollback fall-through (`paneHistoryLines`
// > 0 with baseY 0, after a restart's tail replay) has no twin here: a tile
// loads tmux's rendered history (`full=1`), so it does not share that
// tail-select failure, and its non-shell wheel has no pull to fall through to.
// Returns true when the wheel was consumed here.
_maybePageCliTranscript(ev) {
if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false;
+29 -5
View File
@@ -5883,7 +5883,8 @@ Object.assign(CodemanApp.prototype, {
// a fix. Scoping the setting away from those modes would be the other
// option, but it would override an explicit user choice; instead the caller
// falls through to _maybePageCliTranscript, so the gesture still pages the
// CLI's transcript and the setting keeps meaning exactly what it says.
// CLI's transcript (or loads unloaded tmux scrollback, see there) and the
// setting keeps meaning exactly what it says.
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
const mode = (target.terminal || this.terminal)?.modes?.mouseTrackingMode;
if (mode && mode !== 'none') return false;
@@ -6038,8 +6039,11 @@ Object.assign(CodemanApp.prototype, {
*
* Guarded by `_localScrollbackIsHollow()` (the `transcriptPageKeys` capability +
* `baseY === 0`) plus a false forwarding gate, so a session with real local
* scrollback is never touched. Shift is excluded on purpose: it is the explicit
* "give me local scrollback" gesture and must keep that meaning.
* scrollback is never touched, and neither is one whose scrollback tmux still
* holds but the last replay did not carry (`paneHistoryLines` > 0): that one
* falls through to the local path, whose scroll-to-top pull loads it. Shift is
* excluded on purpose: it is the explicit "give me local scrollback" gesture
* and must keep that meaning.
*
* The FIRST event of a trackpad-sized gesture (opening under
* PAGE_KEY_IMMEDIATE_MAX_ROWS) pages at once. Waiting for half a screen of
@@ -6083,6 +6087,22 @@ Object.assign(CodemanApp.prototype, {
this._logScrollRouting('page-keys-dialog-open');
return true;
}
// A hollow buffer whose PANE still holds tmux scrollback is unloaded, not
// hollow: after a server restart a tab switch replays a byte tail that is
// only the attach repaint (baseY 0), and paging from there moved an inline
// Claude's composer cursor instead of its transcript. The remembered
// `paneHistoryLines` (tmux `#{history_size}`) says a `full=1` pull can add
// it, so fall through to local scrolling, whose top-of-buffer pull fetches
// it (_maybeLoadMoreHistoryOnScroll -> _maybeRefetchFullHistory). 0 and
// unknown keep paging. Page keys already queued belong to the abandoned
// route and go with the gesture debt. Not logged as a decision of its own
// (it would alternate with the caller's line every frame until the pull
// lands): the caller's `local-scrollback` line carries `paneHistoryLines`.
if ((this._paneHistoryLines?.get(this.activeSessionId) ?? 0) > 0) {
this._pageKeyGesture = null;
this._wheelSgrQueue = '';
return false;
}
// Leftover travel and the gesture in progress belong to the tab they were made on.
if (this._pageKeySession !== this.activeSessionId) {
this._pageKeySession = this.activeSessionId;
@@ -6124,16 +6144,20 @@ Object.assign(CodemanApp.prototype, {
// the DECSETs), so the CLI's real tracking state is reported separately.
const cliTracking = session?.cliMouseTracking === true;
const baseY = this.terminal?.buffer?.active?.baseY ?? -1;
// What tmux still holds for this pane (the last capture's `paneHistoryLines`):
// `localScrollbackRows=0` beside a positive count is an UNLOADED buffer,
// which _maybePageCliTranscript hands to the local path instead of paging.
const paneHistory = this._paneHistoryLines?.get(sessionId);
const signature =
`${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|` +
`${tracking}|${encoding}|${cliTracking}|${baseY > 0}`;
`${tracking}|${encoding}|${cliTracking}|${baseY > 0}|${(paneHistory ?? 0) > 0}`;
if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map();
if (this._scrollRoutingLogged.get(sessionId) === signature) return;
this._scrollRoutingLogged.set(sessionId, signature);
console.log(
`[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` +
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, mouseEncoding=${encoding}, ` +
`cliMouseTracking=${cliTracking}, localScrollbackRows=${baseY})`
`cliMouseTracking=${cliTracking}, localScrollbackRows=${baseY}, paneHistoryLines=${paneHistory ?? 'unknown'})`
);
},