mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Closes #259, closes #258. Both bottom out in the same gap: nothing tracked whether the user was following live output or reading history. #259 — the keyboard path forced the terminal to the bottom unconditionally (onKeyboardShow/onKeyboardHide passed scrollToBottom:true, applied with no check), so opening the keyboard while scrolled up yanked the user down. The settle cycle now captures intent on its FIRST event, before any fit() has reflowed the buffer, and returns to that anchor when the user was reading. A later capture would read an already-moved viewportY, which is why the capture point matters. The param is renamed restoreScroll to match. Separately, flushPendingWrites gated viewport preservation on _hasRecentUserScrollUp(), a 1500ms decay window, so a user who scrolled up and then actually READ for longer lost protection mid-read. Being scrolled up IS the intent however long ago it was expressed, so it now keys off position. The recency window stays as a race guard on the sticky scroll-to-bottom. The full-history repull already held the user's place and is unchanged. #258 — truncation was reported by a grey line written INTO the terminal ("earlier output truncated"), which scrolls away with the output it describes, cannot be acted on, and said the same thing whether the rest was one click away or gone forever. The server set one `truncated` boolean at two sites meaning opposite things, and the client discarded fullSize and source entirely. The route now reports truncationReason ('tail' = intentional partial replay, the rest is retained; 'capped' = the byte ceiling dropped it) plus retainedBytes, and 'capped' is not downgraded by a later tail cut. The client renders a dismissible banner outside terminal output with three honest states: recoverable (offers Load full history), at-ceiling, and exhausted. The Load button forces past the scroll cooldown but NOT past _replayWouldShrinkBuffer, which still refuses a downgrade for repaint-mode panes. The banner is an overlay, not a flex child: FitAddon derives rows/cols from the terminal parent's computed height, so occupying real layout space would SIGWINCH the CLI on every truncation-state change. Verified in a real browser on the 7 skins: banner text and button clear 4.5:1 contrast on all of them, and terminal height is byte-identical with the banner shown. The first cut used --bg-elevated and --accent-muted, which do not exist, so light skins rendered a hardcoded dark bar under dark text; it now uses only tokens every skin redefines. test/terminal-scroll-intent.test.ts lives outside test/mobile/ deliberately — that suite is excluded from test:ci, so a guard placed there is invisible to CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
133 lines
5.2 KiB
TypeScript
133 lines
5.2 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('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('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('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('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');
|
|
});
|
|
});
|