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
12 changes: 5 additions & 7 deletions packages/cli/src/ui/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { theme } from '../semantic-colors.js';
import { shortenPath, tildeifyPath } from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import Gradient from 'ink-gradient';
import { ThemedGradient } from './ThemedGradient.js';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
Expand Down Expand Up @@ -87,12 +87,10 @@ export const Footer: React.FC = () => {
)}
{!hideCWD &&
(nightly ? (
<Gradient colors={theme.ui.gradient}>
<Text>
{displayPath}
{branchName && <Text> ({branchName}*)</Text>}
</Text>
</Gradient>
<ThemedGradient>
{displayPath}
{branchName && <Text> ({branchName}*)</Text>}
</ThemedGradient>
) : (
<Text color={theme.text.link}>
{displayPath}
Expand Down
94 changes: 94 additions & 0 deletions packages/cli/src/ui/components/GradientRegression.test.tsx
Original file line number Diff line number Diff line change
@@ -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');
});
});
25 changes: 2 additions & 23 deletions packages/cli/src/ui/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@
*/

import type React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { theme } from '../semantic-colors.js';
import { Box } from 'ink';
import { ThemedGradient } from './ThemedGradient.js';
import {
shortAsciiLogo,
longAsciiLogo,
Expand All @@ -26,26 +25,6 @@ interface HeaderProps {
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> = ({
customAsciiArt,
version,
Expand Down
14 changes: 2 additions & 12 deletions packages/cli/src/ui/components/StatsDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import type React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { ThemedGradient } from './ThemedGradient.js';
import { theme } from '../semantic-colors.js';
import { formatDuration } from '../utils/formatters.js';
import type { ModelMetrics } from '../contexts/SessionContext.js';
Expand Down Expand Up @@ -185,17 +185,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({

const renderTitle = () => {
if (title) {
return theme.ui.gradient && theme.ui.gradient.length > 0 ? (
<Gradient colors={theme.ui.gradient}>
<Text bold color={theme.text.primary}>
{title}
</Text>
</Gradient>
) : (
<Text bold color={theme.text.accent}>
{title}
</Text>
);
return <ThemedGradient bold>{title}</ThemedGradient>;
}
Comment on lines 187 to 189

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This refactoring to use ThemedGradient has caused a visual regression. The original implementation used theme.text.primary for the title color when a gradient was present, and theme.text.accent as a fallback when no gradient was available. The new implementation doesn't specify any color, so it will fall back to the default text color in both cases.

To restore the original behavior, you should determine the color based on the presence of a gradient and pass it to ThemedGradient.

    if (title) {
      const color = (theme.ui.gradient?.length ?? 0) > 0 ? theme.text.primary : theme.text.accent;
      return <ThemedGradient bold color={color}>{title}</ThemedGradient>;
    }

return (
<Text bold color={theme.text.accent}>
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/ui/components/ThemedGradient.tsx
Original file line number Diff line number Diff line change
@@ -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>;
};
28 changes: 28 additions & 0 deletions packages/cli/src/ui/hooks/useAnimatedScrollbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,32 @@ describe('useAnimatedScrollbar', () => {

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)
});
});
11 changes: 9 additions & 2 deletions packages/cli/src/ui/hooks/useAnimatedScrollbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,15 @@ export function useAnimatedScrollbar(
const unfocusedColor = theme.ui.dark;
const startColor = colorRef.current;

if (!focusedColor || !unfocusedColor) {
return;
}

// Phase 1: Fade In
let start = Date.now();
const animateFadeIn = () => {
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));

Expand All @@ -69,7 +73,10 @@ export function useAnimatedScrollbar(
start = Date.now();
const animateFadeOut = () => {
const elapsed = Date.now() - start;
const progress = Math.min(elapsed / fadeOutDuration, 1);
const progress = Math.max(
0,
Math.min(elapsed / fadeOutDuration, 1),
);
setScrollbarColor(
interpolateColor(focusedColor, unfocusedColor, progress),
);
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/ui/themes/color-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,5 +233,26 @@ describe('Color Utils', () => {
it('should return end color when factor is 1', () => {
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('');
});
});
});
9 changes: 9 additions & 0 deletions packages/cli/src/ui/themes/color-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,15 @@ export function interpolateColor(
color2: string,
factor: number,
) {
if (factor <= 0 && color1) {
return color1;
}
if (factor >= 1 && color2) {
return color2;
}
if (!color1 || !color2) {
return '';
}
const gradient = tinygradient(color1, color2);
const color = gradient.rgbAt(factor);
return color.toHexString();
Expand Down