mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
fix(patch): cherry-pick 78a28bf to release/v0.15.3-pr-13188 to patch version v0.15.3 and create version 0.15.4 (#13228)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
@@ -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';
|
||||
@@ -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}
|
||||
|
||||
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 { 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,
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
@@ -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>;
|
||||
}
|
||||
return (
|
||||
<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);
|
||||
});
|
||||
|
||||
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 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));
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user