Merge pull request #681: fix(terminal): load tmux history instead of paging Claude after a hollow tab switch

This commit is contained in:
Codeman maintainer committed 2026-10-24 15:19:16 +02:00
commit d38044e2ac
9 files changed
+312 -18

No files matched your search

@@ -180,3 +180,101 @@ describe('tab switch to a pane that keeps no scrollback', () => {
await context.close();
}, 90_000);
});
describe('a tab switch whose tail replays no scrollback while the pane holds some', () => {
it('scrolls by pulling the full history instead of paging the CLI', async () => {
// After a server restart the byte recording is only the tmux client's attach
// repaint (home + clear + absolute redraws), so the tail a second select
// replays leaves baseY 0 while the response still reports tmux scrollback.
// The scroll used to page the CLI (PageUp into an inline Claude's composer);
// it now falls through to the local path, whose top-of-buffer pull fetches
// `full=1` and the gesture then scrolls real history.
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
const page = await context.newPage();
await openApp(page);
const inline = await createSession(page, 'inline-claude-after-restart');
const other = await createSession(page, 'other');
const log: string[] = [];
const history = Array.from({ length: 200 }, (_, i) => `history line ${i}\r\n`).join('');
await page.route('**/api/sessions/*/terminal*', async (route) => {
const url = new URL(route.request().url());
const id = url.pathname.split('/')[3];
const full = url.searchParams.get('full') === '1';
log.push(`${id}:${full ? 'full' : 'tail'}`);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
terminalBuffer: full ? `${history}frame of ${id}\r\n` : `\x1b[H\x1b[2Jframe of ${id}\r\n`,
status: 'idle',
fullSize: 4096,
retainedBytes: 4096,
truncated: false,
truncationReason: null,
source: full ? 'mux-full-history' : 'mux-visible',
paneHistoryLines: 200,
},
}),
});
});
await select(page, inline);
await select(page, other);
await select(page, inline);
expect(log).toEqual([`${inline}:full`, `${other}:full`, `${inline}:tail`]);
const baseYAfterTail = await page.evaluate(
() => (window as unknown as { app: { terminal: any } }).app.terminal.buffer.active.baseY
);
expect(baseYAfterTail).toBe(0);
// The prerequisite the guard answers: by the old rule this pane IS hollow.
expect(await page.evaluate(() => (window as unknown as { app: any }).app._localScrollbackIsHollow())).toBe(true);
// Record what the scroll sends to the CLI, then wheel up over the terminal.
await page.evaluate(() => {
const app = (window as unknown as { app: any }).app;
app.__sentToCli = [];
app._sendInputEphemeral = (_id: string, data: string) => app.__sentToCli.push(data);
});
log.length = 0;
const box = await page.locator('#terminalContainer').boundingBox();
if (!box) throw new Error('terminal container not laid out');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, -400);
await page.waitForFunction(
() => (window as unknown as { app: { terminal: any } }).app.terminal.buffer.active.baseY > 0,
null,
{ timeout: 10_000 }
);
expect(log).toEqual([`${inline}:full`]);
// The next gesture scrolls the recovered history itself: older text comes
// into view, nothing further is fetched, and the CLI still receives nothing
// (the page-key queue flushes 40 ms after a gesture, so settle first).
await page.mouse.wheel(0, -400);
await page.waitForFunction(
() => {
const b = (window as unknown as { app: { terminal: any } }).app.terminal.buffer.active;
return b.viewportY < b.baseY;
},
null,
{ timeout: 10_000 }
);
await page.waitForTimeout(200);
const view = await page.evaluate(() => {
const t = (window as unknown as { app: { terminal: any } }).app.terminal;
const b = t.buffer.active;
const lines: string[] = [];
for (let i = b.viewportY; i < b.viewportY + t.rows; i++) lines.push(b.getLine(i)?.translateToString(true) ?? '');
return lines.join('\n');
});
expect(view).toContain('history line ');
expect(log).toEqual([`${inline}:full`]);
expect(await page.evaluate(() => (window as unknown as { app: any }).app.__sentToCli)).toEqual([]);
await deleteSession(page, inline);
await deleteSession(page, other);
await context.close();
}, 90_000);
});
+17
View File
@@ -77,4 +77,21 @@ describe('every terminal response feeds it (static guard)', () => {
expect(methodSource(APP, '_onSessionNeedsRefresh')).toContain('this._notePaneHistory?.(sessionId, data);');
expect(methodSource(APP, '_maybeRefetchFullHistory')).toContain('this._notePaneHistory?.(sessionId, payload);');
});
it('the refresh records the pane only once it still owns the terminal, and no caller records an error envelope', () => {
// A refresh that lost its owner is older than what the switch back read; an
// error envelope has no count, and forgetting on it would hand a scroll back
// to the paging fallback (see _maybePageCliTranscript).
const refresh = methodSource(APP, '_onSessionNeedsRefresh');
const ownerCheck = refresh.indexOf('this._terminalRefreshOwner !== refreshOwner) return false;');
expect(ownerCheck).toBeGreaterThan(-1);
expect(refresh.indexOf('this._notePaneHistory?.(sessionId, data);')).toBeGreaterThan(ownerCheck);
expect(refresh).toContain('capture.json?.success !== false) this._notePaneHistory?.(sessionId, data);');
expect(methodSource(APP, 'selectSession')).toContain(
'capture.json?.success !== false) this._notePaneHistory?.(sessionId, data);'
);
expect(methodSource(APP, '_maybeRefetchFullHistory')).toContain(
'capture.json?.success !== false) this._notePaneHistory?.(sessionId, payload);'
);
});
});
+82 -1
View File
@@ -24,6 +24,10 @@
* the real `_setHistoryTruncation` and the real `computeHistoryTruncationNotice`
* to pin what the user is actually told.
*
* The last block pins the pull's own tab-switch guard: a capture that lands
* after A -> B -> A is dropped by the select generation, since the session id
* alone matches again while the switch back is still replaying.
*
* The method is extracted from app.js and run in a `vm` against stubs (no jsdom
* on this box; see connection-indicator.test.ts), with the REAL row estimators
* from terminal-ui.js, which decide both the downgrade and the no-gain skip.
@@ -118,17 +122,19 @@ function makeApp(
activeSessionId: 's1',
sessions: new Map([['s1', { mode }]]),
detachedSessions: new Set<string>(),
_selectGeneration: 7,
_fullHistoryRepullInFlight: false,
_isLoadingBuffer: false,
_fullHistoryRepullAt: new Map<string, number>(),
_fullHistoryRepullUseless: new Set<string>(),
_paneHistoryLines: new Map<string, number>(),
terminalBufferCache: new Map<string, string>(),
terminal: {
cols: 80,
rows: 30,
// xterm's scrollback option; 0 leaves the browser-cap check out of a test.
options: { scrollback },
buffer: { active: { length: bufferRows } },
buffer: { active: { length: bufferRows, baseY: 0 } },
scrollToLine: vi.fn(),
scrollToTop: vi.fn(),
},
@@ -144,6 +150,7 @@ function makeApp(
}),
_recordTerminalLoadTiming: vi.fn(),
_logScrollRouting: vi.fn(),
_notePaneHistory: vi.fn(),
// The real method behind a spy, so a test sees both what it was called with
// and the banner state (`_historyTruncation`) it leaves behind.
_historyTruncation: new Map<string, unknown>(),
@@ -358,3 +365,77 @@ describe('_replayWouldShrinkBuffer takes rows the caller already estimated', ()
expect(ctx._estimateReplayRows).toHaveBeenCalledTimes(1);
});
});
describe('a pull that outlives a tab switch never rewrites the terminal', () => {
it('drops the capture when the select generation moved during the fetch, even back to the same session', async () => {
// A -> B -> A while the request was out: the active id matches again, but
// selectSession is replaying its own capture, and a reset here would land
// in the middle of that write.
const { app } = makeApp('claude', { bufferRows: 40, capture: lines(300) });
app._fetchTerminalCapture.mockImplementationOnce(async () => {
app._selectGeneration = 9;
return {
headersAt: performance.now(),
headers: { get: () => '' },
json: { data: { terminalBuffer: lines(300), source: 'mux-full-history' } },
};
});
await refetch.call(app);
expect(app._resetTerminalForReplay).not.toHaveBeenCalled();
expect(app.chunkedTerminalWrite).not.toHaveBeenCalled();
expect(app._fullHistoryRepullInFlight).toBe(false);
// Nor does the stale response overwrite what the switch back recorded about the pane.
expect(app._notePaneHistory).not.toHaveBeenCalled();
});
it('replays when no switch happened, and records the pane', async () => {
const { app } = makeApp('claude', { bufferRows: 40, capture: lines(300) });
await refetch.call(app);
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
expect(app.chunkedTerminalWrite).toHaveBeenCalledTimes(1);
expect(app._notePaneHistory).toHaveBeenCalledWith('s1', expect.objectContaining({ source: 'mux-full-history' }));
});
});
describe('an unloaded buffer is not held to an older downgrade refusal', () => {
it('pulls on the normal cooldown when the buffer is one screen and the pane holds scrollback', async () => {
// A refused pull latches a 60 s cooldown because the browser then held MORE
// than tmux. A tab switch that replays a hollow tail voids that premise, and
// with paging suppressed for such a buffer the latch would leave the gesture
// dead for up to a minute.
const { app, urls } = makeApp('claude', { bufferRows: 30, capture: lines(300) });
app._fullHistoryRepullUseless.add('s1');
app._fullHistoryRepullAt.set('s1', Date.now() - 10_000);
app.terminal.buffer.active.baseY = 0;
app._paneHistoryLines.set('s1', 11_344);
await refetch.call(app);
expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']);
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
});
it('keeps the latch while the buffer still holds the history the refusal was about', async () => {
const { app, urls } = makeApp('claude', { bufferRows: 300, capture: lines(300) });
app._fullHistoryRepullUseless.add('s1');
app._fullHistoryRepullAt.set('s1', Date.now() - 10_000);
app.terminal.buffer.active.baseY = 270;
app._paneHistoryLines.set('s1', 11_344);
await refetch.call(app);
expect(urls).toEqual([]);
});
it('records nothing from an error envelope, so the remembered count survives for the retry', async () => {
// A JSON 5xx parses like any response. Recording its empty data would
// forget the count and hand the next gesture back to the paging fallback.
const { app } = makeApp('claude', { bufferRows: 30, capture: lines(300) });
app._paneHistoryLines.set('s1', 11_344);
app._fetchTerminalCapture.mockImplementationOnce(async () => ({
headersAt: performance.now(),
headers: { get: () => '' },
json: { success: false, error: 'tmux capture failed' },
}));
await refetch.call(app);
expect(app._notePaneHistory).not.toHaveBeenCalled();
expect(app._resetTerminalForReplay).not.toHaveBeenCalled();
expect(app._fullHistoryRepullInFlight).toBe(false);
});
});
+36
View File
@@ -394,6 +394,42 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
});
it('falls through to local scrolling when tmux still holds the scrollback the replay left out', () => {
// After a server restart a tab switch replays a byte tail that is only the
// tmux client's attach repaint: baseY 0, while the same response reported
// tmux scrollback (`paneHistoryLines`). Paging there sent PageUp into an
// inline Claude's composer; the local path's scroll-to-top pull
// (_maybeRefetchFullHistory) is what loads that scrollback.
const { app, sent, logs } = hollowApp();
app._pageKeyGesture = { stale: true };
// A page key queued before the count was known must not reach the CLI either.
app._wheelSgrQueue = '\x1b[5~';
app._paneHistoryLines = new Map([['sess-1', 64]]);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
expect(app._maybePageCliTranscript({ shiftKey: false }, 18)).toBe(false);
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
expect(app._pageKeyGesture).toBeNull();
// No routing line of its own: the caller's `local-scrollback` line (with
// `localScrollbackRows=0`) is the record, and a second line per frame
// would alternate with it until the pull lands.
expect(logs).toEqual([]);
// 0 is a pane that keeps no scrollback (fullscreen claude, codex, opencode):
// paged as before, and so is unknown (no capture yet, an older server).
app._paneHistoryLines.set('sess-1', 0);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
app._paneHistoryLines.delete('sess-1');
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
delete app._paneHistoryLines;
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
// An open dialog still wins: nothing scrolls under it and no pull starts.
app._paneHistoryLines = new Map([['sess-1', 64]]);
app.tabAlerts = new Map([['sess-1', 'action']]);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
});
it('pages a Codex transcript only while its local scrollback is empty', () => {
// Codex without observed mouse tracking retains its PageUp/PageDown fallback.
const { app, sent } = hollowApp();