mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
fix(terminal): preserve scroll intent across keyboard resize, surface history truncation
Closes #259, closes #258. Both bottom out in the same gap: nothing tracked whether the user was following live output or reading history. #259 — the keyboard path forced the terminal to the bottom unconditionally (onKeyboardShow/onKeyboardHide passed scrollToBottom:true, applied with no check), so opening the keyboard while scrolled up yanked the user down. The settle cycle now captures intent on its FIRST event, before any fit() has reflowed the buffer, and returns to that anchor when the user was reading. A later capture would read an already-moved viewportY, which is why the capture point matters. The param is renamed restoreScroll to match. Separately, flushPendingWrites gated viewport preservation on _hasRecentUserScrollUp(), a 1500ms decay window, so a user who scrolled up and then actually READ for longer lost protection mid-read. Being scrolled up IS the intent however long ago it was expressed, so it now keys off position. The recency window stays as a race guard on the sticky scroll-to-bottom. The full-history repull already held the user's place and is unchanged. #258 — truncation was reported by a grey line written INTO the terminal ("earlier output truncated"), which scrolls away with the output it describes, cannot be acted on, and said the same thing whether the rest was one click away or gone forever. The server set one `truncated` boolean at two sites meaning opposite things, and the client discarded fullSize and source entirely. The route now reports truncationReason ('tail' = intentional partial replay, the rest is retained; 'capped' = the byte ceiling dropped it) plus retainedBytes, and 'capped' is not downgraded by a later tail cut. The client renders a dismissible banner outside terminal output with three honest states: recoverable (offers Load full history), at-ceiling, and exhausted. The Load button forces past the scroll cooldown but NOT past _replayWouldShrinkBuffer, which still refuses a downgrade for repaint-mode panes. The banner is an overlay, not a flex child: FitAddon derives rows/cols from the terminal parent's computed height, so occupying real layout space would SIGWINCH the CLI on every truncation-state change. Verified in a real browser on the 7 skins: banner text and button clear 4.5:1 contrast on all of them, and terminal height is byte-identical with the banner shown. The first cut used --bg-elevated and --accent-muted, which do not exist, so light skins rendered a hardcoded dark bar under dark text; it now uses only tokens every skin redefines. test/terminal-scroll-intent.test.ts lives outside test/mobile/ deliberately — that suite is excluded from test:ci, so a guard placed there is invisible to CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+104
-7
@@ -4509,28 +4509,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 +4559,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 +4698,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 +4953,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 +5139,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 {}
|
||||
|
||||
|
||||
@@ -785,3 +785,69 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice };
|
||||
}
|
||||
|
||||
@@ -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,157 @@
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
@@ -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