mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Conflicts resolved against #583 and #588: - One history-depth field: #583's `historyLines` / `capturedHistoryLines` (reported as `paneHistoryLines`) is kept, and #601's lines cut now reads it instead of a second `capturedHistorySize`. #601's `alternateOn` / `capturedAlternateOn` joins it, and the one display-message format string ends `#{history_size} #{alternate_on}`. - selectSession keeps #588's full=1 switch for panes with no scrollback, gated off remote and Docker sessions, and #601's SHELL_LOAD_QUERY for shells. - computeHistoryTruncationNotice names a line count when paneHistoryLines is known (#583) and otherwise uses #601's remainder wording. - docs: api-reference lists both historyLinesCut and paneHistoryLines; the notice invariant keys on history_size while the shell's automatic load is the one place alternate_on decides. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
259 lines
12 KiB
TypeScript
259 lines
12 KiB
TypeScript
// Port: none (pure helpers from constants.js in a vm context).
|
|
//
|
|
// Issue #258: terminal history is split across browser scrollback, the server
|
|
// byte buffer and tmux, and the only signal the user got was a grey line written
|
|
// INTO the terminal saying "earlier output truncated for performance". That line
|
|
// scrolls away with the output it describes, cannot be acted on, and says the
|
|
// same thing whether the rest is one click away or gone forever.
|
|
//
|
|
// computeHistoryTruncationNotice() is the pure core of the replacement banner.
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
|
|
|
function loadHelpers() {
|
|
const context = vm.createContext({ console, window: {}, document: {}, navigator: { userAgent: 'test' } });
|
|
vm.runInContext(
|
|
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}
|
|
;globalThis.__helpers = { formatHistoryBytes, computeHistoryTruncationNotice };`,
|
|
context,
|
|
{ filename: 'constants.js' }
|
|
);
|
|
return (context as any).__helpers as {
|
|
formatHistoryBytes: (n: number) => string;
|
|
computeHistoryTruncationNotice: (s: Record<string, unknown>) => {
|
|
visible: boolean;
|
|
message: string;
|
|
canLoadMore: boolean;
|
|
};
|
|
};
|
|
}
|
|
|
|
describe('formatHistoryBytes', () => {
|
|
const { formatHistoryBytes } = loadHelpers();
|
|
|
|
it('reports sub-KB amounts as a range, not a byte count', () => {
|
|
expect(formatHistoryBytes(400)).toBe('less than 1 KB');
|
|
expect(formatHistoryBytes(0)).toBe('less than 1 KB');
|
|
});
|
|
|
|
it('scales to KB and MB', () => {
|
|
expect(formatHistoryBytes(2048)).toBe('2 KB');
|
|
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');
|
|
expect(formatHistoryBytes(undefined as unknown as number)).toBe('less than 1 KB');
|
|
});
|
|
});
|
|
|
|
describe('computeHistoryTruncationNotice (issue #258)', () => {
|
|
const { computeHistoryTruncationNotice } = loadHelpers();
|
|
|
|
it('stays hidden when the replay was complete', () => {
|
|
const notice = computeHistoryTruncationNotice({ truncated: false, fullSize: 100, retainedBytes: 100 });
|
|
expect(notice.visible).toBe(false);
|
|
expect(notice.canLoadMore).toBe(false);
|
|
});
|
|
|
|
it('offers to load more after an intentional tail replay', () => {
|
|
const notice = computeHistoryTruncationNotice({
|
|
truncated: true,
|
|
reason: 'tail',
|
|
source: 'history',
|
|
fullSize: 5 * 1024 * 1024,
|
|
retainedBytes: 1024 * 1024,
|
|
});
|
|
expect(notice.visible).toBe(true);
|
|
expect(notice.canLoadMore).toBe(true);
|
|
expect(notice.message).toContain('1.0 MB');
|
|
expect(notice.message).toContain('more may still be retained');
|
|
});
|
|
|
|
it('names no amount when tmux cut the window by lines before any bytes were counted', () => {
|
|
// A shell's `lines=`-bounded load: the server knows tmux holds more, but the
|
|
// window IS the whole payload, so fullSize equals what was kept.
|
|
const notice = computeHistoryTruncationNotice({
|
|
truncated: true,
|
|
reason: 'tail',
|
|
source: 'mux-full-history',
|
|
fullSize: 800 * 1024,
|
|
retainedBytes: 800 * 1024,
|
|
});
|
|
expect(notice.canLoadMore).toBe(true);
|
|
expect(notice.message).toContain('Earlier output may still be retained');
|
|
expect(notice.message).not.toMatch(/\d+ (B|KB) more/);
|
|
});
|
|
|
|
it('names no amount when tail also cut bytes off a window tmux cut by lines', () => {
|
|
// The window was over 1 MiB: `dropped` counts only the bytes tail cut off
|
|
// it, while tmux still holds more beyond the window, so that number would
|
|
// understate what is left.
|
|
const state = {
|
|
truncated: true,
|
|
reason: 'tail',
|
|
source: 'mux-full-history',
|
|
fullSize: 3 * 1024 * 1024,
|
|
retainedBytes: 1024 * 1024,
|
|
};
|
|
const windowOnly = computeHistoryTruncationNotice({ ...state, historyLinesCut: true });
|
|
expect(windowOnly.canLoadMore).toBe(true);
|
|
expect(windowOnly.message).toContain('Earlier output may still be retained');
|
|
expect(windowOnly.message).not.toMatch(/\d+(\.\d+)? (B|KB|MB) more/);
|
|
// Without a lines cut the byte remainder IS all that is left, and is named.
|
|
expect(computeHistoryTruncationNotice(state).message).toContain('2.0 MB more may still be retained');
|
|
});
|
|
|
|
it('promises nothing more once the FULL capture itself hit the ceiling', () => {
|
|
// This is the case the old boolean could not express: a full-history pull
|
|
// that was still capped means tmux has already given everything it has.
|
|
const notice = computeHistoryTruncationNotice({
|
|
truncated: true,
|
|
reason: 'capped',
|
|
source: 'mux-full-history',
|
|
fullSize: 40 * 1024 * 1024,
|
|
retainedBytes: 2 * 1024 * 1024,
|
|
});
|
|
expect(notice.visible).toBe(true);
|
|
expect(notice.canLoadMore).toBe(false);
|
|
expect(notice.message).toContain('cannot be recovered');
|
|
});
|
|
|
|
it('reports exhaustion when a full pull was refused as a downgrade', () => {
|
|
// _replayWouldShrinkBuffer refused: the browser holds MORE than tmux can
|
|
// return (a repaint-mode pane keeps no history), so offering "load more"
|
|
// would be offering to destroy history.
|
|
const notice = computeHistoryTruncationNotice({
|
|
truncated: true,
|
|
reason: 'tail',
|
|
source: 'history',
|
|
fullSize: 900000,
|
|
retainedBytes: 500000,
|
|
exhausted: true,
|
|
});
|
|
expect(notice.visible).toBe(true);
|
|
expect(notice.canLoadMore).toBe(false);
|
|
expect(notice.message).toContain('no longer kept');
|
|
});
|
|
|
|
it('lets exhaustion outrank a would-be recoverable state', () => {
|
|
const recoverable = { truncated: true, reason: 'tail', source: 'history', fullSize: 900, retainedBytes: 100 };
|
|
expect(computeHistoryTruncationNotice(recoverable).canLoadMore).toBe(true);
|
|
expect(computeHistoryTruncationNotice({ ...recoverable, exhausted: true }).canLoadMore).toBe(false);
|
|
});
|
|
});
|
|
|
|
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');
|
|
// The whole point of #258 is that this notice is no longer part of the
|
|
// scrollback it describes.
|
|
expect(app).not.toContain('earlier output truncated for performance');
|
|
});
|
|
|
|
it('loads a bounded shell tail first and keeps unbounded full history user-triggered', () => {
|
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
|
// 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("const shellSession = session?.mode === 'shell';");
|
|
expect(app).toContain(
|
|
'const useFullHistory = !shellSession && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));'
|
|
);
|
|
expect(app).toContain('!session?.remote && !session?.docker && this._paneHistoryLines?.get(sessionId) === 0;');
|
|
// The shell's bounded load is tmux's rendered history, never the byte recording.
|
|
expect(app).toContain('`/api/sessions/${sessionId}/terminal?${SHELL_LOAD_QUERY}`');
|
|
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
|
|
// an abort deadline (a `?full=1` body can be megabytes and used to hang
|
|
// indefinitely on a stalled mobile link). The URL and the full-vs-tail
|
|
// decision this guard exists to pin are unchanged.
|
|
expect(app).toContain(': `/api/sessions/${sessionId}/terminal?full=1`,\n { full: true }');
|
|
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
|
|
expect(app).toContain("if (session?.mode === 'shell')");
|
|
// A shell scroll gesture pulls a BOUNDED window of full history; only the
|
|
// button pulls all of it (behaviour pinned in shell-scroll-history-pull.test.ts).
|
|
expect(app).toContain("const boundedShellPull = !force && session?.mode === 'shell';");
|
|
expect(app).toContain('`/api/sessions/${sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`');
|
|
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
|
|
});
|
|
|
|
it('renders the banner through textContent, never innerHTML', () => {
|
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
|
const start = app.indexOf('_renderHistoryTruncationBanner() {');
|
|
expect(start).toBeGreaterThan(-1);
|
|
const body = app.slice(start, app.indexOf('\n _shouldFocusTerminalForTabSwitch', start));
|
|
expect(body).not.toContain('innerHTML');
|
|
});
|
|
});
|