diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 478aaceeeb4..74388c816a6 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -32,6 +32,7 @@ describe('', () => { it('should render the banner with default text', () => { const mockConfig = makeFakeConfig(); const uiState = { + history: [], bannerData: { defaultText: 'This is the default banner', warningText: '', @@ -52,6 +53,7 @@ describe('', () => { it('should render the banner with warning text', () => { const mockConfig = makeFakeConfig(); const uiState = { + history: [], bannerData: { defaultText: 'This is the default banner', warningText: 'There are capacity issues', @@ -72,6 +74,7 @@ describe('', () => { it('should not render the banner when no flags are set', () => { const mockConfig = makeFakeConfig(); const uiState = { + history: [], bannerData: { defaultText: '', warningText: '', @@ -91,6 +94,7 @@ describe('', () => { it('should render the banner when previewFeatures is disabled', () => { const mockConfig = makeFakeConfig({ previewFeatures: false }); const uiState = { + history: [], bannerData: { defaultText: 'This is the default banner', warningText: '', @@ -111,6 +115,7 @@ describe('', () => { it('should not render the banner when previewFeatures is enabled', () => { const mockConfig = makeFakeConfig({ previewFeatures: true }); const uiState = { + history: [], bannerData: { defaultText: 'This is the default banner', warningText: '', @@ -131,6 +136,7 @@ describe('', () => { persistentStateMock.get.mockReturnValue(5); const mockConfig = makeFakeConfig(); const uiState = { + history: [], bannerData: { defaultText: 'This is the default banner', warningText: '', @@ -151,6 +157,7 @@ describe('', () => { persistentStateMock.get.mockReturnValue({}); const mockConfig = makeFakeConfig(); const uiState = { + history: [], bannerData: { defaultText: 'This is the default banner', warningText: '', @@ -177,6 +184,7 @@ describe('', () => { it('should render banner text with unescaped newlines', () => { const mockConfig = makeFakeConfig(); const uiState = { + history: [], bannerData: { defaultText: 'First line\\nSecond line', warningText: '', diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index ecf1adf8b0d..5200db17d4d 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -15,6 +15,9 @@ import { Text } from 'ink'; import type React from 'react'; vi.mock('../hooks/useTerminalSize.js'); +vi.mock('../hooks/useSnowfall.js', () => ({ + useSnowfall: vi.fn((art) => art), +})); vi.mock('../utils/terminalSetup.js', () => ({ getTerminalProgram: vi.fn(), })); @@ -159,7 +162,6 @@ describe('
', () => { render(
); expect(Gradient.default).not.toHaveBeenCalled(); const textCalls = (Text as Mock).mock.calls; - console.log(JSON.stringify(textCalls, null, 2)); expect(textCalls.length).toBe(1); expect(textCalls[0][0]).toHaveProperty('color', singleColor); }); diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index 8fd773be4d9..52fd0175c5f 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -18,6 +18,7 @@ import { import { getAsciiArtWidth } from '../utils/textUtils.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { getTerminalProgram } from '../utils/terminalSetup.js'; +import { useSnowfall } from '../hooks/useSnowfall.js'; interface HeaderProps { customAsciiArt?: string; // For user-defined ASCII art @@ -47,6 +48,7 @@ export const Header: React.FC = ({ } const artWidth = getAsciiArtWidth(displayTitle); + const title = useSnowfall(displayTitle); return ( = ({ flexShrink={0} flexDirection="column" > - {displayTitle} + {title} {nightly && ( v{version} diff --git a/packages/cli/src/ui/hooks/useSnowfall.test.tsx b/packages/cli/src/ui/hooks/useSnowfall.test.tsx new file mode 100644 index 00000000000..004af733ca4 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSnowfall.test.tsx @@ -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, + }); + + // 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, + }); + + 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, + }); + + 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, + }); + + 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, + }); + + expect(result.current).toBe(mockArt); + expect(debugState.debugNumAnimatedComponents).toBe(0); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSnowfall.ts b/packages/cli/src/ui/hooks/useSnowfall.ts new file mode 100644 index 00000000000..6edb2e4b921 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSnowfall.ts @@ -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([]); + // 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'); +};