Skip to content

Commit 3cbb170

Browse files
fix(patch): cherry-pick 78a28bf to release/v0.16.0-preview.4-pr-13188 to patch version v0.16.0-preview.4 and create version 0.16.0-preview.5 (#13229)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
1 parent c9e4e57 commit 3cbb170

9 files changed

Lines changed: 202 additions & 44 deletions

File tree

packages/cli/src/ui/components/Footer.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { theme } from '../semantic-colors.js';
1010
import { shortenPath, tildeifyPath } from '@google/gemini-cli-core';
1111
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
1212
import process from 'node:process';
13-
import Gradient from 'ink-gradient';
13+
import { ThemedGradient } from './ThemedGradient.js';
1414
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
1515
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
1616
import { DebugProfiler } from './DebugProfiler.js';
@@ -87,12 +87,10 @@ export const Footer: React.FC = () => {
8787
)}
8888
{!hideCWD &&
8989
(nightly ? (
90-
<Gradient colors={theme.ui.gradient}>
91-
<Text>
92-
{displayPath}
93-
{branchName && <Text> ({branchName}*)</Text>}
94-
</Text>
95-
</Gradient>
90+
<ThemedGradient>
91+
{displayPath}
92+
{branchName && <Text> ({branchName}*)</Text>}
93+
</ThemedGradient>
9694
) : (
9795
<Text color={theme.text.link}>
9896
{displayPath}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, vi } from 'vitest';
8+
import { renderWithProviders } from '../../test-utils/render.js';
9+
import { Footer } from './Footer.js';
10+
import { StatsDisplay } from './StatsDisplay.js';
11+
import * as SessionContext from '../contexts/SessionContext.js';
12+
import type { SessionStatsState } from '../contexts/SessionContext.js';
13+
14+
// Mock the theme module
15+
vi.mock('../semantic-colors.js', async (importOriginal) => {
16+
const original =
17+
await importOriginal<typeof import('../semantic-colors.js')>();
18+
return {
19+
...original,
20+
theme: {
21+
...original.theme,
22+
ui: {
23+
...original.theme.ui,
24+
gradient: [], // Empty array to potentially trigger the crash
25+
},
26+
},
27+
};
28+
});
29+
30+
// Mock the context to provide controlled data for testing
31+
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
32+
const actual = await importOriginal<typeof SessionContext>();
33+
return {
34+
...actual,
35+
useSessionStats: vi.fn(),
36+
};
37+
});
38+
39+
const mockSessionStats: SessionStatsState = {
40+
sessionId: 'test-session',
41+
sessionStartTime: new Date(),
42+
lastPromptTokenCount: 0,
43+
promptCount: 0,
44+
metrics: {
45+
models: {},
46+
tools: {
47+
totalCalls: 0,
48+
totalSuccess: 0,
49+
totalFail: 0,
50+
totalDurationMs: 0,
51+
totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 },
52+
byName: {},
53+
},
54+
files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
55+
},
56+
};
57+
58+
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
59+
useSessionStatsMock.mockReturnValue({
60+
stats: mockSessionStats,
61+
getPromptCount: () => 0,
62+
startNewPrompt: vi.fn(),
63+
});
64+
65+
describe('Gradient Crash Regression Tests', () => {
66+
it('<Footer /> should not crash when theme.ui.gradient has only one color (or empty) and nightly is true', () => {
67+
const { lastFrame } = renderWithProviders(<Footer />, {
68+
width: 120,
69+
uiState: {
70+
nightly: true, // Enable nightly to trigger Gradient usage logic
71+
sessionStats: mockSessionStats,
72+
},
73+
});
74+
// If it crashes, this line won't be reached or lastFrame() will throw
75+
expect(lastFrame()).toBeDefined();
76+
// It should fall back to rendering text without gradient
77+
expect(lastFrame()).not.toContain('Gradient');
78+
});
79+
80+
it('<StatsDisplay /> should not crash when theme.ui.gradient is empty', () => {
81+
const { lastFrame } = renderWithProviders(
82+
<StatsDisplay duration="1s" title="My Stats" />,
83+
{
84+
width: 120,
85+
uiState: {
86+
sessionStats: mockSessionStats,
87+
},
88+
},
89+
);
90+
expect(lastFrame()).toBeDefined();
91+
// Ensure title is rendered
92+
expect(lastFrame()).toContain('My Stats');
93+
});
94+
});

