Merge pull request #583: fix(terminal): partial-history notice only when there is history to load

This commit is contained in:
Codeman maintainer committed 2026-10-13 17:47:37 +02:00
commit f2d5af2b4c
14 files changed
+561 -15

No files matched your search

+278
View File
@@ -0,0 +1,278 @@
/**
* @fileoverview The partial-history notice waits until the user reaches for history.
*
* Every tab switch after the first replays a 1 MiB TAIL of the session's byte
* stream, which is truncated for any session that has run for a while, so the
* notice ("Showing the most recent 1.0 MB of this session. 4.8 MB more may still
* be retained.") covered the top rows on nearly every switch. Its × only lasted
* until the next switch. Now:
* - it appears only once a scroll gesture reaches the top of the browser's
* buffer, after the history pull that gesture starts has settled;
* - a scroll back down to live output retires it, and so does a tab switch;
* - a dismissal sticks for that session until the page reloads.
*
* Runs the REAL methods (app.js banner + state, terminal-ui.js scroll hook,
* constants.js notice decision) in a `vm` against a stub DOM, the same way
* shell-scroll-history-pull.test.ts does (no jsdom on this box).
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const APP = readFileSync(resolve(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);
}
interface FakeEl {
tagName: string;
hidden: boolean;
className: string;
type: string;
disabled: boolean;
children: FakeEl[];
attrs: Record<string, string>;
onclick: null | (() => void);
textContent: string;
appendChild(child: FakeEl): void;
setAttribute(name: string, value: string): void;
}
function fakeEl(tagName: string): FakeEl {
let text = '';
const el: FakeEl = {
tagName,
hidden: false,
className: '',
type: '',
disabled: false,
children: [],
attrs: {},
onclick: null,
get textContent() {
return text + el.children.map((c) => c.textContent).join('');
},
set textContent(value: string) {
text = value;
el.children = [];
},
appendChild(child) {
el.children.push(child);
},
setAttribute(name, value) {
el.attrs[name] = value;
},
};
return el;
}
/** Real terminal-ui.js mixin, for `_maybeLoadMoreHistoryOnScroll` / `isTerminalAtBottom`. */
function loadTerminalMixin(): Record<string, (...args: unknown[]) => unknown> {
const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8');
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
CodemanApp: FakeCodemanApp,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
document: { addEventListener: vi.fn() },
});
vm.runInContext(source, context);
return FakeCodemanApp.prototype;
}
function makeApp() {
const bar = fakeEl('div');
bar.hidden = true;
const document = {
getElementById: (id: string) => (id === 'historyTruncationBar' ? bar : null),
createElement: (tag: string) => fakeEl(tag),
};
const methods = [
'_setHistoryTruncation',
'_clearHistoryTruncation',
'_setHistoryNoticeRevealed',
'_renderHistoryTruncationBanner',
]
.map((m) => methodSource(APP, m))
.join(',\n');
const context = vm.createContext({ document, console, window: {}, navigator: { userAgent: 'test' } });
const appMethods = vm.runInContext(
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}
;({ ${methods} })`,
context,
{ filename: 'app-methods.js' }
) as Record<string, (...args: unknown[]) => unknown>;
const mixin = loadTerminalMixin();
const buffer = { viewportY: 500, baseY: 500 };
let resolvePull: (() => void) | null = null;
const app = {
activeSessionId: 's1' as string | null,
terminal: { buffer: { active: buffer } },
// Each gesture's pull is held open until the test settles it.
_maybeRefetchFullHistory: vi.fn(
() =>
new Promise<void>((res) => {
resolvePull = res;
})
),
...appMethods,
_maybeLoadMoreHistoryOnScroll: mixin._maybeLoadMoreHistoryOnScroll,
isTerminalAtBottom: mixin.isTerminalAtBottom,
} as Record<string, any>;
const settle = async () => {
resolvePull?.();
resolvePull = null;
for (let i = 0; i < 5; i++) await Promise.resolve();
};
const scrollTo = async (viewportY: number) => {
const lines = viewportY - buffer.viewportY;
buffer.viewportY = viewportY;
app._maybeLoadMoreHistoryOnScroll(lines);
await settle();
};
return { app, bar, buffer, scrollTo, settle };
}
// What a tab switch's tail replay reports for a session with real scrollback.
const TAIL = {
truncated: true,
truncationReason: 'tail',
source: 'mux-visible',
fullSize: 5 * 1024 * 1024,
retainedBytes: 1024 * 1024,
paneHistoryLines: 40000,
};
const loadButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-load');
const dismissButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-dismiss');
describe('partial-history notice: lazy reveal', () => {
it('stays hidden after a truncated tab-switch replay', () => {
const { app, bar } = makeApp();
app._setHistoryTruncation('s1', TAIL);
expect(bar.hidden).toBe(true);
});
it('appears once a scroll reaches the top, after the pull that gesture started', async () => {
const { app, bar, buffer, settle } = makeApp();
app._setHistoryTruncation('s1', TAIL);
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
expect(app._maybeRefetchFullHistory).toHaveBeenCalledTimes(1);
// Still pulling: no notice describing the state the pull is about to replace.
expect(bar.hidden).toBe(true);
await settle();
expect(bar.hidden).toBe(false);
expect(bar.textContent).toContain('40,000 lines of scrollback are retained.');
expect(loadButton(bar)?.textContent).toBe('Load full history');
});
it('shows what the pull left: nothing, when the pull brought everything back', async () => {
const { app, bar, buffer } = makeApp();
app._setHistoryTruncation('s1', TAIL);
app._maybeRefetchFullHistory.mockImplementation(async () => {
app._setHistoryTruncation('s1', { truncated: false, source: 'mux-full-history', paneHistoryLines: 40000 });
});
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
for (let i = 0; i < 5; i++) await Promise.resolve();
expect(app._historyNoticeRevealedFor).toBe('s1');
expect(bar.hidden).toBe(true);
});
it('does not appear on the way up, only at the top', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(200);
expect(app._maybeRefetchFullHistory).not.toHaveBeenCalled();
expect(bar.hidden).toBe(true);
});
it('goes away once the user scrolls back down to live output', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
expect(bar.hidden).toBe(false);
await scrollTo(300); // still reading history
expect(bar.hidden).toBe(false);
await scrollTo(500); // back at the bottom
expect(bar.hidden).toBe(true);
});
it('never appears for a pane with no scrollback (fullscreen CLI), even at the top', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', { ...TAIL, paneHistoryLines: 0 });
await scrollTo(0);
expect(app._historyNoticeRevealedFor).toBe('s1');
expect(bar.hidden).toBe(true);
});
it('is not revealed for a tab the user switched to while the pull ran', async () => {
const { app, bar, buffer, settle } = makeApp();
app._setHistoryTruncation('s1', TAIL);
app._setHistoryTruncation('s2', TAIL);
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
app.activeSessionId = 's2';
await settle();
expect(app._historyNoticeRevealedFor ?? null).toBe(null);
expect(bar.hidden).toBe(true);
});
it('keeps a dismissal for that session, but only that session', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
dismissButton(bar)!.onclick!();
expect(bar.hidden).toBe(true);
// A new replay and another trip to the top do not bring it back.
await scrollTo(500);
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
expect(bar.hidden).toBe(true);
// Another session still gets its notice.
app.activeSessionId = 's2';
app._setHistoryTruncation('s2', TAIL);
await scrollTo(500);
await scrollTo(0);
expect(bar.hidden).toBe(false);
});
it('forgets the dismissal with the session', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
dismissButton(bar)!.onclick!();
app._clearHistoryTruncation('s1');
expect(app._historyNoticeDismissed.has('s1')).toBe(false);
});
});
describe('partial-history notice: a tab switch retires it (static guard)', () => {
it('selectSession clears the reveal before repainting the banner', () => {
const body = methodSource(APP, 'selectSession');
const reset = body.indexOf('this._historyNoticeRevealedFor = null;');
const render = body.indexOf('this._renderHistoryTruncationBanner();');
expect(reset).toBeGreaterThan(-1);
expect(render).toBeGreaterThan(reset);
});
});
+64
View File
@@ -45,6 +45,11 @@ describe('formatHistoryBytes', () => {
expect(formatHistoryBytes(3 * 1024 * 1024)).toBe('3.0 MB');
});
it('never prints "1024 KB" for a tail cut back to a line boundary just under 1 MiB', () => {
expect(formatHistoryBytes(1048351)).toBe('1.0 MB');
expect(formatHistoryBytes(1023 * 1024)).toBe('1023 KB');
});
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');
@@ -114,6 +119,65 @@ describe('computeHistoryTruncationNotice (issue #258)', () => {
});
});
describe('computeHistoryTruncationNotice: what a pull can really return (paneHistoryLines)', () => {
const { computeHistoryTruncationNotice } = loadHelpers();
// The tab-switch tail of a fullscreen claude pane, as measured on prod: the
// server cut a 5.8 MB byte stream to 1 MB, and tmux held 0 scrollback rows.
const fullscreenTail = {
truncated: true,
reason: 'tail',
source: 'mux-visible',
fullSize: 6158853,
retainedBytes: 1048351,
};
it('says nothing for a pane that keeps no scrollback, however much the byte stream lost', () => {
// The dropped bytes were old repaints of one frame, and `full=1` returns
// only the visible frame for such a pane, so the button could only ever end
// in the downgrade refusal. That is the banner that showed on every switch.
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0 });
expect(notice).toEqual({ visible: false, message: '', canLoadMore: false });
});
it('stays silent for such a pane in the exhausted and at-ceiling states too', () => {
expect(computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0, exhausted: true }).visible).toBe(
false
);
expect(
computeHistoryTruncationNotice({
...fullscreenTail,
source: 'mux-full-history',
reason: 'capped',
paneHistoryLines: 0,
}).visible
).toBe(false);
});
it('names the scrollback lines a pull can load instead of the byte gap', () => {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, source: 'history', paneHistoryLines: 48210 });
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toBe(
'Showing the most recent 1.0 MB of this session. 48,210 lines of scrollback are retained.'
);
expect(notice.message).not.toContain('4.9 MB');
});
it('uses the singular for one line', () => {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 1 });
expect(notice.message).toContain('1 line of scrollback is retained.');
});
it('keeps the byte wording when the server did not report the pane (older server, byte-history fallback)', () => {
for (const paneHistoryLines of [undefined, null, NaN]) {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines });
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toContain('more may still be retained');
}
});
});
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');
+47
View File
@@ -1029,6 +1029,53 @@ describe('session-routes', () => {
expect(body.data.captureRows).toBeUndefined();
});
it('reports how many scrollback rows tmux holds for the captured pane', async () => {
// `truncated` measures the byte stream; this is what a `full=1` pull can
// return. 0 (a fullscreen CLI in the alternate screen) is the case where
// the client must not offer to load the rest.
harness.ctx._session.terminalBuffer = 'x'.repeat(4096);
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(
_name: string,
opts?: { capturedGeometry?: { cols: number; rows: number }; capturedHistoryLines?: number }
) => {
if (opts) {
opts.capturedGeometry = { cols: 100, rows: 50 };
opts.capturedHistoryLines = 0;
}
return 'visible frame';
}
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=1024`,
});
const body = JSON.parse(res.body);
expect(body.data.truncated).toBe(true);
expect(body.data.paneHistoryLines).toBe(0);
});
it('omits paneHistoryLines when the body is the byte history, not a capture', async () => {
harness.ctx._session.terminalBuffer = 'byte history only';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { capturedHistoryLines?: number }) => {
if (opts) opts.capturedHistoryLines = 12;
return null;
}
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
const body = JSON.parse(res.body);
expect(body.data.source).toBe('history');
expect(body.data.paneHistoryLines).toBeUndefined();
});
// ── COD-47: full tmux scrollback replay on full page reload ──
it('full reload (?full=1) requests full tmux history and replays boundary markers', async () => {
// A realistic scrollback-length capture: ~5000 lines, well past one screen.
+43 -1
View File
@@ -11,7 +11,7 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent } from '../src/tmux-manager.js';
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor } from '../src/tmux-manager.js';
describe('tmux full-history pane capture (COD-47)', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
@@ -153,6 +153,48 @@ describe('the geometry a capture reports back', () => {
});
});
describe('the capture reports how much scrollback tmux holds (#{history_size})', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart));
it('reads it from the one cursor query the capture already makes', () => {
// No extra tmux call: every capture is a synchronous exec on the server.
expect(methodBody).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'");
expect(methodBody.match(/display-message/g)).toHaveLength(1);
expect(methodBody).toContain('opts.capturedHistoryLines = geometry.historyLines');
});
it('parses the fifth field as historyLines', () => {
expect(queryPaneCursor(() => '3 5 120 40 812\n')).toEqual({
cursorX: 3,
cursorY: 5,
cols: 120,
rows: 40,
historyLines: 812,
});
// A pane in the alternate screen (fullscreen claude) holds none.
expect(queryPaneCursor(() => '0 31 187 32 0')?.historyLines).toBe(0);
});
it('never lets a missing or bad history field void the caret', () => {
for (const raw of ['3 5 120 40', '3 5 120 40 ', '3 5 120 40 x', '3 5 120 40 -1']) {
const geometry = queryPaneCursor(() => raw);
expect(geometry).toEqual({ cursorX: 3, cursorY: 5, cols: 120, rows: 40 });
expect(geometry && 'historyLines' in geometry).toBe(false);
}
});
it('still returns null when the geometry itself is bad', () => {
expect(queryPaneCursor(() => 'x 5 120 40 812')).toBeNull();
expect(
queryPaneCursor(() => {
throw new Error('no pane');
})
).toBeNull();
});
});
describe('why a capture has to report its height', () => {
it('a snapshot addresses rows the receiving terminal may not have', () => {
// formatPaneSnapshot positions every row absolutely. A terminal shorter