mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Two render-polish fixes for codex sessions in the terminal write pipeline: - flushPendingWrites uses a 32KB first-frame budget for codex (vs 64KB for other modes). Codex's TUI emits dense synchronized redraws during thinking/high-effort phases; a smaller first frame keeps per-frame xterm/WebGL stalls short and avoids multi-second main-thread blocks. - Sticky-scroll now honours a short grace window after a manual scroll-up gesture (USER_SCROLL_STICKY_SUPPRESS_MS = 1500ms). High-frequency codex "Working (Ns)" status ticks were snapping the viewport back to the bottom while the user tried to read earlier output. The wheel/touch scroll handlers record the gesture (_noteTerminalUserScroll); flushPendingWrites suppresses the auto-scroll-to-bottom and restores the preserved viewport via scrollToLine while the grace window is active. Adds test/terminal-flush-budget.test.ts (vm-sandbox harness over terminal-ui.js): codex vs non-codex first-frame budget, buffer-load ownership, and the scroll-up suppression / viewport restore. Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
148 lines
4.7 KiB
TypeScript
148 lines
4.7 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
function loadTerminalUiHarness(mode: string) {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
window: {},
|
|
CodemanApp,
|
|
console: { warn: vi.fn(), log: vi.fn() },
|
|
_crashDiag: { log: vi.fn() },
|
|
performance: { now: () => 0 },
|
|
requestAnimationFrame: (_fn: () => void) => 1,
|
|
setTimeout: (_fn: () => void) => 1,
|
|
Blob: function Blob() {},
|
|
URL: {
|
|
createObjectURL: () => 'blob:yield',
|
|
revokeObjectURL: () => {},
|
|
},
|
|
Worker: function Worker(this: any) {
|
|
this.postMessage = () => {};
|
|
},
|
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
|
});
|
|
|
|
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
|
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
const writes: string[] = [];
|
|
app.activeSessionId = 'session-1';
|
|
app.sessions = new Map([['session-1', { mode }]]);
|
|
app.pendingWrites = [];
|
|
app.writeFrameScheduled = false;
|
|
app._wasAtBottomBeforeWrite = false;
|
|
app._workerYield = () => {};
|
|
app._chunkedWriteGen = 0;
|
|
app.terminal = {
|
|
write: (data: string) => writes.push(data),
|
|
scrollToBottom: () => {},
|
|
scrollToLine: () => {},
|
|
};
|
|
|
|
return { app, writes };
|
|
}
|
|
|
|
describe('terminal flush budget', () => {
|
|
it('uses a smaller first-frame write budget for Codex output to reduce renderer stalls', () => {
|
|
const { app, writes } = loadTerminalUiHarness('codex');
|
|
app.pendingWrites.push('x'.repeat(96 * 1024));
|
|
|
|
app.flushPendingWrites();
|
|
|
|
expect(writes).toHaveLength(1);
|
|
expect(writes[0]).toHaveLength(32 * 1024);
|
|
expect(app.pendingWrites.join('')).toHaveLength(64 * 1024);
|
|
});
|
|
|
|
it('keeps the larger first-frame write budget for non-Codex terminal output', () => {
|
|
const { app, writes } = loadTerminalUiHarness('claude');
|
|
app.pendingWrites.push('x'.repeat(96 * 1024));
|
|
|
|
app.flushPendingWrites();
|
|
|
|
expect(writes).toHaveLength(1);
|
|
expect(writes[0]).toHaveLength(64 * 1024);
|
|
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
|
|
});
|
|
|
|
it('waits for xterm to process small buffer replays before completing buffer load', async () => {
|
|
const { app, writes } = loadTerminalUiHarness('codex');
|
|
let writeDone: (() => void) | undefined;
|
|
let resolved = false;
|
|
const finishBufferLoad = vi.fn();
|
|
app._finishBufferLoad = finishBufferLoad;
|
|
app.terminal.write = (data: string, callback?: () => void) => {
|
|
writes.push(data);
|
|
writeDone = callback;
|
|
};
|
|
|
|
const promise = app.chunkedTerminalWrite('fresh tmux pane frame').then(() => {
|
|
resolved = true;
|
|
});
|
|
|
|
await Promise.resolve();
|
|
|
|
expect(writes).toEqual(['fresh tmux pane frame']);
|
|
expect(writeDone).toBeTypeOf('function');
|
|
expect(resolved).toBe(false);
|
|
expect(finishBufferLoad).not.toHaveBeenCalled();
|
|
|
|
writeDone?.();
|
|
await promise;
|
|
|
|
expect(resolved).toBe(true);
|
|
expect(finishBufferLoad).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('keeps stale buffer load owners from finishing a newer load', () => {
|
|
const { app } = loadTerminalUiHarness('codex');
|
|
|
|
app._beginBufferLoad('select-1');
|
|
app._beginBufferLoad('select-2');
|
|
|
|
expect(app._finishBufferLoad('select-1')).toBe(false);
|
|
expect(app._isLoadingBuffer).toBe(true);
|
|
expect(app._bufferLoadOwner).toBe('select-2');
|
|
|
|
expect(app._finishBufferLoad('select-2')).toBe(true);
|
|
expect(app._isLoadingBuffer).toBe(false);
|
|
expect(app._bufferLoadOwner).toBe(null);
|
|
});
|
|
|
|
it('does not snap back to bottom during Codex Working redraws right after the user scrolls up', () => {
|
|
const { app } = loadTerminalUiHarness('codex');
|
|
const scrollToBottom = vi.fn();
|
|
app.terminal.scrollToBottom = scrollToBottom;
|
|
app._wasAtBottomBeforeWrite = true;
|
|
app._lastUserScrollUpAt = 0;
|
|
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
|
|
|
app.flushPendingWrites();
|
|
|
|
expect(scrollToBottom).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('restores the user scroll position when Codex Working redraws move the viewport', () => {
|
|
const { app } = loadTerminalUiHarness('codex');
|
|
const buffer = { viewportY: 40, baseY: 100 };
|
|
app.terminal.buffer = { active: buffer };
|
|
app.terminal.write = vi.fn(() => {
|
|
buffer.viewportY = buffer.baseY;
|
|
});
|
|
app.terminal.scrollToLine = vi.fn((line: number) => {
|
|
buffer.viewportY = line;
|
|
});
|
|
app._wasAtBottomBeforeWrite = true;
|
|
app._lastUserScrollUpAt = 0;
|
|
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
|
|
|
app.flushPendingWrites();
|
|
|
|
expect(buffer.viewportY).toBe(40);
|
|
});
|
|
});
|