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
1 change: 0 additions & 1 deletion eslint.legacy-filenames.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,6 @@ export const legacyFilenames = [
'toolResultDisplayCompaction',
'usageHistoryService',
'useMcpApproval',
'useResizeSettleRepaint',
'useStatsDialog',
'useTeamInProcess',
'userMemory',
Expand Down
71 changes: 63 additions & 8 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
type Mock,
} from 'vitest';
import { render, cleanup } from 'ink-testing-library';
import { useContext, useState, act } from 'react';
import { useContext, useState, useReducer, useEffect, act } from 'react';
import {
AppContainer,
dedupeNewestFirst,
Expand Down Expand Up @@ -1092,13 +1092,10 @@ describe('AppContainer State Management', () => {
expect(capturedUIState.useTerminalBuffer).toBe(true);
});

// #4891 changed the resize contract: width changes now trigger ONE full
// clearTerminal after RESIZE_REPAINT_SETTLE_MS (trailing-edge debounce),
// instead of never (#3967) or per-event (pre-#3967). This test pins the
// synchronous half: no immediate clear during the burst. The settle-time
// half is not observable here — ink-testing-library's rerender does not
// flush update-time passive effects — and is covered by
// useResizeSettleRepaint.test.ts.
// Resize no longer triggers a clearTerminal or history remount (#8004).
// The old settle → refreshStatic path caused a scroll storm; the dynamic
// region now re-renders via useTerminalSize alone. This test pins that
// no synchronous clear fires during a width change.
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
it('does not clear the terminal synchronously on width change', () => {
vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 24 });
Expand Down Expand Up @@ -1127,6 +1124,64 @@ describe('AppContainer State Management', () => {
);
});

it('does not repaint static history after a resize settles (#8004)', () => {
vi.useFakeTimers();
vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
// measureElement must return a real measurement; a bare vi.fn() returns
// undefined, the controlsHeight layout effect throws on .height, and
// ink's ErrorBoundary silently unmounts the tree — making every
// post-mount assertion vacuous.
(measureElement as Mock).mockReturnValue({ width: 80, height: 2 });

// Deliver width changes to the SAME mounted instance. rerender() from
// ink-testing-library remounts the tree (ErrorBoundary issue above),
// re-seeding useRef(terminalWidth) so a settle debounce never fires.
let columns = 80;
const resizeListeners = new Set<() => void>();
mockedUseTerminalSize.mockImplementation(() => {
const [, force] = useReducer((x: number) => x + 1, 0);
useEffect(() => {
resizeListeners.add(force);
return () => {
resizeListeners.delete(force);
};
}, []);
return { columns, rows: 24 };
});

render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);

// Liveness control: fails if the ErrorBoundary unmounted the tree.
expect(resizeListeners.size).toBeGreaterThan(0);
const remountKeyBefore = capturedUIState.historyRemountKey;
mockStdout.write.mockClear();

act(() => {
columns = 100;
for (const notify of resizeListeners) notify();
});

// Advance well past the old RESIZE_REPAINT_SETTLE_MS (200ms) debounce.
act(() => {
vi.advanceTimersByTime(500);
});
Comment thread
qwen-code-dev-bot marked this conversation as resolved.

expect(mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
expect(capturedUIState.historyRemountKey).toBe(remountKeyBefore);

vi.useRealTimers();
(measureElement as Mock).mockReturnValue(undefined);
});

it('handleClearScreen avoids a second clearTerminal write', () => {
const clearSpy = vi.spyOn(console, 'clear').mockImplementation(() => {});

Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ const MCP_BATCH_FLUSH_MS = 16;
const STARTUP_PROFILE_FINALIZE_CAP_MS = 35_000;
import { useHistory } from './hooks/useHistoryManager.js';
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
import { useResizeSettleRepaint } from './hooks/useResizeSettleRepaint.js';
import { useWakeRepaint } from './hooks/use-wake-repaint.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useFeedbackDialog } from './hooks/useFeedbackDialog.js';
Expand Down Expand Up @@ -3245,8 +3244,12 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [terminalWidth, availableTerminalHeight, activePtyId]);

// Repaint static history on the trailing edge of a resize burst (#4891).
useResizeSettleRepaint(terminalWidth, refreshStatic);
// Resize no longer repaints static history (#8004). The old settle →
// refreshStatic path wrote clearTerminal (destroying scrollback) and remounted
// <Static>, re-emitting all history in 50-item chunks — a scroll storm when
// the terminal's resize animation exceeded the debounce window (e.g. Ghostty
// panel toggle). Ink's dynamic region already re-renders on width changes via
// useTerminalSize; modern terminals reflow scrollback natively.

// Repaint after the process resumes from OS sleep / suspend (lid close,
// display sleep, Ctrl+Z → fg). The terminal's screen buffer is stale but
Expand Down
102 changes: 0 additions & 102 deletions packages/cli/src/ui/hooks/useResizeSettleRepaint.test.ts

This file was deleted.

50 changes: 0 additions & 50 deletions packages/cli/src/ui/hooks/useResizeSettleRepaint.ts

This file was deleted.

Loading