Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,9 @@ class CodemanApp {
// Cooldown per session for the scroll-to-top "load more history" re-pull.
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
this._fullHistoryRepullInFlight = false;
this._liveOutputSinceFullPull = new Set(); // Set<sessionId>
this._liveOutputGen = new Map(); // Map<sessionId, number>
this._earlyReplayTravel = null; // number[] while an early (#814) replay runs
// Sessions whose last re-pull came back THINNER than the live buffer (a
// repaint-mode CLI pane, where tmux keeps no history of its own). The pull is
// refused for those and retried far more slowly — see _maybeRefetchFullHistory.
Expand Down Expand Up @@ -9603,7 +9606,7 @@ class CodemanApp {
* gets a much longer cooldown so a hollow pane stops re-fetching megabytes on
* every scroll-up (issue #205, round 2).
*/
async _maybeRefetchFullHistory({ force = false } = {}) {
async _maybeRefetchFullHistory({ force = false, anchor = 'top' } = {}) {
const sessionId = this.activeSessionId;
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
if (this.detachedSessions?.has(sessionId)) return;
Expand Down Expand Up @@ -9642,6 +9645,10 @@ class CodemanApp {
// 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;
// Sampled BEFORE the fetch: this path does not gate live output while the
// capture downloads, so output flushed after tmux captured would otherwise
// be overwritten by the replay and still count as covered by it (#814).
const liveOutputGen = this._liveOutputGen?.get(sessionId) || 0;
try {
const requestStartedAt = performance.now();
const capture = await this._fetchTerminalCapture(
Expand Down Expand Up @@ -9737,6 +9744,10 @@ class CodemanApp {
this._fullHistoryRepullUseless?.delete(sessionId);
const rowsBefore = this.terminal.buffer.active.length;
const replayStartedAt = performance.now();
const rowsFromBottom = this.terminal.buffer.active.baseY - this.terminal.buffer.active.viewportY;
// Ordered, not summed: each move clamps at the buffer edge as it would
// have live, so a reversal after hitting the bottom is not cancelled out.
if (anchor === 'bottom') this._earlyReplayTravel = [];
this._resetTerminalForReplay();
const {
parsedAt,
Expand All @@ -9750,6 +9761,9 @@ class CodemanApp {
);
timing.resetAndParseMs = parsedAt - replayStartedAt;
if (!completed || this.activeSessionId !== sessionId || selectGen !== this._selectGeneration) return;
if ((this._liveOutputGen?.get(sessionId) || 0) === liveOutputGen) {
this._liveOutputSinceFullPull?.delete(sessionId);
}
// 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.
Expand All @@ -9762,9 +9776,19 @@ class CodemanApp {
// UPWARD, so what used to be row 0 (what they were looking at) is now `delta`
// rows down; scrolling there reveals the recovered history above it instead
// of teleporting them to the bottom the way a normal buffer load does.
const delta = parsedBufferLength - rowsBefore;
if (delta > 0) this.terminal.scrollToLine(delta);
else this.terminal.scrollToTop();
if (anchor === 'bottom') {
// Recover the tmux repaint gap above the live screen without a jump (#814).
const baseY = this.terminal.buffer.active.baseY;
const clamp = (row) => Math.max(0, Math.min(baseY, row));
let row = clamp(baseY - rowsFromBottom);
for (const move of this._earlyReplayTravel) row = clamp(row + move);
this.terminal.scrollToLine(row);
this._earlyReplayTravel = null;
} else {
const delta = parsedBufferLength - rowsBefore;
if (delta > 0) this.terminal.scrollToLine(delta);
else this.terminal.scrollToTop();
}
// The load's own replay sampled the sticky-scroll baseline while the
// terminal sat at the bottom of a just-rewritten buffer, so the next
// flush would scroll back down and undo the restore above. This path is
Expand All @@ -9780,6 +9804,7 @@ class CodemanApp {
} catch {
// Transient (offline, 5xx) — the next scroll-up past the cooldown retries.
} finally {
if (anchor === 'bottom') this._earlyReplayTravel = null;
this._fullHistoryRepullInFlight = false;
}
}
Expand Down Expand Up @@ -10333,6 +10358,7 @@ class CodemanApp {
const fetchStartedAt = performance.now();
const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`;
let capture;
let fullHistoryReplay = useFullHistory;
try {
capture = await this._fetchTerminalCapture(
useFullHistory
Expand All @@ -10353,6 +10379,7 @@ class CodemanApp {
_crashDiag.log('FULL CAPTURE ABORTED → tail');
// It never loaded, so the next select must be allowed to try again.
this._fullHistoryLoaded.delete(sessionId);
fullHistoryReplay = false;
capture = await this._fetchTerminalCapture(tailUrl);
}
const headersReceivedAt = capture.headersAt;
Expand Down Expand Up @@ -10391,14 +10418,15 @@ class CodemanApp {
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
const replayStartedAt = performance.now();
const liveOutputGen = this._liveOutputGen?.get(sessionId) || 0;
this._resetTerminalForReplay();
// Truncation is reported OUT OF BAND (#258). This used to write a grey
// "... earlier output truncated ..." line into the
// terminal itself, which scrolls away with the output it describes,
// cannot be actioned, and is indistinguishable from real CLI output.
this._setHistoryTruncation(sessionId, data);
// Use chunked write for large buffers to avoid UI jank
const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite(
const { parsedAt: freshParsedAt, completed } = await this.chunkedTerminalWrite(
data.terminalBuffer,
TERMINAL_CHUNK_SIZE,
bufferLoadOwner,
Expand All @@ -10409,6 +10437,9 @@ class CodemanApp {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
if (fullHistoryReplay && completed && (this._liveOutputGen?.get(sessionId) || 0) === liveOutputGen) {
this._liveOutputSinceFullPull?.delete(sessionId);
}
// Ensure terminal is scrolled to bottom after buffer load
this.terminal.scrollToBottom();
}
Expand Down Expand Up @@ -10816,6 +10847,8 @@ class CodemanApp {
}
this.terminalBuffers.delete(sessionId);
this.terminalBufferCache.delete(sessionId);
this._liveOutputSinceFullPull?.delete(sessionId);
this._liveOutputGen?.delete(sessionId);
this._clearHistoryTruncation(sessionId);
this._xtermSnapshots?.delete(sessionId);
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
Expand Down
38 changes: 37 additions & 1 deletion src/web/public/terminal-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -4064,11 +4064,41 @@ Object.assign(CodemanApp.prototype, {
* what the pull left rather than flashing the state it is about to replace.
*/
_maybeLoadMoreHistoryOnScroll(lines) {
if (Array.isArray(this._earlyReplayTravel)) {
this._earlyReplayTravel.push(lines);
return;
}
if (lines > 0) {
if (this.isTerminalAtBottom()) this._setHistoryNoticeRevealed?.(null);
return;
}
if (lines === 0 || this.terminal?.buffer?.active?.viewportY !== 0) return;
if (lines === 0) return;
const buffer = this.terminal?.buffer?.active;
if (buffer?.viewportY !== 0) {
const sessionId = this.activeSessionId;
// tmux repaint bursts can leave a gap just above live output (#814).
// Called after scrollLines: include this gesture's travel in the bottom check.
if (
lines < 0 &&
sessionId &&
this._liveOutputSinceFullPull?.has(sessionId) &&
this.sessions.get(sessionId)?.mode !== 'shell' &&
buffer?.viewportY >= buffer.baseY - 2 - Math.abs(lines) &&
!this._fullHistoryRepullInFlight &&
!this._isLoadingBuffer
) {
Promise.resolve(this._maybeRefetchFullHistory?.({ anchor: 'bottom' }))
.catch(() => {})
.then(() => {
// Travel during the replay is swallowed above, so a gesture that
// reached the top meanwhile still owes the notice its reveal.
if (this.activeSessionId === sessionId && this.terminal?.buffer?.active?.viewportY === 0) {
this._setHistoryNoticeRevealed?.(sessionId);
}
});
}
return;
}
// An earlier frame of this gesture already started the pull; its own
// completion reveals the notice, so do not flash the pre-pull state now.
if (this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
Expand Down Expand Up @@ -4654,6 +4684,12 @@ Object.assign(CodemanApp.prototype, {
this._scheduleTerminalWriteFlush();
this._noteMobileImeAuthoritativeOutput(flushedOutputSeq, flushSessionId);
});
// Only live writes can open a new tmux repaint gap; replays cannot (#814).
if (writeChunk.length && flushSessionId && !this._isLoadingBuffer) {
(this._liveOutputSinceFullPull ||= new Set()).add(flushSessionId);
const generations = (this._liveOutputGen ||= new Map());
generations.set(flushSessionId, (generations.get(flushSessionId) || 0) + 1);
}
} catch (err) {
this._terminalWriteInFlight = false;
this._terminalWriteInFlightBytes = 0;
Expand Down
Loading