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
+1 -1
View File
@@ -107,7 +107,7 @@ Set the theme to "Light".
Set the theme to "Dark". Set the theme to "Dark".
</extension_context> </extension_context>
What theme should I use?`, What theme should I use? Tell me just the name of the theme.`,
assert: async (_rig, result) => { assert: async (_rig, result) => {
assertModelHasOutput(result); assertModelHasOutput(result);
expect(result).toMatch(/Dark/i); expect(result).toMatch(/Dark/i);
@@ -24,8 +24,8 @@ export const ContextUsageDisplay = ({
return ( return (
<Text color={theme.text.secondary}> <Text color={theme.text.secondary}>
({percentageLeft} {percentageLeft}
{label}) {label}
</Text> </Text>
); );
}; };
@@ -128,7 +128,7 @@ describe('<Footer />', () => {
}), }),
}); });
expect(lastFrame()).toContain(defaultProps.model); 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', () => { it('displays the usage indicator when usage is low', () => {
@@ -207,7 +207,7 @@ describe('<Footer />', () => {
}), }),
}); });
expect(lastFrame()).toContain(defaultProps.model); expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+%\)/); expect(lastFrame()).toMatch(/\d+%/);
}); });
describe('sandbox and trust info', () => { describe('sandbox and trust info', () => {
@@ -352,9 +352,8 @@ describe('<Footer />', () => {
}), }),
}); });
expect(lastFrame()).toContain(defaultProps.model); 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', () => { it('shows the context percentage when hideContextPercentage is false', () => {
const { lastFrame } = renderWithProviders(<Footer />, { const { lastFrame } = renderWithProviders(<Footer />, {
width: 120, width: 120,
@@ -368,9 +367,8 @@ describe('<Footer />', () => {
}), }),
}); });
expect(lastFrame()).toContain(defaultProps.model); 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)', () => { it('renders complete footer in narrow terminal (baseline narrow)', () => {
const { lastFrame } = renderWithProviders(<Footer />, { const { lastFrame } = renderWithProviders(<Footer />, {
width: 79, width: 79,
+10 -19
View File
@@ -14,7 +14,6 @@ import {
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js'; import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process'; import process from 'node:process';
import { ThemedGradient } from './ThemedGradient.js';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js'; import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { QuotaDisplay } from './QuotaDisplay.js'; import { QuotaDisplay } from './QuotaDisplay.js';
@@ -41,7 +40,6 @@ export const Footer: React.FC = () => {
errorCount, errorCount,
showErrorDetails, showErrorDetails,
promptTokenCount, promptTokenCount,
nightly,
isTrustedFolder, isTrustedFolder,
terminalWidth, terminalWidth,
quotaStats, quotaStats,
@@ -55,7 +53,6 @@ export const Footer: React.FC = () => {
errorCount: uiState.errorCount, errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails, showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount, promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
nightly: uiState.nightly,
isTrustedFolder: uiState.isTrustedFolder, isTrustedFolder: uiState.isTrustedFolder,
terminalWidth: uiState.terminalWidth, terminalWidth: uiState.terminalWidth,
quotaStats: uiState.quota.stats, quotaStats: uiState.quota.stats,
@@ -90,20 +87,14 @@ export const Footer: React.FC = () => {
{displayVimMode && ( {displayVimMode && (
<Text color={theme.text.secondary}>[{displayVimMode}] </Text> <Text color={theme.text.secondary}>[{displayVimMode}] </Text>
)} )}
{!hideCWD && {!hideCWD && (
(nightly ? ( <Text color={theme.text.primary}>
<ThemedGradient> {displayPath}
{displayPath} {branchName && (
{branchName && <Text> ({branchName}*)</Text>} <Text color={theme.text.secondary}> ({branchName}*)</Text>
</ThemedGradient> )}
) : ( </Text>
<Text color={theme.text.link}> )}
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
))}
{debugMode && ( {debugMode && (
<Text color={theme.status.error}> <Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')} {' ' + (debugMessage || '--debug')}
@@ -149,9 +140,9 @@ export const Footer: React.FC = () => {
{!hideModelInfo && ( {!hideModelInfo && (
<Box alignItems="center" justifyContent="flex-end"> <Box alignItems="center" justifyContent="flex-end">
<Box alignItems="center"> <Box alignItems="center">
<Text color={theme.text.accent}> <Text color={theme.text.primary}>
<Text color={theme.text.secondary}>/model </Text>
{getDisplayString(model)} {getDisplayString(model)}
<Text color={theme.text.secondary}> /model</Text>
{!hideContextPercentage && ( {!hideContextPercentage && (
<> <>
{' '} {' '}
@@ -5,6 +5,7 @@
*/ */
import type React from 'react'; import type React from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Text, useIsScreenReaderEnabled } from 'ink'; import { Text, useIsScreenReaderEnabled } from 'ink';
import { CliSpinner } from './CliSpinner.js'; import { CliSpinner } from './CliSpinner.js';
import type { SpinnerName } from 'cli-spinners'; import type { SpinnerName } from 'cli-spinners';
@@ -15,6 +16,10 @@ import {
SCREEN_READER_RESPONDING, SCREEN_READER_RESPONDING,
} from '../textConstants.js'; } from '../textConstants.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { Colors } from '../colors.js';
import tinygradient from 'tinygradient';
const COLOR_CYCLE_DURATION_MS = 4000;
interface GeminiRespondingSpinnerProps { interface GeminiRespondingSpinnerProps {
/** /**
@@ -37,13 +42,16 @@ export const GeminiRespondingSpinner: React.FC<
altText={SCREEN_READER_RESPONDING} altText={SCREEN_READER_RESPONDING}
/> />
); );
} else if (nonRespondingDisplay) { }
if (nonRespondingDisplay) {
return isScreenReaderEnabled ? ( return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_LOADING}</Text> <Text>{SCREEN_READER_LOADING}</Text>
) : ( ) : (
<Text color={theme.text.primary}>{nonRespondingDisplay}</Text> <Text color={theme.text.primary}>{nonRespondingDisplay}</Text>
); );
} }
return null; return null;
}; };
@@ -57,10 +65,39 @@ export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
altText, altText,
}) => { }) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled(); 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 ? ( return isScreenReaderEnabled ? (
<Text>{altText}</Text> <Text>{altText}</Text>
) : ( ) : (
<Text color={theme.text.primary}> <Text color={currentColor}>
<CliSpinner type={spinnerType} /> <CliSpinner type={spinnerType} />
</Text> </Text>
); );
@@ -56,7 +56,10 @@ import {
} from '../utils/commandUtils.js'; } from '../utils/commandUtils.js';
import * as path from 'node:path'; import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js'; 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 { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js'; import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js'; import { useShellFocusState } from '../contexts/ShellFocusContext.js';
@@ -1405,12 +1408,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
/> />
) : null} ) : null}
<HalfLinePaddedBox <HalfLinePaddedBox
backgroundBaseColor={ backgroundBaseColor={theme.text.secondary}
isShellFocused && !isEmbeddedShellFocused backgroundOpacity={
? theme.border.focused showCursor
: theme.border.default ? DEFAULT_INPUT_BACKGROUND_OPACITY
: DEFAULT_BACKGROUND_OPACITY
} }
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor} useBackgroundColor={useBackgroundColor}
> >
<Box <Box
@@ -63,12 +63,12 @@ describe('<LoadingIndicator />', () => {
elapsedTime: 5, 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( const { lastFrame } = renderWithContext(
<LoadingIndicator elapsedTime={5} />, <LoadingIndicator elapsedTime={5} />,
StreamingState.Idle, StreamingState.Idle,
); );
expect(lastFrame()).toBe(''); expect(lastFrame()?.trim()).toBe('');
}); });
it('should render spinner, phrase, and time when streamingState is Responding', () => { it('should render spinner, phrase, and time when streamingState is Responding', () => {
@@ -152,7 +152,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator elapsedTime={5} />, <LoadingIndicator elapsedTime={5} />,
StreamingState.Idle, StreamingState.Idle,
); );
expect(lastFrame()).toBe(''); // Initial: Idle (no loading phrase) expect(lastFrame()?.trim()).toBe(''); // Initial: Idle (no loading phrase)
// Transition to Responding // Transition to Responding
rerender( rerender(
@@ -189,7 +189,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator elapsedTime={5} /> <LoadingIndicator elapsedTime={5} />
</StreamingContext.Provider>, </StreamingContext.Provider>,
); );
expect(lastFrame()).toBe(''); // Idle with no loading phrase expect(lastFrame()?.trim()).toBe(''); // Idle with no loading phrase and no spinner
unmount(); unmount();
}); });
@@ -82,7 +82,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
/> />
</Box> </Box>
{primaryText && ( {primaryText && (
<Text color={theme.text.accent} wrap="truncate-end"> <Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator} {thinkingIndicator}
{primaryText} {primaryText}
</Text> </Text>
@@ -116,7 +116,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
/> />
</Box> </Box>
{primaryText && ( {primaryText && (
<Text color={theme.text.accent} wrap="truncate-end"> <Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator} {thinkingIndicator}
{primaryText} {primaryText}
</Text> </Text>
@@ -1,12 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // 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)"`; 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 /> > 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"`;
@@ -52,7 +52,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
return ( return (
<HalfLinePaddedBox <HalfLinePaddedBox
backgroundBaseColor={theme.border.default} backgroundBaseColor={theme.text.secondary}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY} backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor} useBackgroundColor={useBackgroundColor}
> >
@@ -28,7 +28,7 @@ export const UserShellMessage: React.FC<UserShellMessageProps> = ({
return ( return (
<HalfLinePaddedBox <HalfLinePaddedBox
backgroundBaseColor={theme.border.default} backgroundBaseColor={theme.text.secondary}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY} backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor} useBackgroundColor={useBackgroundColor}
> >
+3 -1
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_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000; 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 = export const KEYBOARD_SHORTCUTS_URL =
'https://geminicli.com/docs/cli/keyboard-shortcuts/'; 'https://geminicli.com/docs/cli/keyboard-shortcuts/';
@@ -121,7 +121,7 @@ export interface UIState {
showEscapePrompt: boolean; showEscapePrompt: boolean;
shortcutsHelpVisible: boolean; shortcutsHelpVisible: boolean;
elapsedTime: number; elapsedTime: number;
currentLoadingPhrase: string; currentLoadingPhrase: string | undefined;
historyRemountKey: number; historyRemountKey: number;
activeHooks: ActiveHook[]; activeHooks: ActiveHook[];
messageQueue: string[]; messageQueue: string[];
@@ -76,9 +76,7 @@ describe('useLoadingIndicator', () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
const { result } = renderLoadingIndicatorHook(StreamingState.Idle); const { result } = renderLoadingIndicatorHook(StreamingState.Idle);
expect(result.current.elapsedTime).toBe(0); expect(result.current.elapsedTime).toBe(0);
expect(WITTY_LOADING_PHRASES).toContain( expect(result.current.currentLoadingPhrase).toBeUndefined();
result.current.currentLoadingPhrase,
);
}); });
it('should show interactive shell waiting phrase when shouldShowFocusHint is true', async () => { 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(result.current.elapsedTime).toBe(0);
expect(WITTY_LOADING_PHRASES).toContain( expect(result.current.currentLoadingPhrase).toBeUndefined();
result.current.currentLoadingPhrase,
);
// Timer should not advance // Timer should not advance
await act(async () => { await act(async () => {
@@ -45,12 +45,12 @@ describe('usePhraseCycler', () => {
vi.restoreAllMocks(); 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 vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
const { lastFrame } = render( const { lastFrame } = render(
<TestComponent isActive={false} isWaiting={false} />, <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 () => { 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 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( rerender(
<TestComponent <TestComponent
isActive={false} isActive={false}
@@ -206,8 +206,8 @@ describe('usePhraseCycler', () => {
await act(async () => { await act(async () => {
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
}); });
// The phrase should be the first phrase after reset // The phrase should be empty after reset
expect(customPhrases).toContain(lastFrame()); expect(lastFrame()).toBe('');
// Activate again -> this will show a tip on first activation, then cycle from where mock is // Activate again -> this will show a tip on first activation, then cycle from where mock is
rerender( rerender(
+4 -4
View File
@@ -31,9 +31,9 @@ export const usePhraseCycler = (
? customPhrases ? customPhrases
: WITTY_LOADING_PHRASES; : WITTY_LOADING_PHRASES;
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState( const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState<
loadingPhrases[0], string | undefined
); >(isActive ? loadingPhrases[0] : undefined);
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null); const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
const hasShownFirstRequestTipRef = useRef(false); const hasShownFirstRequestTipRef = useRef(false);
@@ -56,7 +56,7 @@ export const usePhraseCycler = (
} }
if (!isActive) { if (!isActive) {
setCurrentLoadingPhrase(loadingPhrases[0]); setCurrentLoadingPhrase(undefined);
return; return;
} }
+13 -2
View File
@@ -15,6 +15,7 @@ import {
} from './color-utils.js'; } from './color-utils.js';
import type { CustomTheme } from '@google/gemini-cli-core'; import type { CustomTheme } from '@google/gemini-cli-core';
import { DEFAULT_BORDER_OPACITY } from '../constants.js';
export type { CustomTheme }; export type { CustomTheme };
@@ -136,7 +137,11 @@ export class Theme {
}, },
}, },
border: { border: {
default: this.colors.Gray, default: interpolateColor(
this.colors.Background,
this.colors.Gray,
DEFAULT_BORDER_OPACITY,
),
focused: this.colors.AccentBlue, focused: this.colors.AccentBlue,
}, },
ui: { ui: {
@@ -401,7 +406,13 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
}, },
}, },
border: { 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, focused: customTheme.border?.focused ?? colors.AccentBlue,
}, },
ui: { ui: {