mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Merge pull request #583: fix(terminal): partial-history notice only when there is history to load
This commit is contained in:
commit
f2d5af2b4c
14 files changed
+561
-15
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user