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