mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
+139
-10
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user