Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -1477,6 +1489,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
warningText: warningBannerText,
},
bannerVisible,
customLogoVariants,
}),
[
isThemeDialogOpen,
Expand Down Expand Up @@ -1568,6 +1581,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
defaultBannerText,
warningBannerText,
bannerVisible,
customLogoVariants,
],
);

Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/ui/components/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,25 @@ 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);

return (
<Box flexDirection="column">
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
<>
<Header version={version} nightly={nightly} />
<Header
version={version}
nightly={nightly}
customLogoVariants={customLogoVariants}
/>
{bannerVisible && bannerText && (
<Banner
width={mainAreaWidth}
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/ui/components/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ describe('<Header />', () => {
it('renders custom ASCII art when provided', () => {
const customArt = 'CUSTOM ART';
render(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
<Header
version="1.0.0"
nightly={false}
customLogoVariants={{ longAsciiLogo: customArt }}
/>,
);
expect(Text).toHaveBeenCalledWith(
expect.objectContaining({
Expand All @@ -88,7 +92,11 @@ describe('<Header />', () => {
const customArt = 'CUSTOM ART';
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode');
render(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
<Header
version="1.0.0"
nightly={false}
customLogoVariants={{ longAsciiLogoIde: customArt }}
/>,
);
expect(Text).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down
35 changes: 24 additions & 11 deletions packages/cli/src/ui/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HeaderProps> = ({
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;
Comment on lines +43 to +48
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There's a potential improvement in the fallback logic for IDE-specific logos. Currently, if a user provides a custom longAsciiLogo but not a longAsciiLogoIde, the header will fall back to the defaultLongAsciiLogoIde when in an IDE environment. This might be unexpected for the user, who might assume their custom non-IDE logo would be used as a fallback.

Consider a more intuitive fallback chain: custom IDE variant -> custom non-IDE variant -> default IDE variant. This would provide a better user experience and more consistent branding for users who don't want to create separate IDE variants.

  const longAsciiLogoIde =
    customLogoVariants?.longAsciiLogoIde ??
    customLogoVariants?.longAsciiLogo ??
    defaultLongAsciiLogoIde;
  const shortAsciiLogoIde =
    customLogoVariants?.shortAsciiLogoIde ??
    customLogoVariants?.shortAsciiLogo ??
    defaultShortAsciiLogoIde;
  const tinyAsciiLogoIde =
    customLogoVariants?.tinyAsciiLogoIde ??
    customLogoVariants?.tinyAsciiLogo ??
    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;
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/contexts/UIStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -135,6 +136,7 @@ export interface UIState {
};
bannerVisible: boolean;
customDialog: React.ReactNode | null;
customLogoVariants: LogoVariants | undefined;
}

export const UIStateContext = createContext<UIState | null>(null);
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/ui/hooks/useCustomLogo.ts
Original file line number Diff line number Diff line change
@@ -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<LogoVariants | undefined>(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);
Comment on lines +27 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The type assertion as unknown as LogoVariants is unsafe and can lead to runtime errors if the TOML file contains non-string values for the logo variants. This can happen if a user provides a malformed file. It's crucial to validate the structure and types of the parsed TOML content before using it. This will make the feature more robust against invalid user input.

        const parsed = toml.parse(content);

        if (typeof parsed !== 'object' || parsed === null) {
          throw new Error('TOML file content is not a valid object.');
        }

        // Ensure all logo variants are strings.
        for (const [key, value] of Object.entries(parsed)) {
          if (typeof value !== 'string') {
            throw new Error(`Invalid type for logo variant "${key}": expected string, got ${typeof value}.`);
          }
        }

        setVariants(parsed as LogoVariants);

} catch (e) {
const msg = `Failed to load custom logo variants from "${variantsFilePath}": ${getErrorMessage(e)}`;
debugLogger.warn(msg);
setVariants(undefined);
}
};

loadVariants();
}, [variantsFilePath]);

return variants;
}
9 changes: 9 additions & 0 deletions packages/cli/src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 6 additions & 0 deletions schemas/settings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down