Skip to content
Merged
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
149 changes: 139 additions & 10 deletions src/web/public/app.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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();
Expand All@@ -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;
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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)) {
Expand DownExpand Up@@ -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 {}

Expand Down
87 changes: 87 additions & 0 deletions src/web/public/constants.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 };
}
5 changes: 5 additions & 0 deletions src/web/public/index.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -310,6 +310,11 @@
<!-- Main Terminal Area -->
<main class="main">
<div class="terminal-wrap">
<!-- Partial-history notice (#258). Lives OUTSIDE the terminal on purpose:
the old notice was a grey line written into the scrollback, so it
scrolled away with the output it described and could not be acted
on. Populated by app.js _renderHistoryTruncationBanner(). -->
<div class="history-trunc-bar" id="historyTruncationBar" role="status" aria-live="polite" hidden></div>
<div class="terminal-container" id="terminalContainer"></div>
<textarea id="cjkInput" rows="1" placeholder="CJK input (Enter = send, Esc = clear)"
maxlength="65536" aria-label="CJK IME input field"
Expand Down
Loading