mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-12 07:01:09 -07:00
feat: Show snowfall animation for holiday theme (#15494)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
This commit is contained in:
@@ -32,6 +32,7 @@ describe('<AppHeader />', () => {
|
|||||||
it('should render the banner with default text', () => {
|
it('should render the banner with default text', () => {
|
||||||
const mockConfig = makeFakeConfig();
|
const mockConfig = makeFakeConfig();
|
||||||
const uiState = {
|
const uiState = {
|
||||||
|
history: [],
|
||||||
bannerData: {
|
bannerData: {
|
||||||
defaultText: 'This is the default banner',
|
defaultText: 'This is the default banner',
|
||||||
warningText: '',
|
warningText: '',
|
||||||
@@ -52,6 +53,7 @@ describe('<AppHeader />', () => {
|
|||||||
it('should render the banner with warning text', () => {
|
it('should render the banner with warning text', () => {
|
||||||
const mockConfig = makeFakeConfig();
|
const mockConfig = makeFakeConfig();
|
||||||
const uiState = {
|
const uiState = {
|
||||||
|
history: [],
|
||||||
bannerData: {
|
bannerData: {
|
||||||
defaultText: 'This is the default banner',
|
defaultText: 'This is the default banner',
|
||||||
warningText: 'There are capacity issues',
|
warningText: 'There are capacity issues',
|
||||||
@@ -72,6 +74,7 @@ describe('<AppHeader />', () => {
|
|||||||
it('should not render the banner when no flags are set', () => {
|
it('should not render the banner when no flags are set', () => {
|
||||||
const mockConfig = makeFakeConfig();
|
const mockConfig = makeFakeConfig();
|
||||||
const uiState = {
|
const uiState = {
|
||||||
|
history: [],
|
||||||
bannerData: {
|
bannerData: {
|
||||||
defaultText: '',
|
defaultText: '',
|
||||||
warningText: '',
|
warningText: '',
|
||||||
@@ -91,6 +94,7 @@ describe('<AppHeader />', () => {
|
|||||||
it('should render the banner when previewFeatures is disabled', () => {
|
it('should render the banner when previewFeatures is disabled', () => {
|
||||||
const mockConfig = makeFakeConfig({ previewFeatures: false });
|
const mockConfig = makeFakeConfig({ previewFeatures: false });
|
||||||
const uiState = {
|
const uiState = {
|
||||||
|
history: [],
|
||||||
bannerData: {
|
bannerData: {
|
||||||
defaultText: 'This is the default banner',
|
defaultText: 'This is the default banner',
|
||||||
warningText: '',
|
warningText: '',
|
||||||
@@ -111,6 +115,7 @@ describe('<AppHeader />', () => {
|
|||||||
it('should not render the banner when previewFeatures is enabled', () => {
|
it('should not render the banner when previewFeatures is enabled', () => {
|
||||||
const mockConfig = makeFakeConfig({ previewFeatures: true });
|
const mockConfig = makeFakeConfig({ previewFeatures: true });
|
||||||
const uiState = {
|
const uiState = {
|
||||||
|
history: [],
|
||||||
bannerData: {
|
bannerData: {
|
||||||
defaultText: 'This is the default banner',
|
defaultText: 'This is the default banner',
|
||||||
warningText: '',
|
warningText: '',
|
||||||
@@ -131,6 +136,7 @@ describe('<AppHeader />', () => {
|
|||||||
persistentStateMock.get.mockReturnValue(5);
|
persistentStateMock.get.mockReturnValue(5);
|
||||||
const mockConfig = makeFakeConfig();
|
const mockConfig = makeFakeConfig();
|
||||||
const uiState = {
|
const uiState = {
|
||||||
|
history: [],
|
||||||
bannerData: {
|
bannerData: {
|
||||||
defaultText: 'This is the default banner',
|
defaultText: 'This is the default banner',
|
||||||
warningText: '',
|
warningText: '',
|
||||||
@@ -151,6 +157,7 @@ describe('<AppHeader />', () => {
|
|||||||
persistentStateMock.get.mockReturnValue({});
|
persistentStateMock.get.mockReturnValue({});
|
||||||
const mockConfig = makeFakeConfig();
|
const mockConfig = makeFakeConfig();
|
||||||
const uiState = {
|
const uiState = {
|
||||||
|
history: [],
|
||||||
bannerData: {
|
bannerData: {
|
||||||
defaultText: 'This is the default banner',
|
defaultText: 'This is the default banner',
|
||||||
warningText: '',
|
warningText: '',
|
||||||
@@ -177,6 +184,7 @@ describe('<AppHeader />', () => {
|
|||||||
it('should render banner text with unescaped newlines', () => {
|
it('should render banner text with unescaped newlines', () => {
|
||||||
const mockConfig = makeFakeConfig();
|
const mockConfig = makeFakeConfig();
|
||||||
const uiState = {
|
const uiState = {
|
||||||
|
history: [],
|
||||||
bannerData: {
|
bannerData: {
|
||||||
defaultText: 'First line\\nSecond line',
|
defaultText: 'First line\\nSecond line',
|
||||||
warningText: '',
|
warningText: '',
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import { Text } from 'ink';
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
vi.mock('../hooks/useTerminalSize.js');
|
vi.mock('../hooks/useTerminalSize.js');
|
||||||
|
vi.mock('../hooks/useSnowfall.js', () => ({
|
||||||
|
useSnowfall: vi.fn((art) => art),
|
||||||
|
}));
|
||||||
vi.mock('../utils/terminalSetup.js', () => ({
|
vi.mock('../utils/terminalSetup.js', () => ({
|
||||||
getTerminalProgram: vi.fn(),
|
getTerminalProgram: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -159,7 +162,6 @@ describe('<Header />', () => {
|
|||||||
render(<Header version="1.0.0" nightly={false} />);
|
render(<Header version="1.0.0" nightly={false} />);
|
||||||
expect(Gradient.default).not.toHaveBeenCalled();
|
expect(Gradient.default).not.toHaveBeenCalled();
|
||||||
const textCalls = (Text as Mock).mock.calls;
|
const textCalls = (Text as Mock).mock.calls;
|
||||||
console.log(JSON.stringify(textCalls, null, 2));
|
|
||||||
expect(textCalls.length).toBe(1);
|
expect(textCalls.length).toBe(1);
|
||||||
expect(textCalls[0][0]).toHaveProperty('color', singleColor);
|
expect(textCalls[0][0]).toHaveProperty('color', singleColor);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
import { getTerminalProgram } from '../utils/terminalSetup.js';
|
import { getTerminalProgram } from '../utils/terminalSetup.js';
|
||||||
|
import { useSnowfall } from '../hooks/useSnowfall.js';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
customAsciiArt?: string; // For user-defined ASCII art
|
customAsciiArt?: string; // For user-defined ASCII art
|
||||||
@@ -47,6 +48,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const artWidth = getAsciiArtWidth(displayTitle);
|
const artWidth = getAsciiArtWidth(displayTitle);
|
||||||
|
const title = useSnowfall(displayTitle);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -55,7 +57,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
>
|
>
|
||||||
<ThemedGradient>{displayTitle}</ThemedGradient>
|
<ThemedGradient>{title}</ThemedGradient>
|
||||||
{nightly && (
|
{nightly && (
|
||||||
<Box width="100%" flexDirection="row" justifyContent="flex-end">
|
<Box width="100%" flexDirection="row" justifyContent="flex-end">
|
||||||
<ThemedGradient>v{version}</ThemedGradient>
|
<ThemedGradient>v{version}</ThemedGradient>
|
||||||
|
|||||||
108
packages/cli/src/ui/hooks/useSnowfall.test.tsx
Normal file
108
packages/cli/src/ui/hooks/useSnowfall.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { useSnowfall } from './useSnowfall.js';
|
||||||
|
import { themeManager } from '../themes/theme-manager.js';
|
||||||
|
import { renderHookWithProviders } from '../../test-utils/render.js';
|
||||||
|
import { act } from 'react';
|
||||||
|
import { debugState } from '../debug.js';
|
||||||
|
import type { Theme } from '../themes/theme.js';
|
||||||
|
import type { UIState } from '../contexts/UIStateContext.js';
|
||||||
|
|
||||||
|
vi.mock('../themes/theme-manager.js', () => ({
|
||||||
|
themeManager: {
|
||||||
|
getActiveTheme: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../themes/holiday.js', () => ({
|
||||||
|
Holiday: { name: 'Holiday' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./useTerminalSize.js', () => ({
|
||||||
|
useTerminalSize: vi.fn(() => ({ columns: 120, rows: 20 })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useSnowfall', () => {
|
||||||
|
const mockArt = 'LOGO';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.mocked(themeManager.getActiveTheme).mockReturnValue({
|
||||||
|
name: 'Holiday',
|
||||||
|
} as Theme);
|
||||||
|
vi.setSystemTime(new Date('2025-12-25'));
|
||||||
|
debugState.debugNumAnimatedComponents = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initially enables animation during holiday season with Holiday theme', () => {
|
||||||
|
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
|
||||||
|
uiState: { history: [], historyRemountKey: 0 } as Partial<UIState>,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should contain holiday trees
|
||||||
|
expect(result.current).toContain('|_|');
|
||||||
|
// Should have started animation
|
||||||
|
expect(debugState.debugNumAnimatedComponents).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops animation after 15 seconds', () => {
|
||||||
|
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
|
||||||
|
uiState: { history: [], historyRemountKey: 0 } as Partial<UIState>,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(debugState.debugNumAnimatedComponents).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(15001);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animation should be stopped
|
||||||
|
expect(debugState.debugNumAnimatedComponents).toBe(0);
|
||||||
|
// Should no longer contain trees
|
||||||
|
expect(result.current).toBe(mockArt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not enable animation if not holiday season', () => {
|
||||||
|
vi.setSystemTime(new Date('2025-06-15'));
|
||||||
|
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
|
||||||
|
uiState: { history: [], historyRemountKey: 0 } as Partial<UIState>,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(mockArt);
|
||||||
|
expect(debugState.debugNumAnimatedComponents).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not enable animation if theme is not Holiday', () => {
|
||||||
|
vi.mocked(themeManager.getActiveTheme).mockReturnValue({
|
||||||
|
name: 'Default',
|
||||||
|
} as Theme);
|
||||||
|
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
|
||||||
|
uiState: { history: [], historyRemountKey: 0 } as Partial<UIState>,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(mockArt);
|
||||||
|
expect(debugState.debugNumAnimatedComponents).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not enable animation if chat has started', () => {
|
||||||
|
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
|
||||||
|
uiState: {
|
||||||
|
history: [{ type: 'user', text: 'hello' }],
|
||||||
|
historyRemountKey: 0,
|
||||||
|
} as Partial<UIState>,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(mockArt);
|
||||||
|
expect(debugState.debugNumAnimatedComponents).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
162
packages/cli/src/ui/hooks/useSnowfall.ts
Normal file
162
packages/cli/src/ui/hooks/useSnowfall.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||||
|
import { debugState } from '../debug.js';
|
||||||
|
import { themeManager } from '../themes/theme-manager.js';
|
||||||
|
import { Holiday } from '../themes/holiday.js';
|
||||||
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
import { useTerminalSize } from './useTerminalSize.js';
|
||||||
|
import { shortAsciiLogo } from '../components/AsciiArt.js';
|
||||||
|
|
||||||
|
interface Snowflake {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
char: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNOW_CHARS = ['*', '.', '·', '+'];
|
||||||
|
const FRAME_RATE = 150; // ms
|
||||||
|
|
||||||
|
const addHolidayTrees = (art: string): string => {
|
||||||
|
const holidayTree = `
|
||||||
|
*
|
||||||
|
***
|
||||||
|
*****
|
||||||
|
*******
|
||||||
|
*********
|
||||||
|
|_|`;
|
||||||
|
|
||||||
|
const treeLines = holidayTree.split('\n').filter((l) => l.length > 0);
|
||||||
|
const treeWidth = getAsciiArtWidth(holidayTree);
|
||||||
|
const logoWidth = getAsciiArtWidth(art);
|
||||||
|
|
||||||
|
// Create three trees side by side
|
||||||
|
const treeSpacing = ' ';
|
||||||
|
const tripleTreeLines = treeLines.map((line) => {
|
||||||
|
const paddedLine = line.padEnd(treeWidth, ' ');
|
||||||
|
return `${paddedLine}${treeSpacing}${paddedLine}${treeSpacing}${paddedLine}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tripleTreeWidth = treeWidth * 3 + treeSpacing.length * 2;
|
||||||
|
const paddingCount = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((logoWidth - tripleTreeWidth) / 2),
|
||||||
|
);
|
||||||
|
const treePadding = ' '.repeat(paddingCount);
|
||||||
|
|
||||||
|
const centeredTripleTrees = tripleTreeLines
|
||||||
|
.map((line) => treePadding + line)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
// Add vertical padding and the trees below the logo
|
||||||
|
return `\n\n${art}\n${centeredTripleTrees}\n\n`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSnowfall = (displayTitle: string): string => {
|
||||||
|
const isHolidaySeason =
|
||||||
|
new Date().getMonth() === 11 || new Date().getMonth() === 0;
|
||||||
|
|
||||||
|
const currentTheme = themeManager.getActiveTheme();
|
||||||
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
|
const { history, historyRemountKey } = useUIState();
|
||||||
|
|
||||||
|
const hasStartedChat = history.some(
|
||||||
|
(item) => item.type === 'user' && item.text !== '/theme',
|
||||||
|
);
|
||||||
|
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
|
||||||
|
|
||||||
|
const [showSnow, setShowSnow] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowSnow(true);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowSnow(false);
|
||||||
|
}, 15000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [historyRemountKey]);
|
||||||
|
|
||||||
|
const showAnimation =
|
||||||
|
isHolidaySeason &&
|
||||||
|
currentTheme.name === Holiday.name &&
|
||||||
|
terminalWidth >= widthOfShortLogo &&
|
||||||
|
!hasStartedChat &&
|
||||||
|
showSnow;
|
||||||
|
|
||||||
|
const displayArt = useMemo(() => {
|
||||||
|
if (showAnimation) {
|
||||||
|
return addHolidayTrees(displayTitle);
|
||||||
|
}
|
||||||
|
return displayTitle;
|
||||||
|
}, [displayTitle, showAnimation]);
|
||||||
|
|
||||||
|
const [snowflakes, setSnowflakes] = useState<Snowflake[]>([]);
|
||||||
|
// We don't need 'frame' state if we just use functional updates for snowflakes,
|
||||||
|
// but we need a trigger. A simple interval is fine.
|
||||||
|
|
||||||
|
const lines = displayArt.split('\n');
|
||||||
|
const height = lines.length;
|
||||||
|
const width = getAsciiArtWidth(displayArt);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showAnimation) {
|
||||||
|
setSnowflakes([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugState.debugNumAnimatedComponents++;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setSnowflakes((prev) => {
|
||||||
|
// Move existing flakes
|
||||||
|
const moved = prev
|
||||||
|
.map((flake) => ({ ...flake, y: flake.y + 1 }))
|
||||||
|
.filter((flake) => flake.y < height);
|
||||||
|
|
||||||
|
// Spawn new flakes
|
||||||
|
// Adjust spawn rate based on width to keep density consistent
|
||||||
|
const spawnChance = 0.3;
|
||||||
|
const newFlakes: Snowflake[] = [];
|
||||||
|
|
||||||
|
if (Math.random() < spawnChance) {
|
||||||
|
// Spawn 1 to 2 flakes
|
||||||
|
const count = Math.floor(Math.random() * 2) + 1;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
newFlakes.push({
|
||||||
|
x: Math.floor(Math.random() * width),
|
||||||
|
y: 0,
|
||||||
|
char: SNOW_CHARS[Math.floor(Math.random() * SNOW_CHARS.length)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...moved, ...newFlakes];
|
||||||
|
});
|
||||||
|
}, FRAME_RATE);
|
||||||
|
return () => {
|
||||||
|
debugState.debugNumAnimatedComponents--;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [height, width, showAnimation]);
|
||||||
|
|
||||||
|
if (!showAnimation) return displayTitle;
|
||||||
|
|
||||||
|
// Render current frame
|
||||||
|
if (snowflakes.length === 0) return displayArt;
|
||||||
|
const grid = lines.map((line) => line.padEnd(width, ' ').split(''));
|
||||||
|
|
||||||
|
snowflakes.forEach((flake) => {
|
||||||
|
if (flake.y >= 0 && flake.y < height && flake.x >= 0 && flake.x < width) {
|
||||||
|
// Overwrite with snow character
|
||||||
|
// We check if the row exists just in case
|
||||||
|
if (grid[flake.y]) {
|
||||||
|
grid[flake.y][flake.x] = flake.char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return grid.map((row) => row.join('')).join('\n');
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user