Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 74 additions & 42 deletions packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render } from 'ink-testing-library';
import { Text } from 'ink';
import { DefaultAppLayout } from './DefaultAppLayout.js';
Expand All @@ -17,15 +17,13 @@ import { AgentViewProvider } from '../contexts/AgentViewContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { StreamingState } from '../types.js';

// Drive terminal size deterministically: the real useTerminalSize listens on
// process.stdout and updates via React state, which doesn't flush predictably
// under fake timers. Mocking it lets us change width via an explicit rerender.
let mockSize = { columns: 100, rows: 40 };
// Fixed size for layout; the resize handler under test reads process.stdout
// directly, so we drive it via stdout events rather than this hook.
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: () => mockSize,
useTerminalSize: () => ({ columns: 100, rows: 40 }),
}));

// Heavy children are irrelevant to the resize→reflow wiring under test; stub
// Heavy children are irrelevant to the resize→repaint wiring under test; stub
// them so the layout renders without pulling in their dependency graphs.
vi.mock('../components/MainContent.js', () => ({
MainContent: () => <Text>MainContent</Text>,
Expand Down Expand Up @@ -61,7 +59,7 @@ vi.mock('../components/TranscriptOverlay.js', () => ({
TranscriptOverlay: () => null,
}));

describe('DefaultAppLayout resize reflow', () => {
describe('DefaultAppLayout resize repaint', () => {
const refreshStatic = vi.fn();

const mockUIActions = {
Expand Down Expand Up @@ -90,9 +88,6 @@ describe('DefaultAppLayout resize reflow', () => {
},
};

// A fresh element per render: React bails out of re-rendering when handed the
// same element reference, which would stop the mocked width change from ever
// reaching the component.
const makeTree = () => (
<ConfigContext.Provider value={mockConfig}>
<UIActionsContext.Provider value={mockUIActions}>
Expand All @@ -105,55 +100,92 @@ describe('DefaultAppLayout resize reflow', () => {
</ConfigContext.Provider>
);

// Real timers: Ink flushes passive effects through its own reconciler
// scheduler, which doesn't advance predictably under fake timers. A short
// wait lets an effect (and its debounce timer) run. FLUSH < 150ms debounce
// (effect scheduled, timer not yet fired); SETTLE > 150ms (timer fired).
const FLUSH_MS = 10;
const SETTLE_MS = 250;
// Each render attaches a resize listener to process.stdout; track instances
// so afterEach can unmount them and listeners don't leak across tests.
const instances: Array<ReturnType<typeof render>> = [];
const mount = () => {
const instance = render(makeTree());
instances.push(instance);
return instance;
};

// The resize listener attaches in a passive effect; a short real wait lets it
// run. SETTLE > RESIZE_THROTTLE_MS (80ms) so the trailing timer has fired.
const MOUNT_MS = 20;
const SETTLE_MS = 160;
const wait = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));

const originalColumns = process.stdout.columns;
const originalRows = process.stdout.rows;

const resize = (columns: number, rows = process.stdout.rows) => {
process.stdout.columns = columns;
process.stdout.rows = rows;
process.stdout.emit('resize');
};

beforeEach(() => {
refreshStatic.mockClear();
mockSize = { columns: 100, rows: 40 };
process.stdout.columns = 100;
process.stdout.rows = 40;
});

it('does not refresh static on first mount', async () => {
render(makeTree());
afterEach(() => {
while (instances.length) {
instances.pop()?.unmount();
}
process.stdout.columns = originalColumns;
process.stdout.rows = originalRows;
});

it('does not repaint on first mount', async () => {
mount();
await wait(SETTLE_MS);
expect(refreshStatic).not.toHaveBeenCalled();
});

it('refreshes static once after a width change settles', async () => {
const { rerender } = render(makeTree());
await wait(FLUSH_MS);
mockSize = { columns: 60, rows: 40 };
rerender(makeTree());
await wait(FLUSH_MS);
// Debounced: the effect scheduled the timer but it hasn't fired yet.
expect(refreshStatic).not.toHaveBeenCalled();
await wait(SETTLE_MS);
it('repaints immediately on a resize (leading edge)', async () => {
mount();
await wait(MOUNT_MS);
resize(60);
// Leading edge fires synchronously in the resize handler — no wait needed.
expect(refreshStatic).toHaveBeenCalledTimes(1);
});

it('debounces a burst of width changes into a single refresh', async () => {
const { rerender } = render(makeTree());
await wait(FLUSH_MS);
for (const columns of [90, 80, 70]) {
mockSize = { columns, rows: 40 };
rerender(makeTree());
await wait(FLUSH_MS); // < debounce, so each change resets the timer
}
it('throttles a burst of resizes into a leading + trailing repaint', async () => {
mount();
await wait(MOUNT_MS);
resize(90);
resize(85);
resize(80);
// Only the leading edge has fired so far; the rest coalesce.
expect(refreshStatic).toHaveBeenCalledTimes(1);
await wait(SETTLE_MS);
// ...plus a single trailing repaint at the throttle boundary.
expect(refreshStatic).toHaveBeenCalledTimes(2);
});

it('ignores a resize event that does not change dimensions', async () => {
mount();
await wait(MOUNT_MS);
process.stdout.emit('resize'); // same 100x40 as mount
await wait(SETTLE_MS);
expect(refreshStatic).not.toHaveBeenCalled();
});

it('repaints on a height-only change', async () => {
mount();
await wait(MOUNT_MS);
resize(100, 20); // width unchanged, height differs
expect(refreshStatic).toHaveBeenCalledTimes(1);
});

it('does not refresh static when only the height changes', async () => {
const { rerender } = render(makeTree());
await wait(FLUSH_MS);
mockSize = { columns: 100, rows: 20 }; // width unchanged, height differs
rerender(makeTree());
it('removes the resize listener on unmount', async () => {
const { unmount } = mount();
await wait(MOUNT_MS);
unmount();
resize(50);
await wait(SETTLE_MS);
expect(refreshStatic).not.toHaveBeenCalled();
});
Expand Down
89 changes: 71 additions & 18 deletions packages/cli/src/ui/layouts/DefaultAppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ import { useAgentViewState } from '../contexts/AgentViewContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';

/**
* Max repaint cadence while the terminal is being resized. Small enough that a
* drag never accumulates more than a frame or two of stale output before it's
* cleared, large enough not to clear+reprint on every single resize event.
*/
const RESIZE_THROTTLE_MS = 80;

export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState();
const { refreshStatic, closeTranscript } = useUIActions();
Expand Down Expand Up @@ -59,26 +66,72 @@ export const DefaultAppLayout: React.FC = () => {
}
}, [isTranscriptOpen, refreshStatic]);

// Reflow committed <Static> output on terminal resize. Ink's <Static> is
// append-only and tracked by array index, so it never redraws already-emitted
// history when the width changes — the terminal re-wraps those stale lines
// into garbage. refreshStatic() clears the screen and bumps historyRemountKey
// so MainContent's <Static> remounts and reprints history at the new width.
// Width is the reflow driver (height changes don't mis-wrap committed lines).
// Debounced so a drag-resize doesn't thrash clear/redraw on every intermediate
// size — we only reflow once the drag settles. The initial width is captured
// by useRef so first mount never triggers a clear.
const prevWidthRef = useRef(terminalWidth);
// Clear + reprint the whole frame on terminal resize.
//
// Two things break on resize, both fixed by refreshStatic() (clear the
// screen + remount <Static> so history and the live region reprint cleanly
// at the new size):
// 1. <Static> history is append-only and index-tracked — it never redraws
// already-committed lines, so the terminal re-wraps them into garbage.
// 2. Ink's live region (composer, sticky task list, status bar) is redrawn
// by erasing the previous frame's *stored* line count. After a resize
// the terminal has reflowed that frame to a different height, so the
// erase misses lines and the stale frame is pushed into scrollback —
// one duplicate per resize event, i.e. an avalanche during a drag.
//
// Throttled (leading + trailing), NOT debounced: a continuous drag emits
// resize events faster than it ever settles, so a trailing-only debounce
// would let the avalanche build until the drag stops. The throttle repaints
// at a bounded cadence *during* the drag and once more when it settles.
// Listens on stdout directly (not via useTerminalSize state) so the handler
// runs synchronously on the event and is immune to React batching.
useEffect(() => {
if (prevWidthRef.current === terminalWidth) {
return;
}
const timer = setTimeout(() => {
prevWidthRef.current = terminalWidth;
let lastRun = 0;
let trailing: ReturnType<typeof setTimeout> | undefined;
let lastCols = process.stdout.columns;
let lastRows = process.stdout.rows;

const fire = () => {
lastRun = Date.now();
refreshStatic();
}, 150);
return () => clearTimeout(timer);
}, [terminalWidth, refreshStatic]);
};

const onResize = () => {
const cols = process.stdout.columns;
const rows = process.stdout.rows;
// Some terminals emit 'resize' for non-size events; ignore no-ops.
if (cols === lastCols && rows === lastRows) {
return;
}
lastCols = cols;
lastRows = rows;

const sinceLast = Date.now() - lastRun;
if (sinceLast >= RESIZE_THROTTLE_MS) {
if (trailing) {
clearTimeout(trailing);
trailing = undefined;
}
fire(); // leading edge — repaint immediately
} else if (!trailing) {
// Coalesce the rest of this burst into one repaint at the throttle
// boundary; keep the pending timer rather than resetting it so a
// sustained drag still repaints every RESIZE_THROTTLE_MS.
trailing = setTimeout(() => {
trailing = undefined;
fire();
}, RESIZE_THROTTLE_MS - sinceLast);
}
};

process.stdout.on('resize', onResize);
return () => {
process.stdout.off('resize', onResize);
if (trailing) {
clearTimeout(trailing);
}
};
}, [refreshStatic]);

if (isTranscriptOpen) {
return <TranscriptOverlay onClose={closeTranscript} />;
Expand Down
Loading