Merge pull request #588: perf(terminal): tab switch to a fullscreen Claude tab loads only its current screen

Conflict resolved in config/test-suites.ts: both new browser test globs kept.
This commit is contained in:
Codeman maintainer committed 2026-10-13 17:49:16 +02:00
commit 7ba1e51c51
8 files changed
+323 -2

No files matched your search

@@ -0,0 +1,182 @@
/**
* @fileoverview A tab switch to a pane with no tmux scrollback takes the small capture.
*
* After the first select of a page, a tab switch to a non-shell session used to
* fetch a 1 MiB tail of the server's byte stream. For a fullscreen claude pane
* (alternate screen, `#{history_size}` 0) that tail is old repaints of one frame:
* measured on live panes it cost 250-1070 ms on the server and 70-510 ms to
* parse, and an idle tab parsed it twice (the cached copy, then the fresh one,
* which never matched). The `full=1` capture of such a pane is the visible
* frame, a few KB, so `selectSession` now takes it whenever the session's last
* capture reported `paneHistoryLines: 0`, and goes back to the tail as soon as a
* capture reports scrollback again.
*
* Drives the real client in chromium against a testMode server, with the
* terminal route stubbed (the real one needs live tmux to report a hollow pane).
*
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
*
* Run: npm run test:browser -- test/fullscreen-tab-switch-capture.browser.test.ts
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(0, false, true); // testMode
await server.start();
browser = await chromium.launch({ headless: true });
}, 60_000);
afterAll(async () => {
await browser?.close();
await server?.stop();
}, 30_000);
/** What each session's pane reports, set by the test as the "pane" changes. */
type PaneHistory = Record<string, number | undefined>;
/**
* Serve every terminal fetch from a stub and log which shape was asked for.
* `source` follows the real route: `full=1` answers `mux-full-history`, anything
* else `mux-visible`. No capture geometry, so the geometry retry stays out of it.
*/
async function stubTerminal(page: Page, history: PaneHistory, log: string[]) {
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'}`);
const frame = `frame of ${id}\r\n`;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
terminalBuffer: full ? frame : `${'old repaint\r\n'.repeat(50)}${frame}`,
status: 'idle',
fullSize: full ? frame.length : 5 * 1024 * 1024,
retainedBytes: full ? frame.length : 1024 * 1024,
truncated: !full,
truncationReason: full ? null : 'tail',
source: full ? 'mux-full-history' : 'mux-visible',
paneHistoryLines: history[id],
},
}),
});
});
}
async function openApp(page: Page): Promise<void> {
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
await page.waitForFunction(() => (window as unknown as { app?: { terminal?: unknown } }).app?.terminal, null, {
timeout: 30_000,
});
}
async function createSession(page: Page, name: string): Promise<string> {
return page.evaluate(async (n) => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', name: n }),
});
const body = await res.json();
return body.data?.session?.id ?? body.data?.id ?? body.id;
}, name);
}
async function select(page: Page, sessionId: string): Promise<void> {
await page.evaluate(async (sid) => {
const app = (window as unknown as { app: { selectSession: (id: string) => Promise<void> } }).app;
await app.selectSession(sid);
}, sessionId);
}
async function deleteSession(page: Page, sessionId: string): Promise<void> {
await page.evaluate(
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
sessionId
);
}
describe('tab switch to a pane that keeps no scrollback', () => {
it('takes the small full capture on every switch, and the tail again once the pane keeps history', async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
const page = await context.newPage();
await openApp(page);
const hollow = await createSession(page, 'fullscreen-claude');
const inline = await createSession(page, 'inline-claude');
const history: PaneHistory = { [hollow]: 0, [inline]: 40_000 };
const log: string[] = [];
await stubTerminal(page, history, log);
// First select of each on this page: the canonical full replay, as before.
await select(page, hollow);
await select(page, inline);
expect(log).toEqual([`${hollow}:full`, `${inline}:full`]);
// Switching back: the hollow pane takes the frame, the inline one the tail.
log.length = 0;
await select(page, hollow);
await select(page, inline);
await select(page, hollow);
expect(log).toEqual([`${hollow}:full`, `${inline}:tail`, `${hollow}:full`]);
// The pane starts keeping history (claude switched to its inline view). The
// switch that learns it is still a full capture, which is the canonical
// load anyway; the one after goes back to the bounded tail.
history[hollow] = 1200;
log.length = 0;
await select(page, inline);
await select(page, hollow);
await select(page, inline);
await select(page, hollow);
expect(log).toEqual([`${inline}:tail`, `${hollow}:full`, `${inline}:tail`, `${hollow}:tail`]);
// Unknown is never treated as empty: a response without the field (an
// older server, a byte-history fallback) keeps the tail.
history[hollow] = 0;
log.length = 0;
await select(page, inline);
await select(page, hollow); // learns 0 from this tail response
history[hollow] = undefined;
await select(page, inline);
await select(page, hollow); // last report was 0: frame, which reports nothing
await select(page, inline);
await select(page, hollow); // forgot it: tail
expect(log).toEqual([
`${inline}:tail`,
`${hollow}:tail`,
`${inline}:tail`,
`${hollow}:full`,
`${inline}:tail`,
`${hollow}:tail`,
]);
// What the hollow switch actually paints: the frame, without the repaints.
history[hollow] = 0;
await select(page, inline);
await select(page, hollow); // learns 0
await select(page, inline);
await select(page, hollow);
const screen = await page.evaluate(() => {
const t = (window as unknown as { app: { terminal: any } }).app.terminal;
const lines: string[] = [];
for (let i = 0; i < t.buffer.active.length; i++) lines.push(t.buffer.active.getLine(i)?.translateToString(true));
return lines.join('\n');
});
expect(screen).toContain(`frame of ${hollow}`);
expect(screen).not.toContain('old repaint');
await deleteSession(page, hollow);
await deleteSession(page, inline);
await context.close();
}, 90_000);
});
+7 -1
View File
@@ -188,7 +188,13 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
it('loads a bounded shell tail first and keeps unbounded full history user-triggered', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
// A shell never takes the full capture on a tab switch. A TUI takes it on its
// first select per page, and on every select while its pane keeps no tmux
// scrollback (behaviour pinned in fullscreen-tab-switch-capture.browser.test.ts).
expect(app).toContain(
"session?.mode !== 'shell' && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId))"
);
expect(app).toContain('const paneKeepsNoHistory = this._paneHistoryLines?.get(sessionId) === 0;');
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`');
// Every terminal capture now goes through _fetchTerminalCapture, which adds
+80
View File
@@ -0,0 +1,80 @@
/**
* @fileoverview What the client remembers about a pane's tmux scrollback.
*
* `selectSession` serves a tab switch from the small `full=1` capture when the
* session's last terminal response reported `paneHistoryLines: 0` (a fullscreen
* CLI in the alternate screen), instead of the 1 MiB byte tail. The memory that
* decides it must treat ONLY a reported 0 as empty: a missing field (an older
* server, a byte-history fallback) has to forget, so the tab switch goes back to
* the bounded tail rather than trusting a stale answer.
*
* Runs the real `_notePaneHistory` / `_clearHistoryTruncation` from app.js in a
* `vm`. The switch itself is driven end to end in
* fullscreen-tab-switch-capture.browser.test.ts (browser suite).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const APP = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
function methodSource(source: string, method: string): string {
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
expect(start, `${method} not found`).toBeGreaterThan(-1);
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
}
function makeApp() {
const methods = vm.runInContext(
`({ ${methodSource(APP, '_notePaneHistory')}, ${methodSource(APP, '_clearHistoryTruncation')} })`,
vm.createContext({})
) as Record<string, (...args: unknown[]) => void>;
return {
activeSessionId: null,
_paneHistoryLines: new Map<string, number>(),
_renderHistoryTruncationBanner() {},
...methods,
} as Record<string, any>;
}
describe('_notePaneHistory', () => {
it('records the count a capture reported, 0 included', () => {
const app = makeApp();
app._notePaneHistory('s1', { paneHistoryLines: 0 });
app._notePaneHistory('s2', { paneHistoryLines: 40000 });
expect(app._paneHistoryLines.get('s1')).toBe(0);
expect(app._paneHistoryLines.get('s2')).toBe(40000);
});
it('forgets on a response without a usable count, so unknown never reads as empty', () => {
const app = makeApp();
for (const payload of [{}, { paneHistoryLines: null }, { paneHistoryLines: NaN }, { paneHistoryLines: -1 }, null]) {
app._notePaneHistory('s1', { paneHistoryLines: 0 });
app._notePaneHistory('s1', payload);
expect(app._paneHistoryLines.has('s1')).toBe(false);
}
});
it('ignores a call without a session', () => {
const app = makeApp();
app._notePaneHistory(null, { paneHistoryLines: 0 });
expect(app._paneHistoryLines.size).toBe(0);
});
it('is dropped with the session', () => {
const app = makeApp();
app._notePaneHistory('s1', { paneHistoryLines: 0 });
app._clearHistoryTruncation('s1');
expect(app._paneHistoryLines.has('s1')).toBe(false);
});
});
describe('every terminal response feeds it (static guard)', () => {
it('the tab switch, the refresh and the history re-pull all record the pane', () => {
expect(methodSource(APP, 'selectSession')).toContain('this._notePaneHistory?.(sessionId, data);');
expect(methodSource(APP, '_onSessionNeedsRefresh')).toContain('this._notePaneHistory?.(sessionId, data);');
expect(methodSource(APP, '_maybeRefetchFullHistory')).toContain('this._notePaneHistory?.(sessionId, payload);');
});
});