mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09: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>
158 lines
6.2 KiB
TypeScript
158 lines
6.2 KiB
TypeScript
// Port: none (pure logic in a vm context — no browser, no server).
|
|
//
|
|
// Issue #259: opening or closing the mobile keyboard forced the terminal to the
|
|
// bottom, so a user reading scrollback was yanked down to the live output. The
|
|
// settle cycle now captures scroll intent BEFORE the keyboard reflow and returns
|
|
// to that anchor instead.
|
|
//
|
|
// This lives outside test/mobile/ deliberately. That suite is Playwright-driven
|
|
// and EXCLUDED from `npm run test:ci` (config/vitest.ci.config.ts), so a
|
|
// regression guarded only there is invisible to CI — the exact blind spot that
|
|
// let the #279/#280 merge land a red mobile suite behind two green checks.
|
|
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');
|
|
const SOURCE = readFileSync(resolve(PUBLIC, 'mobile-handlers.js'), 'utf8');
|
|
|
|
interface FakeTerminal {
|
|
buffer: { active: { viewportY: number; baseY: number } };
|
|
scrollToBottom: () => void;
|
|
scrollToLine: (line: number) => void;
|
|
}
|
|
|
|
/**
|
|
* Load mobile-handlers.js and hand back its KeyboardHandler.
|
|
*
|
|
* The module declares `const KeyboardHandler = {...}` at top level, and a
|
|
* lexical binding does not survive to the next vm.runInContext call, so the
|
|
* export is appended to the SAME script rather than read back afterwards.
|
|
*/
|
|
function loadKeyboardHandler(opts: { viewportY: number; baseY: number }) {
|
|
const calls: string[] = [];
|
|
const terminal: FakeTerminal = {
|
|
buffer: { active: { viewportY: opts.viewportY, baseY: opts.baseY } },
|
|
scrollToBottom: () => calls.push('scrollToBottom'),
|
|
scrollToLine: (line: number) => calls.push(`scrollToLine:${line}`),
|
|
};
|
|
const app: any = {
|
|
terminal,
|
|
fitAddon: { fit: () => calls.push('fit') },
|
|
// The real predicate (terminal-ui.js isTerminalAtBottom), reproduced so the
|
|
// test exercises the same tolerance the runtime uses.
|
|
isTerminalAtBottom: () => terminal.buffer.active.viewportY >= terminal.buffer.active.baseY - 2,
|
|
relayoutMobileSubagentWindows: () => {},
|
|
};
|
|
let pendingTimer: (() => void) | null = null;
|
|
const context = vm.createContext({
|
|
console,
|
|
window: { scrollTo: () => {}, matchMedia: () => ({ matches: false }), addEventListener: () => {} },
|
|
document: { body: { classList: { add: () => {}, remove: () => {} } }, addEventListener: () => {} },
|
|
navigator: { userAgent: 'test', maxTouchPoints: 0 },
|
|
app,
|
|
setTimeout: (fn: () => void) => {
|
|
pendingTimer = fn;
|
|
return 1;
|
|
},
|
|
clearTimeout: () => {
|
|
pendingTimer = null;
|
|
},
|
|
});
|
|
vm.runInContext(`${SOURCE}\n;globalThis.__KeyboardHandler = KeyboardHandler;`, context, {
|
|
filename: 'mobile-handlers.js',
|
|
});
|
|
const kh = (context as any).__KeyboardHandler;
|
|
// Stub the layout side effects the settle timer fires alongside the scroll.
|
|
kh._shrinkPaddingToFit = () => {};
|
|
kh._sendTerminalResize = () => {};
|
|
return {
|
|
kh,
|
|
terminal,
|
|
calls,
|
|
/** Run the coalesced settle timer the way the OS animation eventually would. */
|
|
settle: () => {
|
|
const fn = pendingTimer;
|
|
pendingTimer = null;
|
|
fn?.();
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('keyboard settle preserves scroll intent (issue #259)', () => {
|
|
it('scrolls to bottom when the user is following live output', () => {
|
|
const { kh, calls, settle } = loadKeyboardHandler({ viewportY: 500, baseY: 500 });
|
|
|
|
kh._scheduleViewportSettle({ restoreScroll: true });
|
|
settle();
|
|
|
|
expect(calls).toContain('scrollToBottom');
|
|
expect(calls.some((c) => c.startsWith('scrollToLine'))).toBe(false);
|
|
});
|
|
|
|
it('returns to the anchor instead of the bottom when the user is reading history', () => {
|
|
const { kh, calls, settle } = loadKeyboardHandler({ viewportY: 120, baseY: 500 });
|
|
|
|
kh._scheduleViewportSettle({ restoreScroll: true });
|
|
settle();
|
|
|
|
expect(calls).toContain('scrollToLine:120');
|
|
expect(calls).not.toContain('scrollToBottom');
|
|
});
|
|
|
|
it('captures the anchor BEFORE the reflow, not after', () => {
|
|
// The OS emits several viewport heights per animation, so the settle is
|
|
// re-scheduled repeatedly. Only the first capture predates fit(); a later
|
|
// one would read a viewportY the reflow had already moved.
|
|
const { kh, terminal, calls, settle } = loadKeyboardHandler({ viewportY: 120, baseY: 500 });
|
|
|
|
kh._scheduleViewportSettle({ restoreScroll: true });
|
|
terminal.buffer.active.viewportY = 480; // reflow drags the viewport down
|
|
kh._scheduleViewportSettle({ restoreScroll: true });
|
|
settle();
|
|
|
|
expect(calls).toContain('scrollToLine:120');
|
|
});
|
|
|
|
it('clamps an anchor that outlives the buffer it was captured from', () => {
|
|
const { kh, terminal, calls, settle } = loadKeyboardHandler({ viewportY: 400, baseY: 500 });
|
|
|
|
kh._scheduleViewportSettle({ restoreScroll: true });
|
|
terminal.buffer.active.baseY = 90; // buffer shrank under us
|
|
settle();
|
|
|
|
expect(calls).toContain('scrollToLine:90');
|
|
});
|
|
|
|
it('leaves the terminal alone when the settle was not a keyboard transition', () => {
|
|
const { kh, calls, settle } = loadKeyboardHandler({ viewportY: 120, baseY: 500 });
|
|
|
|
kh._scheduleViewportSettle({});
|
|
settle();
|
|
|
|
expect(calls).toContain('fit');
|
|
expect(calls).not.toContain('scrollToBottom');
|
|
expect(calls.some((c) => c.startsWith('scrollToLine'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('keyboard show/hide route through the intent-preserving path (static guard)', () => {
|
|
it('both transitions ask to restore scroll, never to force the bottom', () => {
|
|
// Slice from the METHOD DEFINITIONS ("\n name() {"), not the first
|
|
// occurrence of the name — both are called from _checkKeyboard() further up.
|
|
const bodyOf = (name: string) => {
|
|
const start = SOURCE.indexOf(`\n ${name}() {`);
|
|
expect(start, `${name} definition not found`).toBeGreaterThan(-1);
|
|
return SOURCE.slice(start, SOURCE.indexOf('\n },', start));
|
|
};
|
|
const show = bodyOf('onKeyboardShow');
|
|
const hide = bodyOf('onKeyboardHide');
|
|
|
|
expect(show).toContain('_scheduleViewportSettle({ restoreScroll: true })');
|
|
expect(hide).toContain('_scheduleViewportSettle({ restoreScroll: true })');
|
|
// The old unconditional call must not come back.
|
|
expect(SOURCE).not.toContain('scrollToBottom: true');
|
|
});
|
|
});
|