ui: update & subdue footer colors and animate progress indicator (#18570)

This commit is contained in:
Keith Guerin
2026-02-10 09:36:20 -08:00
committed by GitHub
parent f5b1245f51
commit 5920750c24
17 changed files with 106 additions and 68 deletions

View File

@@ -107,7 +107,7 @@ Set the theme to "Light".
Set the theme to "Dark".
</extension_context>
What theme should I use?`,
What theme should I use? Tell me just the name of the theme.`,
assert: async (_rig, result) => {
assertModelHasOutput(result);
expect(result).toMatch(/Dark/i);

View File

@@ -24,8 +24,8 @@ export const ContextUsageDisplay = ({
return (
<Text color={theme.text.secondary}>
({percentageLeft}
{label})
{percentageLeft}
{label}
</Text>
);
};

View File

@@ -128,7 +128,7 @@ describe('<Footer />', () => {
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
expect(lastFrame()).toMatch(/\d+% context left/);
});
it('displays the usage indicator when usage is low', () => {
@@ -207,7 +207,7 @@ describe('<Footer />', () => {
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+%\)/);
expect(lastFrame()).toMatch(/\d+%/);
});
describe('sandbox and trust info', () => {
@@ -352,9 +352,8 @@ describe('<Footer />', () => {
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\(\d+% context left\)/);
expect(lastFrame()).not.toMatch(/\d+% context left/);
});
it('shows the context percentage when hideContextPercentage is false', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
@@ -368,9 +367,8 @@ describe('<Footer />', () => {
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
expect(lastFrame()).toMatch(/\d+% context left/);
});
it('renders complete footer in narrow terminal (baseline narrow)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 79,

View File

@@ -14,7 +14,6 @@ import {
} from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import { ThemedGradient } from './ThemedGradient.js';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { QuotaDisplay } from './QuotaDisplay.js';
@@ -41,7 +40,6 @@ export const Footer: React.FC = () => {
errorCount,
showErrorDetails,
promptTokenCount,
nightly,
isTrustedFolder,
terminalWidth,
quotaStats,
@@ -55,7 +53,6 @@ export const Footer: React.FC = () => {
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
nightly: uiState.nightly,
isTrustedFolder: uiState.isTrustedFolder,
terminalWidth: uiState.terminalWidth,
quotaStats: uiState.quota.stats,
@@ -90,20 +87,14 @@ export const Footer: React.FC = () => {
{displayVimMode && (
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
)}
{!hideCWD &&
(nightly ? (
<ThemedGradient>
{displayPath}
{branchName && <Text> ({branchName}*)</Text>}
</ThemedGradient>
) : (
<Text color={theme.text.link}>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
))}
{!hideCWD && (
<Text color={theme.text.primary}>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
)}
{debugMode && (
<Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')}
@@ -149,9 +140,9 @@ export const Footer: React.FC = () => {
{!hideModelInfo && (
<Box alignItems="center" justifyContent="flex-end">
<Box alignItems="center">
<Text color={theme.text.accent}>
<Text color={theme.text.primary}>
<Text color={theme.text.secondary}>/model </Text>
{getDisplayString(model)}
<Text color={theme.text.secondary}> /model</Text>
{!hideContextPercentage && (
<>
{' '}

View File

@@ -5,6 +5,7 @@
*/
import type React from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Text, useIsScreenReaderEnabled } from 'ink';
import { CliSpinner } from './CliSpinner.js';
import type { SpinnerName } from 'cli-spinners';
@@ -15,6 +16,10 @@ import {
SCREEN_READER_RESPONDING,
} from '../textConstants.js';
import { theme } from '../semantic-colors.js';
import { Colors } from '../colors.js';
import tinygradient from 'tinygradient';
const COLOR_CYCLE_DURATION_MS = 4000;
interface GeminiRespondingSpinnerProps {
/**
@@ -37,13 +42,16 @@ export const GeminiRespondingSpinner: React.FC<
altText={SCREEN_READER_RESPONDING}
/>
);
} else if (nonRespondingDisplay) {
}
if (nonRespondingDisplay) {
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_LOADING}</Text>
) : (
<Text color={theme.text.primary}>{nonRespondingDisplay}</Text>
);
}
return null;
};
@@ -57,10 +65,39 @@ export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
altText,
}) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const [time, setTime] = useState(0);
const googleGradient = useMemo(() => {
const brandColors = [
Colors.AccentPurple,
Colors.AccentBlue,
Colors.AccentCyan,
Colors.AccentGreen,
Colors.AccentYellow,
Colors.AccentRed,
];
return tinygradient([...brandColors, brandColors[0]]);
}, []);
useEffect(() => {
if (isScreenReaderEnabled) {
return;
}
const interval = setInterval(() => {
setTime((prevTime) => prevTime + 30);
}, 30); // ~33fps for smooth color transitions
return () => clearInterval(interval);
}, [isScreenReaderEnabled]);
const progress = (time % COLOR_CYCLE_DURATION_MS) / COLOR_CYCLE_DURATION_MS;
const currentColor = googleGradient.rgbAt(progress).toHexString();
return isScreenReaderEnabled ? (
<Text>{altText}</Text>
) : (
<Text color={theme.text.primary}>
<Text color={currentColor}>
<CliSpinner type={spinnerType} />
</Text>
);

View File

@@ -56,7 +56,10 @@ import {
} from '../utils/commandUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { DEFAULT_BACKGROUND_OPACITY } from '../constants.js';
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
} from '../constants.js';
import { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
@@ -1405,12 +1408,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
/>
) : null}
<HalfLinePaddedBox
backgroundBaseColor={
isShellFocused && !isEmbeddedShellFocused
? theme.border.focused
: theme.border.default
backgroundBaseColor={theme.text.secondary}
backgroundOpacity={
showCursor
? DEFAULT_INPUT_BACKGROUND_OPACITY
: DEFAULT_BACKGROUND_OPACITY
}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>
<Box

View File

@@ -63,12 +63,12 @@ describe('<LoadingIndicator />', () => {
elapsedTime: 5,
};
it('should not render when streamingState is Idle and no loading phrase or thought', () => {
it('should render blank when streamingState is Idle and no loading phrase or thought', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
expect(lastFrame()).toBe('');
expect(lastFrame()?.trim()).toBe('');
});
it('should render spinner, phrase, and time when streamingState is Responding', () => {
@@ -152,7 +152,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
expect(lastFrame()).toBe(''); // Initial: Idle (no loading phrase)
expect(lastFrame()?.trim()).toBe(''); // Initial: Idle (no loading phrase)
// Transition to Responding
rerender(
@@ -189,7 +189,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator elapsedTime={5} />
</StreamingContext.Provider>,
);
expect(lastFrame()).toBe(''); // Idle with no loading phrase
expect(lastFrame()?.trim()).toBe(''); // Idle with no loading phrase and no spinner
unmount();
});

View File

@@ -82,7 +82,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
/>
</Box>
{primaryText && (
<Text color={theme.text.accent} wrap="truncate-end">
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
@@ -116,7 +116,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
/>
</Box>
{primaryText && (
<Text color={theme.text.accent} wrap="truncate-end">
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>

View File

@@ -1,12 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model Limit reached"`;
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro Limit reached"`;
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model 15%"`;
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 15%"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro /model (100%)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox /model gemini-pro 100%"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model (100% context left)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 100% context left"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
@@ -14,4 +14,4 @@ exports[`<Footer /> > footer configuration filtering (golden snapshots) > render
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `" ...directories/to/make/it/long no sandbox (see /docs)"`;
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model"`;
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro"`;

View File

@@ -52,7 +52,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
return (
<HalfLinePaddedBox
backgroundBaseColor={theme.border.default}
backgroundBaseColor={theme.text.secondary}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>

View File

@@ -28,7 +28,7 @@ export const UserShellMessage: React.FC<UserShellMessageProps> = ({
return (
<HalfLinePaddedBox
backgroundBaseColor={theme.border.default}
backgroundBaseColor={theme.text.secondary}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>

View File

@@ -34,7 +34,9 @@ export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000;
export const DEFAULT_BACKGROUND_OPACITY = 0.08;
export const DEFAULT_BACKGROUND_OPACITY = 0.16;
export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24;
export const DEFAULT_BORDER_OPACITY = 0.2;
export const KEYBOARD_SHORTCUTS_URL =
'https://geminicli.com/docs/cli/keyboard-shortcuts/';

View File

@@ -121,7 +121,7 @@ export interface UIState {
showEscapePrompt: boolean;
shortcutsHelpVisible: boolean;
elapsedTime: number;
currentLoadingPhrase: string;
currentLoadingPhrase: string | undefined;
historyRemountKey: number;
activeHooks: ActiveHook[];
messageQueue: string[];

View File

@@ -76,9 +76,7 @@ describe('useLoadingIndicator', () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
const { result } = renderLoadingIndicatorHook(StreamingState.Idle);
expect(result.current.elapsedTime).toBe(0);
expect(WITTY_LOADING_PHRASES).toContain(
result.current.currentLoadingPhrase,
);
expect(result.current.currentLoadingPhrase).toBeUndefined();
});
it('should show interactive shell waiting phrase when shouldShowFocusHint is true', async () => {
@@ -198,9 +196,7 @@ describe('useLoadingIndicator', () => {
});
expect(result.current.elapsedTime).toBe(0);
expect(WITTY_LOADING_PHRASES).toContain(
result.current.currentLoadingPhrase,
);
expect(result.current.currentLoadingPhrase).toBeUndefined();
// Timer should not advance
await act(async () => {

View File

@@ -45,12 +45,12 @@ describe('usePhraseCycler', () => {
vi.restoreAllMocks();
});
it('should initialize with a witty phrase when not active and not waiting', () => {
it('should initialize with an empty string when not active and not waiting', () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
const { lastFrame } = render(
<TestComponent isActive={false} isWaiting={false} />,
);
expect(WITTY_LOADING_PHRASES).toContain(lastFrame());
expect(lastFrame()).toBe('');
});
it('should show "Waiting for user confirmation..." when isWaiting is true', async () => {
@@ -195,7 +195,7 @@ describe('usePhraseCycler', () => {
});
expect(customPhrases).toContain(lastFrame()); // Should be one of the custom phrases
// Deactivate -> resets to first phrase in sequence
// Deactivate -> resets to undefined (empty string in output)
rerender(
<TestComponent
isActive={false}
@@ -206,8 +206,8 @@ describe('usePhraseCycler', () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
// The phrase should be the first phrase after reset
expect(customPhrases).toContain(lastFrame());
// The phrase should be empty after reset
expect(lastFrame()).toBe('');
// Activate again -> this will show a tip on first activation, then cycle from where mock is
rerender(

View File

@@ -31,9 +31,9 @@ export const usePhraseCycler = (
? customPhrases
: WITTY_LOADING_PHRASES;
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(
loadingPhrases[0],
);
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState<
string | undefined
>(isActive ? loadingPhrases[0] : undefined);
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
const hasShownFirstRequestTipRef = useRef(false);
@@ -56,7 +56,7 @@ export const usePhraseCycler = (
}
if (!isActive) {
setCurrentLoadingPhrase(loadingPhrases[0]);
setCurrentLoadingPhrase(undefined);
return;
}

View File

@@ -15,6 +15,7 @@ import {
} from './color-utils.js';
import type { CustomTheme } from '@google/gemini-cli-core';
import { DEFAULT_BORDER_OPACITY } from '../constants.js';
export type { CustomTheme };
@@ -136,7 +137,11 @@ export class Theme {
},
},
border: {
default: this.colors.Gray,
default: interpolateColor(
this.colors.Background,
this.colors.Gray,
DEFAULT_BORDER_OPACITY,
),
focused: this.colors.AccentBlue,
},
ui: {
@@ -401,7 +406,13 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
},
},
border: {
default: customTheme.border?.default ?? colors.Gray,
default:
customTheme.border?.default ??
interpolateColor(
colors.Background,
colors.Gray,
DEFAULT_BORDER_OPACITY,
),
focused: customTheme.border?.focused ?? colors.AccentBlue,
},
ui: {