diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 0fcae7874a5..b989ac08836 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -544,6 +544,17 @@ const SETTINGS_SCHEMA = { showInDialog: false, items: { type: 'string' }, }, + customLogoVariantsFile: { + type: 'string', + label: 'Custom Logo Variants File', + category: 'UI', + requiresRestart: false, + default: undefined as string | undefined, + description: oneLine` + Path to a TOML file containing custom ASCII art variants for the header logo. + `, + showInDialog: false, + }, accessibility: { type: 'object', label: 'Accessibility', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 2435976f9dd..b618cc60a98 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -117,6 +117,7 @@ import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useSettings } from './contexts/SettingsContext.js'; import { enableSupportedProtocol } from './utils/kittyProtocolDetector.js'; import { enableBracketedPaste } from './utils/bracketedPaste.js'; +import { useCustomLogo } from './hooks/useCustomLogo.js'; const WARNING_PROMPT_DURATION_MS = 1000; const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; @@ -214,6 +215,10 @@ export const AppContainer = (props: AppContainerProps) => { config.getEnableExtensionReloading(), ); + const customLogoVariants = useCustomLogo( + settings.merged.ui?.customLogoVariantsFile, + ); + const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false); const [permissionsDialogProps, setPermissionsDialogProps] = useState<{ targetDirectory?: string; @@ -377,6 +382,13 @@ export const AppContainer = (props: AppContainerProps) => { } setHistoryRemountKey((prev) => prev + 1); }, [setHistoryRemountKey, isAlternateBuffer, stdout]); + + useEffect(() => { + if (customLogoVariants) { + refreshStatic(); + } + }, [customLogoVariants, refreshStatic]); + const handleEditorClose = useCallback(() => { if ( shouldEnterAlternateScreen(isAlternateBuffer, config.getScreenReader()) @@ -1477,6 +1489,7 @@ Logging in with Google... Restarting Gemini CLI to continue. warningText: warningBannerText, }, bannerVisible, + customLogoVariants, }), [ isThemeDialogOpen, @@ -1568,6 +1581,7 @@ Logging in with Google... Restarting Gemini CLI to continue. defaultBannerText, warningBannerText, bannerVisible, + customLogoVariants, ], ); diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index c404c0e9f99..eb4beae4cff 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -20,7 +20,13 @@ interface AppHeaderProps { export const AppHeader = ({ version }: AppHeaderProps) => { const settings = useSettings(); const config = useConfig(); - const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState(); + const { + nightly, + mainAreaWidth, + bannerData, + bannerVisible, + customLogoVariants, + } = useUIState(); const { bannerText } = useBanner(bannerData, config); @@ -28,7 +34,11 @@ export const AppHeader = ({ version }: AppHeaderProps) => { {!(settings.merged.ui?.hideBanner || config.getScreenReader()) && ( <> -
+
{bannerVisible && bannerText && ( ', () => { it('renders custom ASCII art when provided', () => { const customArt = 'CUSTOM ART'; render( -
, +
, ); expect(Text).toHaveBeenCalledWith( expect.objectContaining({ @@ -88,7 +92,11 @@ describe('
', () => { const customArt = 'CUSTOM ART'; vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode'); render( -
, +
, ); expect(Text).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index 8fd773be4d9..09326515b04 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -8,37 +8,50 @@ import type React from 'react'; import { Box } from 'ink'; import { ThemedGradient } from './ThemedGradient.js'; import { - shortAsciiLogo, - longAsciiLogo, - tinyAsciiLogo, - shortAsciiLogoIde, - longAsciiLogoIde, - tinyAsciiLogoIde, + shortAsciiLogo as defaultShortAsciiLogo, + longAsciiLogo as defaultLongAsciiLogo, + tinyAsciiLogo as defaultTinyAsciiLogo, + shortAsciiLogoIde as defaultShortAsciiLogoIde, + longAsciiLogoIde as defaultLongAsciiLogoIde, + tinyAsciiLogoIde as defaultTinyAsciiLogoIde, } from './AsciiArt.js'; import { getAsciiArtWidth } from '../utils/textUtils.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { getTerminalProgram } from '../utils/terminalSetup.js'; +import type { LogoVariants } from '../types.js'; interface HeaderProps { - customAsciiArt?: string; // For user-defined ASCII art + customLogoVariants?: LogoVariants; version: string; nightly: boolean; } export const Header: React.FC = ({ - customAsciiArt, + customLogoVariants, version, nightly, }) => { const { columns: terminalWidth } = useTerminalSize(); const isIde = getTerminalProgram(); + + const longAsciiLogo = + customLogoVariants?.longAsciiLogo ?? defaultLongAsciiLogo; + const shortAsciiLogo = + customLogoVariants?.shortAsciiLogo ?? defaultShortAsciiLogo; + const tinyAsciiLogo = + customLogoVariants?.tinyAsciiLogo ?? defaultTinyAsciiLogo; + const longAsciiLogoIde = + customLogoVariants?.longAsciiLogoIde ?? defaultLongAsciiLogoIde; + const shortAsciiLogoIde = + customLogoVariants?.shortAsciiLogoIde ?? defaultShortAsciiLogoIde; + const tinyAsciiLogoIde = + customLogoVariants?.tinyAsciiLogoIde ?? defaultTinyAsciiLogoIde; + let displayTitle; const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo); const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo); - if (customAsciiArt) { - displayTitle = customAsciiArt; - } else if (terminalWidth >= widthOfLongLogo) { + if (terminalWidth >= widthOfLongLogo) { displayTitle = isIde ? longAsciiLogoIde : longAsciiLogo; } else if (terminalWidth >= widthOfShortLogo) { displayTitle = isIde ? shortAsciiLogoIde : shortAsciiLogo; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 907a374cb12..79495850889 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -14,6 +14,7 @@ import type { LoopDetectionConfirmationRequest, HistoryItemWithoutId, StreamingState, + LogoVariants, } from '../types.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; @@ -135,6 +136,7 @@ export interface UIState { }; bannerVisible: boolean; customDialog: React.ReactNode | null; + customLogoVariants: LogoVariants | undefined; } export const UIStateContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/useCustomLogo.ts b/packages/cli/src/ui/hooks/useCustomLogo.ts new file mode 100644 index 00000000000..27040a0d830 --- /dev/null +++ b/packages/cli/src/ui/hooks/useCustomLogo.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useState } from 'react'; +import * as fs from 'node:fs/promises'; +import { getErrorMessage, debugLogger } from '@google/gemini-cli-core'; +import toml from '@iarna/toml'; +import type { LogoVariants } from '../types.js'; + +export function useCustomLogo( + variantsFilePath: string | undefined, +): LogoVariants | undefined { + const [variants, setVariants] = useState(undefined); + + useEffect(() => { + if (!variantsFilePath) { + setVariants(undefined); + return; + } + + const loadVariants = async () => { + try { + const content = await fs.readFile(variantsFilePath, 'utf-8'); + const parsed = toml.parse(content) as unknown as LogoVariants; + setVariants(parsed); + } catch (e) { + const msg = `Failed to load custom logo variants from "${variantsFilePath}": ${getErrorMessage(e)}`; + debugLogger.warn(msg); + setVariants(undefined); + } + }; + + loadVariants(); + }, [variantsFilePath]); + + return variants; +} diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 491a1eede1f..57cb280a32b 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -384,3 +384,12 @@ export interface ConfirmationRequest { export interface LoopDetectionConfirmationRequest { onComplete: (result: { userSelection: 'disable' | 'keep' }) => void; } + +export interface LogoVariants { + longAsciiLogo?: string; + shortAsciiLogo?: string; + tinyAsciiLogo?: string; + longAsciiLogoIde?: string; + shortAsciiLogoIde?: string; + tinyAsciiLogoIde?: string; +} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 8f2f74f7f51..ca725883104 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -313,6 +313,12 @@ "type": "string" } }, + "customLogoVariantsFile": { + "title": "Custom Logo Variants File", + "description": "Path to a TOML file containing custom ASCII art variants for the header logo.", + "markdownDescription": "Path to a TOML file containing custom ASCII art variants for the header logo.\n\n- Category: `UI`\n- Requires restart: `no`", + "type": "string" + }, "accessibility": { "title": "Accessibility", "description": "Accessibility settings.",