mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
fix(terminal): bound shell history replay
This commit is contained in:
+127
-37
@@ -536,10 +536,10 @@ class CodemanApp {
|
||||
this._initGeneration = 0; // dedup concurrent handleInit calls
|
||||
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
||||
this._selectGeneration = 0; // cancel stale selectSession loads
|
||||
// Sessions whose full tmux scrollback has already been replayed this page load
|
||||
// (COD-47). Tracked PER SESSION rather than as a single "first load" flag: the
|
||||
// flag was consumed by whichever session auto-selected at page load, so every
|
||||
// OTHER tab started life with one visible frame of history (issue #205).
|
||||
// Non-shell sessions whose full tmux scrollback has already been replayed this
|
||||
// page load (COD-47). Shells deliberately start from a bounded tail because
|
||||
// their scrollback can be very large; full history stays available on demand.
|
||||
// Tracked PER SESSION rather than as a single "first load" flag (issue #205).
|
||||
this._fullHistoryLoaded = new Set();
|
||||
// Cooldown per session for the scroll-to-top "load more history" re-pull.
|
||||
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
|
||||
@@ -5268,6 +5268,22 @@ class CodemanApp {
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
|
||||
_recordTerminalLoadTiming(timing) {
|
||||
this._lastTerminalLoadTiming = timing;
|
||||
console.info('[TERMINAL-PERF]', timing);
|
||||
const resetAndParseMs =
|
||||
(timing.cacheResetAndParseMs || 0) +
|
||||
(timing.freshResetAndParseMs || 0) +
|
||||
(timing.resetAndParseMs || 0);
|
||||
const totalMs = timing.selectDoneMs ?? timing.totalMs ?? timing.selectToReplayCompleteMs ?? 0;
|
||||
_crashDiag.log(
|
||||
`TERMINAL_LOAD: ${timing.trigger} ${timing.full ? 'full' : 'tail'} ${timing.chars} chars ` +
|
||||
`ttfb=${timing.ttfbMs.toFixed(0)}ms body+json=${timing.bodyAndJsonMs.toFixed(0)}ms ` +
|
||||
`reset+parse=${resetAndParseMs.toFixed(0)}ms total=${totalMs.toFixed(0)}ms ` +
|
||||
`server="${timing.serverTiming}"${timing.refused ? ' refused-downgrade' : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Load more history": re-pull the whole tmux scrollback when the user scrolls up
|
||||
* while already at the top of what the browser has.
|
||||
@@ -5307,13 +5323,32 @@ class CodemanApp {
|
||||
this._fullHistoryRepullAt.set(sessionId, now);
|
||||
this._fullHistoryRepullInFlight = true;
|
||||
try {
|
||||
const requestStartedAt = performance.now();
|
||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
|
||||
const headersReceivedAt = performance.now();
|
||||
const payload = (await res.json())?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
const buffer = payload.terminalBuffer;
|
||||
const timing = {
|
||||
trigger: force ? 'full-history-button' : 'full-history-scroll',
|
||||
mode: this.sessions.get(sessionId)?.mode || 'unknown',
|
||||
full: true,
|
||||
source: payload.source || 'unknown',
|
||||
chars: buffer?.length || 0,
|
||||
ttfbMs: headersReceivedAt - requestStartedAt,
|
||||
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
|
||||
resetAndParseMs: 0,
|
||||
totalMs: 0,
|
||||
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||
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;
|
||||
if (this._replayWouldShrinkBuffer(buffer)) {
|
||||
timing.refused = true;
|
||||
timing.totalMs = performance.now() - requestStartedAt;
|
||||
this._recordTerminalLoadTiming(timing);
|
||||
(this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
|
||||
this._logScrollRouting?.('repull-refused-downgrade');
|
||||
// The browser already holds more than tmux can give back, so there is
|
||||
@@ -5324,17 +5359,32 @@ class CodemanApp {
|
||||
this._setHistoryTruncation(sessionId, payload);
|
||||
this._fullHistoryRepullUseless?.delete(sessionId);
|
||||
const rowsBefore = this.terminal.buffer.active.length;
|
||||
const replayStartedAt = performance.now();
|
||||
this._resetTerminalForReplay();
|
||||
await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
||||
if (this.activeSessionId !== sessionId) return;
|
||||
this.terminalBufferCache.set(sessionId, buffer);
|
||||
const {
|
||||
parsedAt,
|
||||
bufferLength: parsedBufferLength,
|
||||
completed,
|
||||
} = await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
||||
timing.resetAndParseMs = parsedAt - replayStartedAt;
|
||||
if (!completed || this.activeSessionId !== sessionId) 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.
|
||||
if (this.sessions.get(sessionId)?.mode !== 'shell') {
|
||||
this.terminalBufferCache.set(sessionId, buffer);
|
||||
} else {
|
||||
this.terminalBufferCache.delete(sessionId);
|
||||
}
|
||||
// Hold the user's place. The replay is a superset that grew the buffer
|
||||
// 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 = this.terminal.buffer.active.length - rowsBefore;
|
||||
const delta = parsedBufferLength - rowsBefore;
|
||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||
else this.terminal.scrollToTop();
|
||||
timing.totalMs = performance.now() - requestStartedAt;
|
||||
this._recordTerminalLoadTiming(timing);
|
||||
} catch {
|
||||
// Transient (offline, 5xx) — the next scroll-up past the cooldown retries.
|
||||
} finally {
|
||||
@@ -5614,6 +5664,7 @@ class CodemanApp {
|
||||
// COD-144: track whether the load painted nothing (empty fetch + no cache).
|
||||
// For that just-created-session case we flush (not discard) queued SSE events.
|
||||
let bufferWasEmpty = false;
|
||||
let cacheResetAndParseMs = 0;
|
||||
try {
|
||||
// Fit terminal to container BEFORE writing any buffer data.
|
||||
// If the browser was resized while viewing another session, the terminal
|
||||
@@ -5691,23 +5742,30 @@ class CodemanApp {
|
||||
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
||||
// buffer once for a single clean transition.
|
||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||
let clearedForBusy = false;
|
||||
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) {
|
||||
let clearedBeforeFresh = false;
|
||||
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot && session?.mode !== 'shell') {
|
||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||
const cacheReplayStartedAt = performance.now();
|
||||
this._resetTerminalForReplay();
|
||||
await this.chunkedTerminalWrite(cachedBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
|
||||
const { parsedAt: cacheParsedAt } = await this.chunkedTerminalWrite(
|
||||
cachedBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
bufferLoadOwner
|
||||
);
|
||||
cacheResetAndParseMs = cacheParsedAt - cacheReplayStartedAt;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
this.terminal.scrollToBottom();
|
||||
_crashDiag.log('CACHE_DONE');
|
||||
} else if (sessionIsBusy) {
|
||||
// Clear stale content immediately — fresh buffer is being fetched
|
||||
} else if (sessionIsBusy || session?.mode === 'shell') {
|
||||
// Busy sessions have stale caches. Shell sessions deliberately skip even
|
||||
// an idle cache so a changed 1MB tail cannot cause two back-to-back parses.
|
||||
this._resetTerminalForReplay();
|
||||
clearedForBusy = true;
|
||||
_crashDiag.log('CACHE_SKIP_BUSY');
|
||||
clearedBeforeFresh = true;
|
||||
_crashDiag.log(session?.mode === 'shell' ? 'CACHE_SKIP_SHELL' : 'CACHE_SKIP_BUSY');
|
||||
}
|
||||
|
||||
// Give TUI sessions a short chance to redraw after resize before the
|
||||
@@ -5725,26 +5783,29 @@ class CodemanApp {
|
||||
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
|
||||
_crashDiag.log('FETCH_START');
|
||||
// The first load OF EACH SESSION this page load requests the full tmux
|
||||
// scrollback (?full=1, COD-47) so history that scrolled off the server's byte
|
||||
// buffer comes back. Later switches to an already-replayed session keep the
|
||||
// fast ?tail= frame path, which is why this is a Set and not a flag: the flag
|
||||
// version gave the full replay to the auto-selected tab and one frame of
|
||||
// history to every other one (issue #205).
|
||||
const useFullHistory = !this._fullHistoryLoaded.has(sessionId);
|
||||
// TUI sessions still get one canonical full replay per page (COD-47/#205).
|
||||
// A shell can retain hundreds of thousands of plain scrollback lines, so
|
||||
// automatically replaying all of them makes tab selection scale with the
|
||||
// entire session. Load its bounded 1MB tail first; the existing truncation
|
||||
// banner / scroll-to-top action fetches ?full=1 when the user asks for it.
|
||||
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
|
||||
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
||||
const fetchStartedAt = performance.now();
|
||||
const res = await fetch(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||
);
|
||||
const headersReceivedAt = performance.now();
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json())?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
||||
|
||||
let freshResetAndParseMs = 0;
|
||||
if (data.terminalBuffer) {
|
||||
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
|
||||
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
|
||||
@@ -5753,10 +5814,11 @@ class CodemanApp {
|
||||
// something other than the cache, so the fetched buffer must be
|
||||
// replayed even when it byte-matches the cache.
|
||||
const needsRewrite =
|
||||
restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer;
|
||||
restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer;
|
||||
if (needsRewrite) {
|
||||
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||
const replayStartedAt = performance.now();
|
||||
this._resetTerminalForReplay();
|
||||
// Truncation is reported OUT OF BAND (#258). This used to write a grey
|
||||
// "... earlier output truncated ..." line into the
|
||||
@@ -5764,7 +5826,12 @@ class CodemanApp {
|
||||
// cannot be actioned, and is indistinguishable from real CLI output.
|
||||
this._setHistoryTruncation(sessionId, data);
|
||||
// Use chunked write for large buffers to avoid UI jank
|
||||
await this.chunkedTerminalWrite(data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
|
||||
const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite(
|
||||
data.terminalBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
bufferLoadOwner
|
||||
);
|
||||
freshResetAndParseMs = freshParsedAt - replayStartedAt;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
@@ -5773,22 +5840,42 @@ class CodemanApp {
|
||||
this.terminal.scrollToBottom();
|
||||
}
|
||||
|
||||
// Update cache (cap at 20 entries)
|
||||
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
|
||||
if (this.terminalBufferCache.size > 20) {
|
||||
// Evict oldest entry (first key in Map iteration order)
|
||||
const oldest = this.terminalBufferCache.keys().next().value;
|
||||
this.terminalBufferCache.delete(oldest);
|
||||
// Shell selection always uses a fresh bounded tail, so retaining its
|
||||
// payload only wastes memory and can evict useful TUI caches.
|
||||
if (session?.mode === 'shell') {
|
||||
this.terminalBufferCache.delete(sessionId);
|
||||
} else {
|
||||
// Update cache (cap at 20 entries)
|
||||
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
|
||||
if (this.terminalBufferCache.size > 20) {
|
||||
// Evict oldest entry (first key in Map iteration order)
|
||||
const oldest = this.terminalBufferCache.keys().next().value;
|
||||
this.terminalBufferCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
} else if (!cachedBuffer) {
|
||||
// No fresh buffer and no cache — clear any stale content
|
||||
this._resetTerminalForReplay();
|
||||
} else if (!cachedBuffer || clearedBeforeFresh) {
|
||||
// Nothing was painted. If this path was not already cleared above,
|
||||
// clear stale content now; either way queued live output must be flushed.
|
||||
if (!clearedBeforeFresh) this._resetTerminalForReplay();
|
||||
bufferWasEmpty = true;
|
||||
}
|
||||
|
||||
const terminalLoadTiming = {
|
||||
trigger: 'session-select',
|
||||
mode: session?.mode || 'unknown',
|
||||
full: useFullHistory,
|
||||
source: data.source || 'unknown',
|
||||
chars: data.terminalBuffer?.length || 0,
|
||||
ttfbMs: headersReceivedAt - fetchStartedAt,
|
||||
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
|
||||
cacheResetAndParseMs,
|
||||
freshResetAndParseMs,
|
||||
selectToReplayCompleteMs: performance.now() - _selStart,
|
||||
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||
};
|
||||
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
|
||||
// _finishBufferLoad internally (discarding queued events to prevent duplicate
|
||||
// content); if we skipped the write (cache hit or empty), call it here.
|
||||
// _finishBufferLoad after ordering the fetched snapshot in xterm; if we skipped
|
||||
// the write (cache hit or empty), call it here.
|
||||
// COD-144: when the load painted nothing, FLUSH the queued events instead of
|
||||
// discarding — a new session's prompt arrives only as a queued SSE event.
|
||||
if (this._isLoadingBuffer) {
|
||||
@@ -5917,9 +6004,12 @@ class CodemanApp {
|
||||
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) {
|
||||
KeyboardHandler.onKeyboardShow();
|
||||
}
|
||||
const selectDoneMs = performance.now() - _selStart;
|
||||
terminalLoadTiming.selectDoneMs = selectDoneMs;
|
||||
this._recordTerminalLoadTiming(terminalLoadTiming);
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||
_crashDiag.log(`SELECT_DONE: ${selectDoneMs.toFixed(0)}ms`);
|
||||
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${selectDoneMs.toFixed(0)}ms`);
|
||||
} catch (err) {
|
||||
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
|
||||
this._restoringFlushedState = false;
|
||||
|
||||
Reference in New Issue
Block a user