diff --git a/src/web/public/app.js b/src/web/public/app.js index d80aa7305..b1de02d49 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2196,14 +2196,46 @@ class CodemanApp { if (!this.activeSessionId || !this.terminal) return; // Skip if buffer load already in progress — avoids competing clear+rewrite cycles if (this._isLoadingBuffer) return; + const sessionId = this.activeSessionId; try { - const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); - const data = (await res.json())?.data ?? {}; + // Recovery should restore the WHOLE picture, so ask for full history + // rather than a tail. Measured on a 900-line shell pane: the tail rewrite + // replaced an 869-row buffer with 158 rows, so every backpressure refresh + // silently destroyed most of the scrollback it was meant to repair. + // + // A repaint-mode pane is the opposite case (tmux keeps ~one frame for it), + // so the full capture can be SMALLER than what xterm already holds. Reuse + // the same downgrade guard as the scroll-to-top re-pull and fall back to + // the historical tail there, leaving that case exactly as it was. + let res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`); + let data = (await res.json())?.data ?? {}; + if (data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) { + res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); + data = (await res.json())?.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. + if (this.activeSessionId !== sessionId) return; 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). + // The rewrite replaces the buffer, so an absolute viewportY is + // meaningless across it — distance from the bottom is what survives. + const before = this.terminal.buffer?.active; + const linesFromBottom = before ? Math.max(0, (before.baseY || 0) - (before.viewportY || 0)) : 0; this.terminal.clear(); this.terminal.reset(); await this.chunkedTerminalWrite(data.terminalBuffer); - this.terminal.scrollToBottom(); + // A tail fetch can be partial, and the banner would otherwise keep + // describing the pre-refresh buffer (#258). + this._setHistoryTruncation(sessionId, data); + const target = computeRewriteScrollLine({ + linesFromBottom, + baseY: this.terminal.buffer?.active?.baseY ?? 0, + }); + if (target === null || typeof this.terminal.scrollToLine !== 'function') this.terminal.scrollToBottom(); + else this.terminal.scrollToLine(target); // Re-position local echo overlay at new prompt location this._localEchoOverlay?.rerender(); // Resize PTY to match actual browser dimensions (critical for OpenCode @@ -4509,28 +4541,36 @@ 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() { + async _maybeRefetchFullHistory({ force = false } = {}) { const sessionId = this.activeSessionId; if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return; if (this.detachedSessions?.has(sessionId)) return; const now = Date.now(); // Momentum scrolling fires this dozens of times per flick, and a burst of new // output is the normal reason to want a re-pull, so cooldown rather than latch. + // `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; - if (now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return; + if (!force && now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return; this._fullHistoryRepullAt.set(sessionId, now); this._fullHistoryRepullInFlight = true; try { const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`); - const buffer = (await res.json())?.data?.terminalBuffer; + const payload = (await res.json())?.data ?? {}; + const buffer = payload.terminalBuffer; // 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)) { (this._fullHistoryRepullUseless ||= new Set()).add(sessionId); this._logScrollRouting?.('repull-refused-downgrade'); + // The browser already holds more than tmux can give back, so there is + // nothing further to offer and the indicator must stop promising it. + this._setHistoryTruncation(sessionId, { ...payload, exhausted: true }); return; } + this._setHistoryTruncation(sessionId, payload); this._fullHistoryRepullUseless?.delete(sessionId); const rowsBefore = this.terminal.buffer.active.length; this._resetTerminalForReplay(); @@ -4551,6 +4591,89 @@ class CodemanApp { } } + /** + * Record how much history a replay actually carried, and refresh the banner. + * + * Called from every path that writes a fetched buffer into xterm. Keyed by + * session because the banner describes the ACTIVE tab and a background fetch + * must not relabel it. + */ + _setHistoryTruncation(sessionId, payload = {}) { + if (!sessionId) return; + (this._historyTruncation ||= new Map()).set(sessionId, { + truncated: !!payload.truncated, + reason: payload.truncationReason ?? null, + source: payload.source ?? null, + fullSize: payload.fullSize ?? 0, + retainedBytes: payload.retainedBytes ?? 0, + // Set once a full-history pull has been refused as a downgrade: the + // browser holds more than the server can return, so there is no more. + exhausted: !!payload.exhausted, + }); + if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner(); + } + + /** Drop banner state for a session that is going away. */ + _clearHistoryTruncation(sessionId) { + this._historyTruncation?.delete(sessionId); + if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner(); + } + + /** + * Paint the partial-history banner for the active session. + * + * Three distinct states, because "we tailed for speed" and "the oldest output + * is gone forever" are not the same message and the old single boolean could + * not tell them apart: + * - recoverable → offer to load the rest + * - exhausted → say so plainly, offer nothing + * - at the limit → the full capture ITSELF hit the byte ceiling + */ + _renderHistoryTruncationBanner() { + const bar = document.getElementById('historyTruncationBar'); + if (!bar) return; + const state = this.activeSessionId ? this._historyTruncation?.get(this.activeSessionId) : null; + const notice = computeHistoryTruncationNotice(state || {}); + if (!notice.visible) { + bar.hidden = true; + return; + } + + bar.textContent = ''; + const label = document.createElement('span'); + label.className = 'history-trunc-text'; + label.textContent = notice.message; + bar.appendChild(label); + + if (notice.canLoadMore) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'history-trunc-load'; + btn.textContent = 'Load full history'; + btn.onclick = () => { + btn.disabled = true; + btn.textContent = 'Loading…'; + // Forced: the cooldown exists to throttle scroll gestures, not choices. + this._maybeRefetchFullHistory({ force: true }).finally(() => { + this._renderHistoryTruncationBanner(); + }); + }; + bar.appendChild(btn); + } + + const dismiss = document.createElement('button'); + dismiss.type = 'button'; + dismiss.className = 'history-trunc-dismiss'; + dismiss.setAttribute('aria-label', 'Dismiss history notice'); + dismiss.textContent = '×'; + dismiss.onclick = () => { + bar.hidden = true; + }; + bar.appendChild(dismiss); + + bar.hidden = false; + } + _shouldFocusTerminalForTabSwitch() { if (typeof MobileDetection === 'undefined' || !MobileDetection.isTouchDevice()) { return true; @@ -4607,6 +4730,10 @@ class CodemanApp { this._cleanupPreviousSession(sessionId); this.activeSessionId = sessionId; + // Repaint the partial-history banner for the tab being switched TO. The + // replay paths refresh it when their fetch lands; without this the previous + // session's notice stays on screen until then (#258). + this._renderHistoryTruncationBanner(); try { localStorage.setItem('codeman-active-session', sessionId); } catch {} // Narrow SSE filter to the active session — server stops streaming // session:terminal events for other sessions to this client. Cuts @@ -4858,10 +4985,11 @@ class CodemanApp { _crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`); this._setTerminalLoadState(sessionId, selectGen, 'replaying'); this._resetTerminalForReplay(); - // Show truncation indicator if buffer was cut - if (data.truncated) { - this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n'); - } + // 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 await this.chunkedTerminalWrite(data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner); if (this._isStaleSelect(selectGen)) { @@ -5043,6 +5171,7 @@ class CodemanApp { } this.terminalBuffers.delete(sessionId); this.terminalBufferCache.delete(sessionId); + this._clearHistoryTruncation(sessionId); this._xtermSnapshots?.delete(sessionId); try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {} diff --git a/src/web/public/constants.js b/src/web/public/constants.js index e7a375596..b39d445cb 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -795,3 +795,90 @@ function escapeHtml(text) { if (typeof text !== 'string') return ''; return text.replace(_htmlEscapePattern, (ch) => _htmlEscapeMap[ch]); } + +/** + * Human-readable byte size for the partial-history banner (#258). + * + * Deliberately coarse: the banner is telling the user roughly how much of a + * transcript they are looking at, not accounting for bytes. Sub-KB amounts read + * as "less than 1 KB" rather than an exact count nobody can act on. + * + * @param {number} bytes + * @returns {string} + */ +function formatHistoryBytes(bytes) { + const n = typeof bytes === 'number' && isFinite(bytes) && bytes > 0 ? bytes : 0; + if (n < 1024) return 'less than 1 KB'; + if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Decide what the partial-history banner should say (#258). + * + * PURE so the three states can be tested without a DOM. They exist because one + * `truncated` boolean could not distinguish messages the user acts on very + * differently: + * - recoverable: we tailed for speed and the rest is still retained + * - atCeiling: the FULL capture itself hit the byte ceiling + * - exhausted: a full pull was refused as a downgrade, so this is all there is + * + * @param {{truncated?: boolean, reason?: string|null, source?: string|null, + * fullSize?: number, retainedBytes?: number, exhausted?: boolean}} state + * @returns {{visible: boolean, message: string, canLoadMore: boolean}} + */ +function computeHistoryTruncationNotice(state = {}) { + if (!state.truncated) return { visible: false, message: '', canLoadMore: false }; + + const retained = Math.max(0, state.retainedBytes || 0); + const dropped = Math.max(0, (state.fullSize || 0) - retained); + const shown = formatHistoryBytes(retained); + // A full-history capture that was STILL capped is already everything tmux + // holds, so the remainder is out of reach rather than one request away. + const atCeiling = state.source === 'mux-full-history' && state.reason === 'capped'; + + if (state.exhausted) { + return { + visible: true, + message: `Showing all ${shown} of retained history. Earlier output is no longer kept for this session.`, + canLoadMore: false, + }; + } + if (atCeiling) { + return { + visible: true, + message: `Showing the most recent ${shown}. Earlier output exceeds the retained history limit and cannot be recovered.`, + canLoadMore: false, + }; + } + return { + visible: true, + message: `Showing the most recent ${shown} of this session. ${formatHistoryBytes(dropped)} more may still be retained.`, + canLoadMore: true, + }; +} + +/** + * Where to land after a rewrite that REPLACES the whole buffer (#259). + * + * The backpressure refresh clears the terminal and reloads it from a freshly + * fetched capture, so an absolute viewportY captured beforehand means nothing + * afterwards: the line it pointed at may not even exist. Distance from the + * BOTTOM is the anchor that survives a rewrite, so a reader stays roughly + * where they were reading. + * + * Returns null when the user was following live output, which the caller reads + * as "scroll to bottom" — the historical behavior, kept for that case. + * + * @param {{linesFromBottom?: number, baseY?: number}} input + * @returns {number|null} + */ +function computeRewriteScrollLine(input) { + const linesFromBottom = input?.linesFromBottom || 0; + if (!(linesFromBottom > 0)) return null; + return Math.max(0, (input?.baseY || 0) - linesFromBottom); +} + +if (typeof window !== 'undefined') { + window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine }; +} diff --git a/src/web/public/index.html b/src/web/public/index.html index c2e8966c3..2983ac6ef 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -310,6 +310,11 @@
+ +