diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
index 3e51acd740..6a803a39eb 100644
--- a/packages/cli/src/ui/components/Footer.tsx
+++ b/packages/cli/src/ui/components/Footer.tsx
@@ -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 ? (
-
-
- {displayPath}
- {branchName && ({branchName}*)}
-
-
+
+ {displayPath}
+ {branchName && ({branchName}*)}
+
) : (
{displayPath}
diff --git a/packages/cli/src/ui/components/GradientRegression.test.tsx b/packages/cli/src/ui/components/GradientRegression.test.tsx
new file mode 100644
index 0000000000..1b4bc8a4f7
--- /dev/null
+++ b/packages/cli/src/ui/components/GradientRegression.test.tsx
@@ -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();
+ 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();
+ 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(' should not crash when theme.ui.gradient has only one color (or empty) and nightly is true', () => {
+ const { lastFrame } = renderWithProviders(, {
+ 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(' should not crash when theme.ui.gradient is empty', () => {
+ const { lastFrame } = renderWithProviders(
+ ,
+ {
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ },
+ },
+ );
+ expect(lastFrame()).toBeDefined();
+ // Ensure title is rendered
+ expect(lastFrame()).toContain('My Stats');
+ });
+});
diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx
index 08b356ef89..8fd773be4d 100644
--- a/packages/cli/src/ui/components/Header.tsx
+++ b/packages/cli/src/ui/components/Header.tsx
@@ -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 (
-
- {children}
-
- );
- }
-
- if (gradient && gradient.length === 1) {
- return {children};
- }
-
- return {children};
-};
-
export const Header: React.FC = ({
customAsciiArt,
version,
diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx
index 8c7bacd7ab..964f98dfcb 100644
--- a/packages/cli/src/ui/components/StatsDisplay.tsx
+++ b/packages/cli/src/ui/components/StatsDisplay.tsx
@@ -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 = ({
const renderTitle = () => {
if (title) {
- return theme.ui.gradient && theme.ui.gradient.length > 0 ? (
-
-
- {title}
-
-
- ) : (
-
- {title}
-
- );
+ return {title};
}
return (
diff --git a/packages/cli/src/ui/components/ThemedGradient.tsx b/packages/cli/src/ui/components/ThemedGradient.tsx
new file mode 100644
index 0000000000..339626c04f
--- /dev/null
+++ b/packages/cli/src/ui/components/ThemedGradient.tsx
@@ -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 = ({ children, ...props }) => {
+ const gradient = theme.ui.gradient;
+
+ if (gradient && gradient.length >= 2) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ if (gradient && gradient.length === 1) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return {children};
+};
diff --git a/packages/cli/src/ui/hooks/useAnimatedScrollbar.test.tsx b/packages/cli/src/ui/hooks/useAnimatedScrollbar.test.tsx
index 3fd84ad7a5..32f4c0cedf 100644
--- a/packages/cli/src/ui/hooks/useAnimatedScrollbar.test.tsx
+++ b/packages/cli/src/ui/hooks/useAnimatedScrollbar.test.tsx
@@ -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();
+
+ // Start animation. This captures start = 1000.
+ rerender();
+
+ // 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)
+ });
});
diff --git a/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts b/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts
index aeb1d79041..7ee3fb8ec9 100644
--- a/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts
+++ b/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts
@@ -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),
);
diff --git a/packages/cli/src/ui/themes/color-utils.test.ts b/packages/cli/src/ui/themes/color-utils.test.ts
index 9d6a11698a..d35fcb183e 100644
--- a/packages/cli/src/ui/themes/color-utils.test.ts
+++ b/packages/cli/src/ui/themes/color-utils.test.ts
@@ -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('');
+ });
});
});
diff --git a/packages/cli/src/ui/themes/color-utils.ts b/packages/cli/src/ui/themes/color-utils.ts
index 1f326a4840..7f0054c3a0 100644
--- a/packages/cli/src/ui/themes/color-utils.ts
+++ b/packages/cli/src/ui/themes/color-utils.ts
@@ -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();