mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
fix(terminal): stop the backpressure refresh yanking and shrinking the buffer
Two further instances of the same root cause, both in _onSessionNeedsRefresh, which is SERVER-triggered (it fires after SSE backpressure clears) so the user has no gesture to blame the result on. 1. It ended in an unconditional scrollToBottom, so a user quietly reading scrollback was dropped to the live output by a background event. It now holds their place. The rewrite REPLACES the buffer, so an absolute viewportY captured beforehand is meaningless afterwards; distance from the bottom is the anchor that survives, via computeRewriteScrollLine(). 2. It rebuilt the terminal from a 1MB TAIL. Measured end to end on a 900-line shell pane: an 869-row buffer came back as 158 rows, so the refresh meant to REPAIR the display was destroying most of the scrollback every time it ran. It now asks for full history, and falls back to the tail only when _replayWouldShrinkBuffer refuses the capture, which keeps repaint-mode panes (tmux holds roughly one frame for them) exactly as they were. Also records truncation state here, so the #258 banner stops describing the pre-refresh buffer. Verified in a real browser against a live session: baseY 869 -> 869 where it used to be 869 -> 158, a reader 200 lines up stays 200 lines up, and a follower stays pinned to the bottom. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -155,3 +155,62 @@ describe('keyboard show/hide route through the intent-preserving path (static gu
|
||||
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}');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user