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:
gemini-cli-robot
2025-11-17 08:02:12 -08:00
committed by GitHub
parent aa5ca13ef9
commit 60407daf54
9 changed files with 202 additions and 44 deletions

View File

@@ -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}

View 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');
});
});

View File

@@ -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,

View File

@@ -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}>

View 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>;
};

View File

@@ -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)
});
});

View File

@@ -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),
);

View File

@@ -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('');
});
});
});

View File

@@ -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();