Merge pull request #286 from Ark0N/fix/terminal-history-scroll

fix(terminal): preserve scroll intent across keyboard resize, surface history truncation
This commit is contained in:
Ark0N
2026-08-14 01:16:01 +02:00
committed by GitHub
12 changed files with 814 additions and 25 deletions
+139 -10
View File
@@ -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 {}
+87
View File
@@ -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
View File
@@ -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"
+55 -9
View File
@@ -214,8 +214,13 @@ const KeyboardHandler = {
keyboardVisible: false,
initialViewportHeight: 0,
_viewportSettleTimer: null,
_settleScrollToBottom: false,
_settleRestoreScroll: false,
_settlePending: false,
// Scroll intent captured at the start of a settle cycle (#259). `true` =
// following live output, `false` = reading history and _settleAnchorY holds
// the top visible line to return to.
_settleFollowing: true,
_settleAnchorY: null,
/** Initialize keyboard handling */
init() {
@@ -284,8 +289,10 @@ const KeyboardHandler = {
clearTimeout(this._viewportSettleTimer);
this._viewportSettleTimer = null;
}
this._settleScrollToBottom = false;
this._settleRestoreScroll = false;
this._settlePending = false;
this._settleFollowing = true;
this._settleAnchorY = null;
},
/** Handle viewport resize (keyboard show/hide) */
@@ -427,7 +434,7 @@ const KeyboardHandler = {
// visualViewport emits multiple heights throughout the OS animation.
// Re-schedule on every event and fit only after the final height settles.
this._scheduleViewportSettle({ scrollToBottom: true });
this._scheduleViewportSettle({ restoreScroll: true });
// Reposition subagent windows to stack from bottom (above keyboard)
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
@@ -442,7 +449,7 @@ const KeyboardHandler = {
this.resetLayout();
this._scheduleViewportSettle({ scrollToBottom: true });
this._scheduleViewportSettle({ restoreScroll: true });
// Reposition subagent windows to stack from top (below header)
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
@@ -459,12 +466,46 @@ const KeyboardHandler = {
* fit against it resizes the PTY to transient dims and the SIGWINCH thrash
* garbles the transcript.
*/
_scheduleViewportSettle({ scrollToBottom = false } = {}) {
this._settleScrollToBottom = this._settleScrollToBottom || scrollToBottom;
_scheduleViewportSettle({ restoreScroll = false } = {}) {
// Capture scroll intent on the FIRST event of a settle cycle, BEFORE any
// fit() has reflowed the buffer — a later capture reads an already-moved
// viewportY. Issue #259: this path used to force scrollToBottom
// unconditionally, so opening the keyboard yanked a user who was reading
// history down to the live output.
if (!this._settlePending) this._captureTerminalScrollIntent();
this._settleRestoreScroll = this._settleRestoreScroll || restoreScroll;
this._settlePending = true;
this._armViewportSettleTimer();
},
/**
* Record whether the terminal is following live output, and if not, the top
* visible line to return to. `_settleFollowing` defaults to true so a
* terminal we cannot read keeps the historical scroll-to-bottom behavior.
*/
_captureTerminalScrollIntent() {
this._settleFollowing = true;
this._settleAnchorY = null;
if (typeof app === 'undefined' || !app.terminal?.buffer?.active) return;
this._settleFollowing = app.isTerminalAtBottom();
if (!this._settleFollowing) this._settleAnchorY = app.terminal.buffer.active.viewportY;
},
/**
* Return to the captured anchor after the keyboard reflow. Reflow can rewrap
* lines, so the anchor is approximate by construction; it is clamped to the
* post-reflow buffer rather than trusted blindly.
*/
_restoreTerminalScrollIntent() {
const term = typeof app !== 'undefined' ? app.terminal : null;
const anchor = this._settleAnchorY;
if (typeof anchor !== 'number' || typeof term?.scrollToLine !== 'function' || !term.buffer?.active) {
term?.scrollToBottom?.();
return;
}
term.scrollToLine(Math.max(0, Math.min(anchor, term.buffer.active.baseY)));
},
/** Push a pending settle back while the viewport is still animating; no-op otherwise. */
_deferViewportSettle() {
if (!this._settlePending) return;
@@ -476,8 +517,8 @@ const KeyboardHandler = {
this._viewportSettleTimer = setTimeout(() => {
this._viewportSettleTimer = null;
this._settlePending = false;
const shouldScrollToBottom = this._settleScrollToBottom;
this._settleScrollToBottom = false;
const shouldRestoreScroll = this._settleRestoreScroll;
this._settleRestoreScroll = false;
if (typeof app !== 'undefined' && app.terminal) {
if (app.fitAddon) {
@@ -486,7 +527,12 @@ const KeyboardHandler = {
} catch {}
}
if (this.keyboardVisible) this._shrinkPaddingToFit();
if (shouldScrollToBottom) app.terminal.scrollToBottom();
// Following live output → bottom, as before. Reading history → back to
// the pre-reflow anchor instead of being yanked down (#259).
if (shouldRestoreScroll) {
if (this._settleFollowing === false) this._restoreTerminalScrollIntent();
else app.terminal.scrollToBottom();
}
app._syncMobileHelperTextareaToCursor?.();
app._localEchoOverlay?.rerender?.();
this._sendTerminalResize();
+77
View File
@@ -3195,6 +3195,83 @@ body.solo-mode .btn-lifecycle-log {
display: flex;
flex-direction: column;
overflow: hidden;
/* Anchor for the partial-history banner, which overlays rather than stacks. */
position: relative;
}
/* Partial-history banner (#258).
OVERLAY, not a flex child, on purpose: FitAddon derives rows/cols from the
terminal parent's computed height, so a banner that occupied real layout
space would SIGWINCH the CLI every time truncation state changed and make
Ink repaint the world. Floating it costs a few covered rows at the top,
which the dismiss button releases. */
.history-trunc-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 6; /* under the local-echo overlay (7), over terminal content */
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
font-size: 12px;
line-height: 1.35;
color: var(--text-dim);
background: var(--bg-card);
border-bottom: 1px solid var(--border);
box-shadow: 0 2px 8px rgb(0 0 0 / 22%);
}
/* `.history-trunc-bar` sets display:flex, which outranks the hidden attribute's
UA display:none — without this the banner can never be hidden. */
.history-trunc-bar[hidden] {
display: none;
}
.history-trunc-text {
flex: 1;
min-width: 0;
}
.history-trunc-load {
flex: none;
padding: 4px 10px;
font-size: 12px;
font-family: inherit;
color: var(--text);
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
}
.history-trunc-load:hover:not(:disabled) {
background: var(--border-light);
}
.history-trunc-load:disabled {
opacity: 0.6;
cursor: default;
}
.history-trunc-dismiss {
flex: none;
width: 22px;
height: 22px;
padding: 0;
font-size: 15px;
line-height: 1;
color: var(--text-muted);
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
}
.history-trunc-dismiss:hover {
color: var(--text);
background: var(--bg-hover);
}
.terminal-container {
+10 -3
View File
@@ -2910,10 +2910,17 @@ Object.assign(CodemanApp.prototype, {
const activeSession = this.activeSessionId && this.sessions ? this.sessions.get(this.activeSessionId) : null;
const MAX_FRAME_BYTES = activeSession?.mode === 'codex' ? 32768 : 65536;
let deferred = false;
// If the user recently scrolled up, remember the viewport so we can restore
// it after the write — Codex status redraws would otherwise jump it.
// If the user is reading history, remember the viewport so we can restore it
// after the write — Codex status redraws would otherwise jump it.
//
// Position, not recency (#259). This was gated on _hasRecentUserScrollUp(),
// a 1500ms decay window, so a user who scrolled up and then actually READ
// for longer than that lost the protection mid-read and got dragged along by
// the next repaint. Being scrolled up IS the intent, however long ago it was
// expressed; the recency window remains as an extra guard on the sticky
// scroll-to-bottom below, where it protects against a mid-flush race.
const preserveViewportY =
this._hasRecentUserScrollUp() && this.terminal.buffer?.active ? this.terminal.buffer.active.viewportY : null;
this.terminal.buffer?.active && !this.isTerminalAtBottom() ? this.terminal.buffer.active.viewportY : null;
if (_joinedLen <= MAX_FRAME_BYTES) {
this.terminal.write(joined);
+16
View File
@@ -2292,6 +2292,14 @@ export function registerSessionRoutes(
}
const fullSize = rawBuffer.length;
let truncated = false;
// WHY the reason and not just the boolean (#258): `truncated` is set at two
// sites that mean opposite things to a user. 'tail' is an intentional
// partial replay and the rest is still retained, so a `full=1` pull recovers
// it. 'capped' means we hit the byte ceiling — and on a full-history capture
// that is already everything tmux holds, so the oldest output is genuinely
// out of reach rather than one click away. Collapsing both into one flag is
// why the UI could only ever say "truncated for performance".
let truncationReason: 'capped' | 'tail' | null = null;
let cleanBuffer: string;
// Cap the payload EARLY — before the regex normalization passes below run
@@ -2302,6 +2310,7 @@ export function registerSessionRoutes(
if (terminalBufferMaxBytes > 0 && rawBuffer.length > terminalBufferMaxBytes) {
rawBuffer = rawBuffer.slice(-terminalBufferMaxBytes);
truncated = true;
truncationReason = 'capped';
const capNewline = rawBuffer.indexOf('\n');
if (capNewline > 0 && capNewline < 4096) {
rawBuffer = rawBuffer.slice(capNewline + 1);
@@ -2335,6 +2344,9 @@ export function registerSessionRoutes(
// Banner is near the top and gets discarded by tail anyway.
cleanBuffer = strippedBuffer.slice(-tailBytes);
truncated = true;
// 'capped' already means the oldest bytes are gone for good; a tail cut on
// top of it does not soften that, so the stronger reason wins.
truncationReason ??= 'tail';
// Avoid starting mid-ANSI-escape: find first newline within the first 4KB
// and start from there. This prevents xterm.js from parsing a partial escape
// sequence which corrupts cursor position for all subsequent Ink redraws.
@@ -2365,6 +2377,10 @@ export function registerSessionRoutes(
status: session.status,
fullSize,
truncated,
truncationReason,
// `retainedBytes` is what this response actually carries; `fullSize` is
// what existed before the cut. The gap is what the indicator reports.
retainedBytes: cleanBuffer.length,
source,
};
});
+132
View File
@@ -0,0 +1,132 @@
// Port: none (pure helpers from constants.js in a vm context).
//
// Issue #258: terminal history is split across browser scrollback, the server
// byte buffer and tmux, and the only signal the user got was a grey line written
// INTO the terminal saying "earlier output truncated for performance". That line
// scrolls away with the output it describes, cannot be acted on, and says the
// same thing whether the rest is one click away or gone forever.
//
// computeHistoryTruncationNotice() is the pure core of the replacement banner.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
function loadHelpers() {
const context = vm.createContext({ console, window: {}, document: {}, navigator: { userAgent: 'test' } });
vm.runInContext(
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}
;globalThis.__helpers = { formatHistoryBytes, computeHistoryTruncationNotice };`,
context,
{ filename: 'constants.js' }
);
return (context as any).__helpers as {
formatHistoryBytes: (n: number) => string;
computeHistoryTruncationNotice: (s: Record<string, unknown>) => {
visible: boolean;
message: string;
canLoadMore: boolean;
};
};
}
describe('formatHistoryBytes', () => {
const { formatHistoryBytes } = loadHelpers();
it('reports sub-KB amounts as a range, not a byte count', () => {
expect(formatHistoryBytes(400)).toBe('less than 1 KB');
expect(formatHistoryBytes(0)).toBe('less than 1 KB');
});
it('scales to KB and MB', () => {
expect(formatHistoryBytes(2048)).toBe('2 KB');
expect(formatHistoryBytes(3 * 1024 * 1024)).toBe('3.0 MB');
});
it('survives junk input rather than printing NaN into the UI', () => {
expect(formatHistoryBytes(-5)).toBe('less than 1 KB');
expect(formatHistoryBytes(NaN as unknown as number)).toBe('less than 1 KB');
expect(formatHistoryBytes(undefined as unknown as number)).toBe('less than 1 KB');
});
});
describe('computeHistoryTruncationNotice (issue #258)', () => {
const { computeHistoryTruncationNotice } = loadHelpers();
it('stays hidden when the replay was complete', () => {
const notice = computeHistoryTruncationNotice({ truncated: false, fullSize: 100, retainedBytes: 100 });
expect(notice.visible).toBe(false);
expect(notice.canLoadMore).toBe(false);
});
it('offers to load more after an intentional tail replay', () => {
const notice = computeHistoryTruncationNotice({
truncated: true,
reason: 'tail',
source: 'history',
fullSize: 5 * 1024 * 1024,
retainedBytes: 1024 * 1024,
});
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toContain('1.0 MB');
expect(notice.message).toContain('more may still be retained');
});
it('promises nothing more once the FULL capture itself hit the ceiling', () => {
// This is the case the old boolean could not express: a full-history pull
// that was still capped means tmux has already given everything it has.
const notice = computeHistoryTruncationNotice({
truncated: true,
reason: 'capped',
source: 'mux-full-history',
fullSize: 40 * 1024 * 1024,
retainedBytes: 2 * 1024 * 1024,
});
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(false);
expect(notice.message).toContain('cannot be recovered');
});
it('reports exhaustion when a full pull was refused as a downgrade', () => {
// _replayWouldShrinkBuffer refused: the browser holds MORE than tmux can
// return (a repaint-mode pane keeps no history), so offering "load more"
// would be offering to destroy history.
const notice = computeHistoryTruncationNotice({
truncated: true,
reason: 'tail',
source: 'history',
fullSize: 900000,
retainedBytes: 500000,
exhausted: true,
});
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(false);
expect(notice.message).toContain('no longer kept');
});
it('lets exhaustion outrank a would-be recoverable state', () => {
const recoverable = { truncated: true, reason: 'tail', source: 'history', fullSize: 900, retainedBytes: 100 };
expect(computeHistoryTruncationNotice(recoverable).canLoadMore).toBe(true);
expect(computeHistoryTruncationNotice({ ...recoverable, exhausted: true }).canLoadMore).toBe(false);
});
});
describe('the in-terminal truncation line is gone (static guard)', () => {
it('no longer writes the notice into terminal output', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
// The whole point of #258 is that this notice is no longer part of the
// scrollback it describes.
expect(app).not.toContain('earlier output truncated for performance');
});
it('renders the banner through textContent, never innerHTML', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const start = app.indexOf('_renderHistoryTruncationBanner() {');
expect(start).toBeGreaterThan(-1);
const body = app.slice(start, app.indexOf('\n _shouldFocusTerminalForTabSwitch', start));
expect(body).not.toContain('innerHTML');
});
});
+2 -2
View File
@@ -351,7 +351,7 @@ describe('Virtual Keyboard', () => {
bottomRestores++;
};
KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true });
KeyboardHandler._scheduleViewportSettle({ restoreScroll: true });
await new Promise((resolve) => setTimeout(resolve, 30));
KeyboardHandler._scheduleViewportSettle();
await new Promise((resolve) => setTimeout(resolve, 30));
@@ -446,7 +446,7 @@ describe('Virtual Keyboard', () => {
// A real transition arms the work; a following wiggle defers it but the
// settle still fires exactly once.
KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true });
KeyboardHandler._scheduleViewportSettle({ restoreScroll: true });
await new Promise((resolve) => setTimeout(resolve, 30));
KeyboardHandler._deferViewportSettle();
await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS + 80));
+72
View File
@@ -55,6 +55,7 @@ vi.mock('../../src/remote-hosts.js', async (orig) => {
});
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
interface LocalHarness {
app: FastifyInstance;
@@ -632,6 +633,77 @@ describe('session-routes', () => {
expect(body.data.terminalBuffer).toBeDefined();
});
// ── #258: a single `truncated` boolean could not distinguish "we tailed for
// speed, the rest is still there" from "the oldest bytes are gone". The UI
// needs that difference to know whether offering "Load full history" is a
// promise it can keep.
describe('truncation reason (#258)', () => {
const lines = (n: number) => Array.from({ length: n }, (_, i) => `history line ${i}`).join('\n');
beforeEach(() => {
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null);
harness.ctx._session.mode = 'shell';
});
it('reports no reason when nothing was cut', async () => {
harness.ctx._session.terminalBuffer = 'short buffer';
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
const body = JSON.parse(res.body);
expect(body.data.truncated).toBe(false);
expect(body.data.truncationReason).toBeNull();
expect(body.data.retainedBytes).toBe(body.data.terminalBuffer.length);
});
it("reports 'tail' for an intentional partial replay", async () => {
harness.ctx._session.terminalBuffer = lines(4000);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=500`,
});
const body = JSON.parse(res.body);
expect(body.data.truncated).toBe(true);
expect(body.data.truncationReason).toBe('tail');
// fullSize describes what existed, retainedBytes what was sent.
expect(body.data.retainedBytes).toBeLessThan(body.data.fullSize);
});
it("reports 'capped' when the byte ceiling dropped the oldest output", async () => {
harness.ctx.getTerminalHistoryConfig = vi.fn(async () => ({
...resolveTerminalHistoryConfig({}),
terminalBufferMaxBytes: 2000,
}));
harness.ctx._session.terminalBuffer = lines(4000);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
const body = JSON.parse(res.body);
expect(body.data.truncated).toBe(true);
expect(body.data.truncationReason).toBe('capped');
});
it("keeps 'capped' when a tail cut lands on top of it", async () => {
// Both sites fire. 'capped' is the stronger statement (bytes are gone),
// so a subsequent tail must not downgrade it to the recoverable reason.
harness.ctx.getTerminalHistoryConfig = vi.fn(async () => ({
...resolveTerminalHistoryConfig({}),
terminalBufferMaxBytes: 2000,
}));
harness.ctx._session.terminalBuffer = lines(4000);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=500`,
});
const body = JSON.parse(res.body);
expect(body.data.truncationReason).toBe('capped');
});
});
it('does not strip VPA-like shell scrollback as Ink redraw bloat', async () => {
const shellHistory = Array.from(
{ length: 3000 },
+216
View File
@@ -0,0 +1,216 @@
// Port: none (pure logic in a vm context — no browser, no server).
//
// Issue #259: opening or closing the mobile keyboard forced the terminal to the
// bottom, so a user reading scrollback was yanked down to the live output. The
// settle cycle now captures scroll intent BEFORE the keyboard reflow and returns
// to that anchor instead.
//
// This lives outside test/mobile/ deliberately. That suite is Playwright-driven
// and EXCLUDED from `npm run test:ci` (config/vitest.ci.config.ts), so a
// regression guarded only there is invisible to CI — the exact blind spot that
// let the #279/#280 merge land a red mobile suite behind two green checks.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const SOURCE = readFileSync(resolve(PUBLIC, 'mobile-handlers.js'), 'utf8');
interface FakeTerminal {
buffer: { active: { viewportY: number; baseY: number } };
scrollToBottom: () => void;
scrollToLine: (line: number) => void;
}
/**
* Load mobile-handlers.js and hand back its KeyboardHandler.
*
* The module declares `const KeyboardHandler = {...}` at top level, and a
* lexical binding does not survive to the next vm.runInContext call, so the
* export is appended to the SAME script rather than read back afterwards.
*/
function loadKeyboardHandler(opts: { viewportY: number; baseY: number }) {
const calls: string[] = [];
const terminal: FakeTerminal = {
buffer: { active: { viewportY: opts.viewportY, baseY: opts.baseY } },
scrollToBottom: () => calls.push('scrollToBottom'),
scrollToLine: (line: number) => calls.push(`scrollToLine:${line}`),
};
const app: any = {
terminal,
fitAddon: { fit: () => calls.push('fit') },
// The real predicate (terminal-ui.js isTerminalAtBottom), reproduced so the
// test exercises the same tolerance the runtime uses.
isTerminalAtBottom: () => terminal.buffer.active.viewportY >= terminal.buffer.active.baseY - 2,
relayoutMobileSubagentWindows: () => {},
};
let pendingTimer: (() => void) | null = null;
const context = vm.createContext({
console,
window: { scrollTo: () => {}, matchMedia: () => ({ matches: false }), addEventListener: () => {} },
document: { body: { classList: { add: () => {}, remove: () => {} } }, addEventListener: () => {} },
navigator: { userAgent: 'test', maxTouchPoints: 0 },
app,
setTimeout: (fn: () => void) => {
pendingTimer = fn;
return 1;
},
clearTimeout: () => {
pendingTimer = null;
},
});
vm.runInContext(`${SOURCE}\n;globalThis.__KeyboardHandler = KeyboardHandler;`, context, {
filename: 'mobile-handlers.js',
});
const kh = (context as any).__KeyboardHandler;
// Stub the layout side effects the settle timer fires alongside the scroll.
kh._shrinkPaddingToFit = () => {};
kh._sendTerminalResize = () => {};
return {
kh,
terminal,
calls,
/** Run the coalesced settle timer the way the OS animation eventually would. */
settle: () => {
const fn = pendingTimer;
pendingTimer = null;
fn?.();
},
};
}
describe('keyboard settle preserves scroll intent (issue #259)', () => {
it('scrolls to bottom when the user is following live output', () => {
const { kh, calls, settle } = loadKeyboardHandler({ viewportY: 500, baseY: 500 });
kh._scheduleViewportSettle({ restoreScroll: true });
settle();
expect(calls).toContain('scrollToBottom');
expect(calls.some((c) => c.startsWith('scrollToLine'))).toBe(false);
});
it('returns to the anchor instead of the bottom when the user is reading history', () => {
const { kh, calls, settle } = loadKeyboardHandler({ viewportY: 120, baseY: 500 });
kh._scheduleViewportSettle({ restoreScroll: true });
settle();
expect(calls).toContain('scrollToLine:120');
expect(calls).not.toContain('scrollToBottom');
});
it('captures the anchor BEFORE the reflow, not after', () => {
// The OS emits several viewport heights per animation, so the settle is
// re-scheduled repeatedly. Only the first capture predates fit(); a later
// one would read a viewportY the reflow had already moved.
const { kh, terminal, calls, settle } = loadKeyboardHandler({ viewportY: 120, baseY: 500 });
kh._scheduleViewportSettle({ restoreScroll: true });
terminal.buffer.active.viewportY = 480; // reflow drags the viewport down
kh._scheduleViewportSettle({ restoreScroll: true });
settle();
expect(calls).toContain('scrollToLine:120');
});
it('clamps an anchor that outlives the buffer it was captured from', () => {
const { kh, terminal, calls, settle } = loadKeyboardHandler({ viewportY: 400, baseY: 500 });
kh._scheduleViewportSettle({ restoreScroll: true });
terminal.buffer.active.baseY = 90; // buffer shrank under us
settle();
expect(calls).toContain('scrollToLine:90');
});
it('leaves the terminal alone when the settle was not a keyboard transition', () => {
const { kh, calls, settle } = loadKeyboardHandler({ viewportY: 120, baseY: 500 });
kh._scheduleViewportSettle({});
settle();
expect(calls).toContain('fit');
expect(calls).not.toContain('scrollToBottom');
expect(calls.some((c) => c.startsWith('scrollToLine'))).toBe(false);
});
});
describe('keyboard show/hide route through the intent-preserving path (static guard)', () => {
it('both transitions ask to restore scroll, never to force the bottom', () => {
// Slice from the METHOD DEFINITIONS ("\n name() {"), not the first
// occurrence of the name — both are called from _checkKeyboard() further up.
const bodyOf = (name: string) => {
const start = SOURCE.indexOf(`\n ${name}() {`);
expect(start, `${name} definition not found`).toBeGreaterThan(-1);
return SOURCE.slice(start, SOURCE.indexOf('\n },', start));
};
const show = bodyOf('onKeyboardShow');
const hide = bodyOf('onKeyboardHide');
expect(show).toContain('_scheduleViewportSettle({ restoreScroll: true })');
expect(hide).toContain('_scheduleViewportSettle({ restoreScroll: true })');
// The old unconditional call must not come back.
expect(SOURCE).not.toContain('scrollToBottom: true');
});
});
describe('backpressure refresh keeps a reader in place (issue #259)', () => {
// _onSessionNeedsRefresh is SERVER-triggered: it fires after SSE backpressure
// clears and rewrites the whole buffer. A user quietly reading scrollback did
// not ask for it, so being dropped to the bottom by it is the same bug as the
// keyboard yank, with no gesture to blame it on.
const loadConstants = () => {
const context = vm.createContext({ console, window: {}, document: {}, navigator: { userAgent: 'test' } });
vm.runInContext(
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}\n;globalThis.__fn = computeRewriteScrollLine;`,
context,
{ filename: 'constants.js' }
);
return (context as any).__fn as (i: { linesFromBottom?: number; baseY?: number }) => number | null;
};
it('returns null (scroll to bottom) for someone following live output', () => {
const computeRewriteScrollLine = loadConstants();
expect(computeRewriteScrollLine({ linesFromBottom: 0, baseY: 900 })).toBeNull();
});
it('holds the reader the same distance from the bottom of the NEW buffer', () => {
const computeRewriteScrollLine = loadConstants();
// The rewrite replaces the buffer, so the old absolute line is meaningless;
// 50 lines up stays 50 lines up even though baseY changed.
expect(computeRewriteScrollLine({ linesFromBottom: 50, baseY: 900 })).toBe(850);
expect(computeRewriteScrollLine({ linesFromBottom: 50, baseY: 400 })).toBe(350);
});
it('clamps when the refreshed buffer is shorter than the old offset', () => {
const computeRewriteScrollLine = loadConstants();
expect(computeRewriteScrollLine({ linesFromBottom: 900, baseY: 100 })).toBe(0);
});
it('is wired into the refresh path instead of an unconditional scrollToBottom', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const start = app.indexOf('async _onSessionNeedsRefresh()');
expect(start).toBeGreaterThan(-1);
const body = app.slice(start, app.indexOf('\n async _onSessionClearTerminal', start));
expect(body).toContain('computeRewriteScrollLine');
// The bottom is now one branch of a decision, never the whole story.
expect(body).toContain('this.terminal.scrollToLine(target)');
});
it('recovers FULL history, guarded against a repaint-pane downgrade', () => {
// Measured before the fix: this path rewrote an 869-row buffer from a 1MB
// tail and left 158 rows, so the refresh meant to REPAIR the terminal was
// destroying most of its scrollback. It asks for full history now, and
// falls back to the tail only when the full capture would shrink the buffer
// (a repaint-mode pane keeps roughly one frame in tmux).
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const start = app.indexOf('async _onSessionNeedsRefresh()');
const body = app.slice(start, app.indexOf('\n async _onSessionClearTerminal', start));
expect(body).toContain('terminal?full=1');
expect(body).toContain('this._replayWouldShrinkBuffer(data.terminalBuffer)');
// The tail must survive as the fallback, not vanish.
expect(body).toContain('tail=${TERMINAL_TAIL_SIZE}');
});
});
+3 -1
View File
@@ -108,7 +108,9 @@ describe('full-history re-pull downgrade guard (issue #205 round 2)', () => {
it('is wired into _maybeRefetchFullHistory BEFORE the destructive reset', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const start = source.indexOf('async _maybeRefetchFullHistory()');
// Anchor on the open paren, not the full empty signature: the method takes
// options since #258 ({ force }) and this guard is about ORDER, not arity.
const start = source.indexOf('async _maybeRefetchFullHistory(');
const guard = source.indexOf('this._replayWouldShrinkBuffer(buffer)', start);
const reset = source.indexOf('this._resetTerminalForReplay()', start);