mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge pull request #681: fix(terminal): load tmux history instead of paging Claude after a hollow tab switch
This commit is contained in:
commit
d38044e2ac
9 files changed
+312
-18
No files matched your search
+43
-9
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'})`
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user