mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-12 15:10:59 -07:00
Fix: Animated scrollbar renders black in NO_COLOR mode (#13188)
This commit is contained in:
@@ -10,7 +10,7 @@ import { theme } from '../semantic-colors.js';
|
|||||||
import { shortenPath, tildeifyPath } from '@google/gemini-cli-core';
|
import { shortenPath, tildeifyPath } from '@google/gemini-cli-core';
|
||||||
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import Gradient from 'ink-gradient';
|
import { ThemedGradient } from './ThemedGradient.js';
|
||||||
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
||||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||||
import { DebugProfiler } from './DebugProfiler.js';
|
import { DebugProfiler } from './DebugProfiler.js';
|
||||||
@@ -87,12 +87,10 @@ export const Footer: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
{!hideCWD &&
|
{!hideCWD &&
|
||||||
(nightly ? (
|
(nightly ? (
|
||||||
<Gradient colors={theme.ui.gradient}>
|
<ThemedGradient>
|
||||||
<Text>
|
{displayPath}
|
||||||
{displayPath}
|
{branchName && <Text> ({branchName}*)</Text>}
|
||||||
{branchName && <Text> ({branchName}*)</Text>}
|
</ThemedGradient>
|
||||||
</Text>
|
|
||||||
</Gradient>
|
|
||||||
) : (
|
) : (
|
||||||
<Text color={theme.text.link}>
|
<Text color={theme.text.link}>
|
||||||
{displayPath}
|
{displayPath}
|
||||||
|
|||||||
94
packages/cli/src/ui/components/GradientRegression.test.tsx
Normal file
94
packages/cli/src/ui/components/GradientRegression.test.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { renderWithProviders } from '../../test-utils/render.js';
|
||||||
|
import { Footer } from './Footer.js';
|
||||||
|
import { StatsDisplay } from './StatsDisplay.js';
|
||||||
|
import * as SessionContext from '../contexts/SessionContext.js';
|
||||||
|
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||||
|
|
||||||
|
// Mock the theme module
|
||||||
|
vi.mock('../semantic-colors.js', async (importOriginal) => {
|
||||||
|
const original =
|
||||||
|
await importOriginal<typeof import('../semantic-colors.js')>();
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
theme: {
|
||||||
|
...original.theme,
|
||||||
|
ui: {
|
||||||
|
...original.theme.ui,
|
||||||
|
gradient: [], // Empty array to potentially trigger the crash
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the context to provide controlled data for testing
|
||||||
|
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof SessionContext>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useSessionStats: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockSessionStats: SessionStatsState = {
|
||||||
|
sessionId: 'test-session',
|
||||||
|
sessionStartTime: new Date(),
|
||||||
|
lastPromptTokenCount: 0,
|
||||||
|
promptCount: 0,
|
||||||
|
metrics: {
|
||||||
|
models: {},
|
||||||
|
tools: {
|
||||||
|
totalCalls: 0,
|
||||||
|
totalSuccess: 0,
|
||||||
|
totalFail: 0,
|
||||||
|
totalDurationMs: 0,
|
||||||
|
totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 },
|
||||||
|
byName: {},
|
||||||
|
},
|
||||||
|
files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||||
|
useSessionStatsMock.mockReturnValue({
|
||||||
|
stats: mockSessionStats,
|
||||||
|
getPromptCount: () => 0,
|
||||||
|
startNewPrompt: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Gradient Crash Regression Tests', () => {
|
||||||
|
it('<Footer /> should not crash when theme.ui.gradient has only one color (or empty) and nightly is true', () => {
|
||||||
|
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||||
|
width: 120,
|
||||||
|
uiState: {
|
||||||
|
nightly: true, // Enable nightly to trigger Gradient usage logic
|
||||||
|
sessionStats: mockSessionStats,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// If it crashes, this line won't be reached or lastFrame() will throw
|
||||||
|
expect(lastFrame()).toBeDefined();
|
||||||
|
// It should fall back to rendering text without gradient
|
||||||
|
expect(lastFrame()).not.toContain('Gradient');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('<StatsDisplay /> should not crash when theme.ui.gradient is empty', () => {
|
||||||
|
const { lastFrame } = renderWithProviders(
|
||||||
|
<StatsDisplay duration="1s" title="My Stats" />,
|
||||||
|
{
|
||||||
|
width: 120,
|
||||||
|
uiState: {
|
||||||
|
sessionStats: mockSessionStats,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toBeDefined();
|
||||||
|
// Ensure title is rendered
|
||||||
|
expect(lastFrame()).toContain('My Stats');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,9 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box } from 'ink';
|
||||||
import Gradient from 'ink-gradient';
|
import { ThemedGradient } from './ThemedGradient.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
|
||||||
import {
|
import {
|
||||||
shortAsciiLogo,
|
shortAsciiLogo,
|
||||||
longAsciiLogo,
|
longAsciiLogo,
|
||||||
@@ -26,26 +25,6 @@ interface HeaderProps {
|
|||||||
nightly: boolean;
|
nightly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemedGradient: React.FC<{ children: React.ReactNode }> = ({
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const gradient = theme.ui.gradient;
|
|
||||||
|
|
||||||
if (gradient && gradient.length >= 2) {
|
|
||||||
return (
|
|
||||||
<Gradient colors={gradient}>
|
|
||||||
<Text>{children}</Text>
|
|
||||||
</Gradient>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gradient && gradient.length === 1) {
|
|
||||||
return <Text color={gradient[0]}>{children}</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Text>{children}</Text>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = ({
|
export const Header: React.FC<HeaderProps> = ({
|
||||||
customAsciiArt,
|
customAsciiArt,
|
||||||
version,
|
version,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import Gradient from 'ink-gradient';
|
import { ThemedGradient } from './ThemedGradient.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { formatDuration } from '../utils/formatters.js';
|
import { formatDuration } from '../utils/formatters.js';
|
||||||
import type { ModelMetrics } from '../contexts/SessionContext.js';
|
import type { ModelMetrics } from '../contexts/SessionContext.js';
|
||||||
@@ -185,17 +185,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
|||||||
|
|
||||||
const renderTitle = () => {
|
const renderTitle = () => {
|
||||||
if (title) {
|
if (title) {
|
||||||
return theme.ui.gradient && theme.ui.gradient.length > 0 ? (
|
return <ThemedGradient bold>{title}</ThemedGradient>;
|
||||||
<Gradient colors={theme.ui.gradient}>
|
|
||||||
<Text bold color={theme.text.primary}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</Gradient>
|
|
||||||
) : (
|
|
||||||
<Text bold color={theme.text.accent}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
|
|||||||
32
packages/cli/src/ui/components/ThemedGradient.tsx
Normal file
32
packages/cli/src/ui/components/ThemedGradient.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { Text, type TextProps } from 'ink';
|
||||||
|
import Gradient from 'ink-gradient';
|
||||||
|
import { theme } from '../semantic-colors.js';
|
||||||
|
|
||||||
|
export const ThemedGradient: React.FC<TextProps> = ({ children, ...props }) => {
|
||||||
|
const gradient = theme.ui.gradient;
|
||||||
|
|
||||||
|
if (gradient && gradient.length >= 2) {
|
||||||
|
return (
|
||||||
|
<Gradient colors={gradient}>
|
||||||
|
<Text {...props}>{children}</Text>
|
||||||
|
</Gradient>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gradient && gradient.length === 1) {
|
||||||
|
return (
|
||||||
|
<Text color={gradient[0]} {...props}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Text {...props}>{children}</Text>;
|
||||||
|
};
|
||||||
@@ -70,4 +70,32 @@ describe('useAnimatedScrollbar', () => {
|
|||||||
|
|
||||||
expect(debugState.debugNumAnimatedComponents).toBe(0);
|
expect(debugState.debugNumAnimatedComponents).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not crash if Date.now() goes backwards (regression test)', async () => {
|
||||||
|
// Only fake timers, keep Date real so we can mock it manually
|
||||||
|
vi.useFakeTimers({
|
||||||
|
toFake: ['setInterval', 'clearInterval', 'setTimeout', 'clearTimeout'],
|
||||||
|
});
|
||||||
|
const dateSpy = vi.spyOn(Date, 'now');
|
||||||
|
let currentTime = 1000;
|
||||||
|
dateSpy.mockImplementation(() => currentTime);
|
||||||
|
|
||||||
|
const { rerender } = render(<TestComponent isFocused={false} />);
|
||||||
|
|
||||||
|
// Start animation. This captures start = 1000.
|
||||||
|
rerender(<TestComponent isFocused={true} />);
|
||||||
|
|
||||||
|
// Simulate time going backwards before the next frame
|
||||||
|
currentTime = 900;
|
||||||
|
|
||||||
|
// Trigger the interval (33ms)
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If it didn't crash, we are good.
|
||||||
|
// Cleanup
|
||||||
|
dateSpy.mockRestore();
|
||||||
|
// Reset timers to default full fake for other tests (handled by afterEach/beforeEach usually, but here we overrode it)
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,11 +49,15 @@ export function useAnimatedScrollbar(
|
|||||||
const unfocusedColor = theme.ui.dark;
|
const unfocusedColor = theme.ui.dark;
|
||||||
const startColor = colorRef.current;
|
const startColor = colorRef.current;
|
||||||
|
|
||||||
|
if (!focusedColor || !unfocusedColor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 1: Fade In
|
// Phase 1: Fade In
|
||||||
let start = Date.now();
|
let start = Date.now();
|
||||||
const animateFadeIn = () => {
|
const animateFadeIn = () => {
|
||||||
const elapsed = Date.now() - start;
|
const elapsed = Date.now() - start;
|
||||||
const progress = Math.min(elapsed / fadeInDuration, 1);
|
const progress = Math.max(0, Math.min(elapsed / fadeInDuration, 1));
|
||||||
|
|
||||||
setScrollbarColor(interpolateColor(startColor, focusedColor, progress));
|
setScrollbarColor(interpolateColor(startColor, focusedColor, progress));
|
||||||
|
|
||||||
@@ -69,7 +73,10 @@ export function useAnimatedScrollbar(
|
|||||||
start = Date.now();
|
start = Date.now();
|
||||||
const animateFadeOut = () => {
|
const animateFadeOut = () => {
|
||||||
const elapsed = Date.now() - start;
|
const elapsed = Date.now() - start;
|
||||||
const progress = Math.min(elapsed / fadeOutDuration, 1);
|
const progress = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(elapsed / fadeOutDuration, 1),
|
||||||
|
);
|
||||||
setScrollbarColor(
|
setScrollbarColor(
|
||||||
interpolateColor(focusedColor, unfocusedColor, progress),
|
interpolateColor(focusedColor, unfocusedColor, progress),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -233,5 +233,26 @@ describe('Color Utils', () => {
|
|||||||
it('should return end color when factor is 1', () => {
|
it('should return end color when factor is 1', () => {
|
||||||
expect(interpolateColor('#ff0000', '#0000ff', 1)).toBe('#0000ff');
|
expect(interpolateColor('#ff0000', '#0000ff', 1)).toBe('#0000ff');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return start color when factor is < 0', () => {
|
||||||
|
expect(interpolateColor('#ff0000', '#0000ff', -0.5)).toBe('#ff0000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return end color when factor is > 1', () => {
|
||||||
|
expect(interpolateColor('#ff0000', '#0000ff', 1.5)).toBe('#0000ff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return valid color if one is empty but factor selects the valid one', () => {
|
||||||
|
expect(interpolateColor('', '#ffffff', 1)).toBe('#ffffff');
|
||||||
|
expect(interpolateColor('#ffffff', '', 0)).toBe('#ffffff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string if either color is empty and factor does not select the valid one', () => {
|
||||||
|
expect(interpolateColor('', '#ffffff', 0.5)).toBe('');
|
||||||
|
expect(interpolateColor('#ffffff', '', 0.5)).toBe('');
|
||||||
|
expect(interpolateColor('', '', 0.5)).toBe('');
|
||||||
|
expect(interpolateColor('', '#ffffff', 0)).toBe('');
|
||||||
|
expect(interpolateColor('#ffffff', '', 1)).toBe('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -238,6 +238,15 @@ export function interpolateColor(
|
|||||||
color2: string,
|
color2: string,
|
||||||
factor: number,
|
factor: number,
|
||||||
) {
|
) {
|
||||||
|
if (factor <= 0 && color1) {
|
||||||
|
return color1;
|
||||||
|
}
|
||||||
|
if (factor >= 1 && color2) {
|
||||||
|
return color2;
|
||||||
|
}
|
||||||
|
if (!color1 || !color2) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
const gradient = tinygradient(color1, color2);
|
const gradient = tinygradient(color1, color2);
|
||||||
const color = gradient.rgbAt(factor);
|
const color = gradient.rgbAt(factor);
|
||||||
return color.toHexString();
|
return color.toHexString();
|
||||||
|
|||||||
Reference in New Issue
Block a user