mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-09 21:00:56 -07:00
feat(ui): add flicker detection and metrics (#10821)
This commit is contained in:
114
packages/cli/src/ui/hooks/useFlickerDetector.test.ts
Normal file
114
packages/cli/src/ui/hooks/useFlickerDetector.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { vi, type Mock } from 'vitest';
|
||||
import { useFlickerDetector } from './useFlickerDetector.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { recordFlickerFrame } from '@google/gemini-cli-core';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { type DOMElement, measureElement } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../contexts/ConfigContext.js');
|
||||
vi.mock('../contexts/UIStateContext.js');
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
recordFlickerFrame: vi.fn(),
|
||||
}));
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...original,
|
||||
measureElement: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../../utils/events.js', () => ({
|
||||
appEvents: {
|
||||
emit: vi.fn(),
|
||||
},
|
||||
AppEvent: {
|
||||
Flicker: 'flicker',
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUseConfig = useConfig as Mock;
|
||||
const mockUseUIState = useUIState as Mock;
|
||||
const mockRecordFlickerFrame = recordFlickerFrame as Mock;
|
||||
const mockMeasureElement = measureElement as Mock;
|
||||
const mockAppEventsEmit = appEvents.emit as Mock;
|
||||
|
||||
describe('useFlickerDetector', () => {
|
||||
const mockConfig = {} as Config;
|
||||
let mockRef: React.RefObject<DOMElement | null>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseConfig.mockReturnValue(mockConfig);
|
||||
mockRef = { current: { yogaNode: {} } as DOMElement };
|
||||
// Default UI state
|
||||
mockUseUIState.mockReturnValue({ constrainHeight: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should not record a flicker when height is less than terminal height', () => {
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 20 });
|
||||
renderHook(() => useFlickerDetector(mockRef, 25));
|
||||
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
|
||||
expect(mockAppEventsEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not record a flicker when height is equal to terminal height', () => {
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 25 });
|
||||
renderHook(() => useFlickerDetector(mockRef, 25));
|
||||
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
|
||||
expect(mockAppEventsEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should record a flicker when height is greater than terminal height and height is constrained', () => {
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
|
||||
renderHook(() => useFlickerDetector(mockRef, 25));
|
||||
expect(mockRecordFlickerFrame).toHaveBeenCalledTimes(1);
|
||||
expect(mockRecordFlickerFrame).toHaveBeenCalledWith(mockConfig);
|
||||
expect(mockAppEventsEmit).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppEventsEmit).toHaveBeenCalledWith(AppEvent.Flicker);
|
||||
});
|
||||
|
||||
it('should NOT record a flicker when height is greater than terminal height but height is NOT constrained', () => {
|
||||
// Override default UI state for this test
|
||||
mockUseUIState.mockReturnValue({ constrainHeight: false });
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
|
||||
renderHook(() => useFlickerDetector(mockRef, 25));
|
||||
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
|
||||
expect(mockAppEventsEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not check for flicker if the ref is not set', () => {
|
||||
mockRef.current = null;
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
|
||||
renderHook(() => useFlickerDetector(mockRef, 25));
|
||||
expect(mockMeasureElement).not.toHaveBeenCalled();
|
||||
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
|
||||
expect(mockAppEventsEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should re-evaluate on re-render', () => {
|
||||
// Start with a valid height
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 20 });
|
||||
const { rerender } = renderHook(() => useFlickerDetector(mockRef, 25));
|
||||
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
|
||||
|
||||
// Now, simulate a re-render where the height is too great
|
||||
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
|
||||
rerender();
|
||||
|
||||
expect(mockRecordFlickerFrame).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppEventsEmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
43
packages/cli/src/ui/hooks/useFlickerDetector.ts
Normal file
43
packages/cli/src/ui/hooks/useFlickerDetector.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type DOMElement, measureElement } from 'ink';
|
||||
import { useEffect } from 'react';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { recordFlickerFrame } from '@google/gemini-cli-core';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
/**
|
||||
* A hook that detects when the UI flickers (renders taller than the terminal).
|
||||
* This is a sign of a rendering bug that should be fixed.
|
||||
*
|
||||
* @param rootUiRef A ref to the root UI element.
|
||||
* @param terminalHeight The height of the terminal.
|
||||
*/
|
||||
export function useFlickerDetector(
|
||||
rootUiRef: React.RefObject<DOMElement | null>,
|
||||
terminalHeight: number,
|
||||
) {
|
||||
const config = useConfig();
|
||||
const { constrainHeight } = useUIState();
|
||||
|
||||
useEffect(() => {
|
||||
if (rootUiRef.current) {
|
||||
const measurement = measureElement(rootUiRef.current);
|
||||
if (measurement.height > terminalHeight) {
|
||||
// If we are not constraining the height, we are intentionally
|
||||
// overflowing the screen.
|
||||
if (!constrainHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordFlickerFrame(config);
|
||||
appEvents.emit(AppEvent.Flicker);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user