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
42 changes: 41 additions & 1 deletion packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,47 @@ describe('AppContainer State Management', () => {
expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearTerminal);
});

it('does not clear the terminal just because width changed', () => {
it('refreshStatic skips the physical clear in VP mode (#4891)', () => {
const vpSettings = {
merged: {
hideTips: false,
theme: 'default',
ui: {
showStatusInTitle: false,
hideWindowTitle: false,
useTerminalBuffer: true,
},
},
setValue: vi.fn(),
} as unknown as LoadedSettings;

render(
<AppContainer
config={mockConfig}
settings={vpSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
mockStdout.write.mockClear();

capturedUIActions.refreshStatic();

// VP mode owns the viewport via the React tree, so refreshStatic must not
// emit a physical clear — the resize-settle path (#4891) strands nothing.
expect(mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
});

// #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.
it('does not clear the terminal synchronously on width change', () => {
vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 24 });
const { rerender } = render(
Expand Down
35 changes: 3 additions & 32 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ 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 { useThemeCommand } from './hooks/useThemeCommand.js';
import { useFeedbackDialog } from './hooks/useFeedbackDialog.js';
import { useAuthCommand } from './auth/useAuth.js';
Expand Down Expand Up @@ -866,26 +867,6 @@ export const AppContainer = (props: AppContainerProps) => {
remountStaticHistory();
}, [useTerminalBuffer, remountStaticHistory, stdout]);

// Targeted repaint for resize events: move cursor to top-left and erase
// downward instead of a full clearTerminal, avoiding the full-screen
// flash. Ink's <Static> region is append-only, so when terminal width
// changes (tmux split, fullscreen toggle, font size change) we must
// explicitly re-emit the static history at the new width — otherwise
// header content stays at the old width and visibly tears.
// VP mode handles resize via ink's reflow + its own overflow clipping, so
// the physical write is unnecessary there too.
const repaintStaticViewport = useCallback(() => {
if (!useTerminalBuffer) {
stdout.write(`${ansiEscapes.cursorTo(0, 0)}${ansiEscapes.eraseDown}`);
}
remountStaticHistory();
}, [useTerminalBuffer, remountStaticHistory, stdout]);

// Track previous terminal width across renders so we only repaint when
// the width actually changes. Initialized to the current width to avoid
// a spurious repaint on first mount.
const previousTerminalWidthRef = useRef(terminalWidth);

// Keep the static header in sync with model changes without polling.
// Ink's <Static> output is append-only, so model changes must explicitly
// clear and remount the static region to redraw the banner at the top.
Expand Down Expand Up @@ -2419,18 +2400,8 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [terminalWidth, availableTerminalHeight, activePtyId]);

// Repaint static header on terminal resize. Without this, tmux pane
// resizes and fullscreen toggles leave the static region rendered at the
// old width — header content visibly tears until the next refreshStatic
// (e.g. /model). Cheap repaint (cursor-to + erase-down) rather than a
// full clearTerminal to avoid the full-screen flash.
useEffect(() => {
if (previousTerminalWidthRef.current === terminalWidth) {
return;
}
previousTerminalWidthRef.current = terminalWidth;
repaintStaticViewport();
}, [terminalWidth, repaintStaticViewport]);
// Repaint static history on the trailing edge of a resize burst (#4891).
useResizeSettleRepaint(terminalWidth, refreshStatic);

useEffect(() => {
if (ideNeedsRestart) {
Expand Down
102 changes: 102 additions & 0 deletions packages/cli/src/ui/hooks/useResizeSettleRepaint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import {
RESIZE_REPAINT_SETTLE_MS,
useResizeSettleRepaint,
} from './useResizeSettleRepaint.js';

describe('useResizeSettleRepaint (#4891)', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

const setup = (initialWidth: number) => {
const refreshStatic = vi.fn();
const view = renderHook(
({ width }: { width: number }) =>
useResizeSettleRepaint(width, refreshStatic),
{ initialProps: { width: initialWidth } },
);
return { refreshStatic, view };
};

it('does not repaint on first mount', () => {
const { refreshStatic } = setup(80);

act(() => {
vi.advanceTimersByTime(RESIZE_REPAINT_SETTLE_MS + 50);
});

expect(refreshStatic).not.toHaveBeenCalled();
});

it('coalesces a burst of width changes into one repaint after settle', () => {
const { refreshStatic, view } = setup(80);

// Three rapid width changes, each well within the settle window.
act(() => view.rerender({ width: 90 }));
act(() => vi.advanceTimersByTime(100));
act(() => view.rerender({ width: 100 }));
act(() => vi.advanceTimersByTime(100));
act(() => view.rerender({ width: 110 }));

// Burst not yet settled (measured from the last change): nothing fired.
act(() => vi.advanceTimersByTime(RESIZE_REPAINT_SETTLE_MS - 1));
expect(refreshStatic).not.toHaveBeenCalled();

// Settle window elapses → exactly one repaint for the whole burst.
act(() => vi.advanceTimersByTime(1));
expect(refreshStatic).toHaveBeenCalledTimes(1);
});

it('does not repaint when a drag returns to the original width', () => {
const { refreshStatic, view } = setup(80);

act(() => view.rerender({ width: 100 }));
act(() => vi.advanceTimersByTime(RESIZE_REPAINT_SETTLE_MS - 1)); // pending
act(() => view.rerender({ width: 80 })); // back to start before it fires

act(() => vi.advanceTimersByTime(RESIZE_REPAINT_SETTLE_MS + 50));
expect(refreshStatic).not.toHaveBeenCalled();
});

it('repaints exactly once after a single settled width change', () => {
const { refreshStatic, view } = setup(80);

act(() => view.rerender({ width: 100 }));
act(() => vi.advanceTimersByTime(RESIZE_REPAINT_SETTLE_MS));

expect(refreshStatic).toHaveBeenCalledTimes(1);
});

it('schedules nothing when a re-render leaves the width unchanged', () => {
// A height-only resize re-renders with the same width: no repaint.
const { refreshStatic, view } = setup(80);

act(() => view.rerender({ width: 80 }));
act(() => vi.advanceTimersByTime(RESIZE_REPAINT_SETTLE_MS + 50));

expect(refreshStatic).not.toHaveBeenCalled();
});

it('cancels a pending repaint when unmounted mid-settle', () => {
const { refreshStatic, view } = setup(80);

act(() => view.rerender({ width: 100 }));
act(() => vi.advanceTimersByTime(RESIZE_REPAINT_SETTLE_MS - 1)); // pending
view.unmount();

act(() => vi.advanceTimersByTime(RESIZE_REPAINT_SETTLE_MS + 50));
expect(refreshStatic).not.toHaveBeenCalled();
});
});
50 changes: 50 additions & 0 deletions packages/cli/src/ui/hooks/useResizeSettleRepaint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { useEffect, useRef } from 'react';

// Trailing-edge debounce for resize-triggered static repaints (#4891).
export const RESIZE_REPAINT_SETTLE_MS = 200;

/**
* Repaint the static history once the terminal width settles (#4891).
*
* A window drag fires dozens of `resize` events. Per-event repainting restarted
* the progressive <Static> replay (#3899) mid-flight, and the viewport-only
* cursorTo+eraseDown erase introduced by #3967 (4bab7a1a) cannot reach output
* that has already scrolled into the scrollback, so each event stranded a
* fragment at that instant's width. Debouncing to the trailing edge and issuing
* one full `refreshStatic` (clearTerminal incl. ESC[3J + remount) wipes the
* fragments and re-emits the history once at the final width. The cleanup
* cancels the pending timer on the next width change or unmount, so a drag
* returning to the start width is a no-op.
*
* Trade-off: the full-screen flash #3967 removed returns, but at most once per
* resize gesture; the settle-time ESC[3J clears pre-session scrollback — the
* same as the pre-#3967 per-event behavior and today's /clear.
*
* `refreshStatic` must be referentially stable (e.g. `useCallback`) so an
* unrelated re-render does not cancel an in-flight settle.
*/
export function useResizeSettleRepaint(
terminalWidth: number,
refreshStatic: () => void,
): void {
// Width at the last settled repaint; starts at mount width (first mount is a
// no-op) and only advances when a repaint actually fires.
const settledTerminalWidthRef = useRef(terminalWidth);

useEffect(() => {
if (settledTerminalWidthRef.current === terminalWidth) {
return;
}
const timer = setTimeout(() => {
settledTerminalWidthRef.current = terminalWidth;
refreshStatic();
}, RESIZE_REPAINT_SETTLE_MS);
return () => clearTimeout(timer);
}, [terminalWidth, refreshStatic]);
}
Loading