packages/cli/src/ui/components/Header.tsx

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@
55
*/
66

77
import type React from 'react';
8-
import { Box, Text } from 'ink';
9-
import Gradient from 'ink-gradient';
10-
import { theme } from '../semantic-colors.js';
8+
import { Box } from 'ink';
9+
import { ThemedGradient } from './ThemedGradient.js';
1110
import {
1211
shortAsciiLogo,
1312
longAsciiLogo,
@@ -26,26 +25,6 @@ interface HeaderProps {
2625
nightly: boolean;
2726
}
2827

29-
const ThemedGradient: React.FC<{ children: React.ReactNode }> = ({
30-
children,
31-
}) => {
32-
const gradient = theme.ui.gradient;
33-
34-
if (gradient && gradient.length >= 2) {
35-
return (
36-
<Gradient colors={gradient}>
37-
<Text>{children}</Text>
38-
</Gradient>
39-
);
40-
}
41-
42-
if (gradient && gradient.length === 1) {
43-
return <Text color={gradient[0]}>{children}</Text>;
44-
}
45-
46-
return <Text>{children}</Text>;
47-
};
48-
4928
export const Header: React.FC<HeaderProps> = ({
5029
customAsciiArt,
5130
version,

packages/cli/src/ui/components/StatsDisplay.tsx

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

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

186186
const renderTitle = () => {
187187
if (title) {
188-
return theme.ui.gradient && theme.ui.gradient.length > 0 ? (
189-
<Gradient colors={theme.ui.gradient}>
190-
<Text bold color={theme.text.primary}>
191-
{title}
192-
</Text>
193-
</Gradient>
194-
) : (
195-
<Text bold color={theme.text.accent}>
196-
{title}
197-
</Text>
198-
);
188+
return <ThemedGradient bold>{title}</ThemedGradient>;
199189
}
200190
return (
201191
<Text bold color={theme.text.accent}>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type React from 'react';
8+
import { Text, type TextProps } from 'ink';
9+
import Gradient from 'ink-gradient';
10+
import { theme } from '../semantic-colors.js';
11+
12+
export const ThemedGradient: React.FC<TextProps> = ({ children, ...props }) => {
13+
const gradient = theme.ui.gradient;
14+
15+
if (gradient && gradient.length >= 2) {
16+
return (
17+
<Gradient colors={gradient}>
18+
<Text {...props}>{children}</Text>
19+
</Gradient>
20+
);
21+
}
22+
23+
if (gradient && gradient.length === 1) {
24+
return (
25+
<Text color={gradient[0]} {...props}>
26+
{children}
27+
</Text>
28+
);
29+
}
30+
31+
return <Text {...props}>{children}</Text>;
32+
};

packages/cli/src/ui/hooks/useAnimatedScrollbar.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,32 @@ describe('useAnimatedScrollbar', () => {
7070

7171
expect(debugState.debugNumAnimatedComponents).toBe(0);
7272
});
73+
74+
it('should not crash if Date.now() goes backwards (regression test)', async () => {
75+
// Only fake timers, keep Date real so we can mock it manually
76+
vi.useFakeTimers({
77+
toFake: ['setInterval', 'clearInterval', 'setTimeout', 'clearTimeout'],
78+
});
79+
const dateSpy = vi.spyOn(Date, 'now');
80+
let currentTime = 1000;
81+
dateSpy.mockImplementation(() => currentTime);
82+
83+
const { rerender } = render(<TestComponent isFocused={false} />);
84+
85+
// Start animation. This captures start = 1000.
86+
rerender(<TestComponent isFocused={true} />);
87+
88+
// Simulate time going backwards before the next frame
89+
currentTime = 900;
90+
91+
// Trigger the interval (33ms)
92+
await act(async () => {
93+
vi.advanceTimersByTime(50);
94+
});
95+
96+
// If it didn't crash, we are good.
97+
// Cleanup
98+
dateSpy.mockRestore();
99+
// Reset timers to default full fake for other tests (handled by afterEach/beforeEach usually, but here we overrode it)
100+
});
73101
});

packages/cli/src/ui/hooks/useAnimatedScrollbar.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,15 @@ export function useAnimatedScrollbar(
4949
const unfocusedColor = theme.ui.dark;
5050
const startColor = colorRef.current;
5151

52+
if (!focusedColor || !unfocusedColor) {
53+
return;
54+
}
55+
5256
// Phase 1: Fade In
5357
let start = Date.now();
5458
const animateFadeIn = () => {
5559
const elapsed = Date.now() - start;
56-
const progress = Math.min(elapsed / fadeInDuration, 1);
60+
const progress = Math.max(0, Math.min(elapsed / fadeInDuration, 1));
5761

5862
setScrollbarColor(interpolateColor(startColor, focusedColor, progress));
5963

@@ -69,7 +73,10 @@ export function useAnimatedScrollbar(
6973
start = Date.now();
7074
const animateFadeOut = () => {
7175
const elapsed = Date.now() - start;
72-
const progress = Math.min(elapsed / fadeOutDuration, 1);
76+
const progress = Math.max(
77+
0,
78+
Math.min(elapsed / fadeOutDuration, 1),
79+
);
7380
setScrollbarColor(
7481
interpolateColor(focusedColor, unfocusedColor, progress),
7582
);

packages/cli/src/ui/themes/color-utils.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,5 +233,26 @@ describe('Color Utils', () => {
233233
it('should return end color when factor is 1', () => {
234234
expect(interpolateColor('#ff0000', '#0000ff', 1)).toBe('#0000ff');
235235
});
236+
237+
it('should return start color when factor is < 0', () => {
238+
expect(interpolateColor('#ff0000', '#0000ff', -0.5)).toBe('#ff0000');
239+
});
240+
241+
it('should return end color when factor is > 1', () => {
242+
expect(interpolateColor('#ff0000', '#0000ff', 1.5)).toBe('#0000ff');
243+
});
244+
245+
it('should return valid color if one is empty but factor selects the valid one', () => {
246+
expect(interpolateColor('', '#ffffff', 1)).toBe('#ffffff');
247+
expect(interpolateColor('#ffffff', '', 0)).toBe('#ffffff');
248+
});
249+
250+
it('should return empty string if either color is empty and factor does not select the valid one', () => {
251+
expect(interpolateColor('', '#ffffff', 0.5)).toBe('');
252+
expect(interpolateColor('#ffffff', '', 0.5)).toBe('');
253+
expect(interpolateColor('', '', 0.5)).toBe('');
254+
expect(interpolateColor('', '#ffffff', 0)).toBe('');
255+
expect(interpolateColor('#ffffff', '', 1)).toBe('');
256+
});
236257
});
237258
});

packages/cli/src/ui/themes/color-utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,15 @@ export function interpolateColor(
238238
color2: string,
239239
factor: number,
240240
) {
241+
if (factor <= 0 && color1) {
242+
return color1;
243+
}
244+
if (factor >= 1 && color2) {
245+
return color2;
246+
}
247+
if (!color1 || !color2) {
248+
return '';
249+
}
241250
const gradient = tinygradient(color1, color2);
242251
const color = gradient.rgbAt(factor);
243252
return color.toHexString();

0 commit comments

Comments
 (0)