ui: update & subdue footer colors and animate progress indicator

Update path, model, and thinking indicators to use primary theme colors instead of accents/gradients. Animate the progress indicator using interpolated Google brand colors, starting with purple for a calmer initial state.
This commit is contained in:
Keith Guerin
2026-02-07 23:04:45 -08:00
parent 31522045cd
commit 2df1f31af2
7 changed files with 97 additions and 62 deletions
+7 -4
View File
@@ -63,14 +63,17 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
Boolean(uiState.proQuotaRequest) || Boolean(uiState.proQuotaRequest) ||
Boolean(uiState.validationRequest) || Boolean(uiState.validationRequest) ||
Boolean(uiState.customDialog); Boolean(uiState.customDialog);
const isActivelyStreaming =
uiState.streamingState === StreamingState.Responding;
const showLoadingIndicator = const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
uiState.streamingState === StreamingState.Responding &&
!hasPendingActionRequired; !hasPendingActionRequired;
const showApprovalIndicator = !uiState.shellModeActive; const showApprovalIndicator = !uiState.shellModeActive;
const showRawMarkdownIndicator = !uiState.renderMarkdown; const showRawMarkdownIndicator = !uiState.renderMarkdown;
const showEscToCancelHint = const showEscToCancelHint =
showLoadingIndicator && isActivelyStreaming &&
!uiState.embeddedShellFocused &&
!hasPendingActionRequired &&
uiState.streamingState !== StreamingState.WaitingForConfirmation; uiState.streamingState !== StreamingState.WaitingForConfirmation;
return ( return (
@@ -158,7 +161,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
alignItems="center" alignItems="center"
flexGrow={1} flexGrow={1}
> >
{!showLoadingIndicator && ( {!isActivelyStreaming && (
<Box <Box
flexDirection={isNarrow ? 'column' : 'row'} flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'} alignItems={isNarrow ? 'flex-start' : 'center'}
@@ -204,7 +207,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexDirection="column" flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'} alignItems={isNarrow ? 'flex-start' : 'flex-end'}
> >
{!showLoadingIndicator && ( {!isActivelyStreaming && (
<StatusDisplay hideContextSummary={hideContextSummary} /> <StatusDisplay hideContextSummary={hideContextSummary} />
)} )}
</Box> </Box>
@@ -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>
); );
}; };
+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 { DebugProfiler } from './DebugProfiler.js'; import { DebugProfiler } from './DebugProfiler.js';
@@ -40,7 +39,6 @@ export const Footer: React.FC = () => {
errorCount, errorCount,
showErrorDetails, showErrorDetails,
promptTokenCount, promptTokenCount,
nightly,
isTrustedFolder, isTrustedFolder,
terminalWidth, terminalWidth,
} = { } = {
@@ -53,7 +51,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,
}; };
@@ -87,20 +84,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')}
@@ -146,9 +137,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>
); );
@@ -37,41 +37,43 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const { columns: terminalWidth } = useTerminalSize(); const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth); const isNarrow = isNarrowWidth(terminalWidth);
if (
streamingState === StreamingState.Idle &&
!currentLoadingPhrase &&
!thought
) {
return null;
}
// Prioritize the interactive shell waiting phrase over the thought subject // Prioritize the interactive shell waiting phrase over the thought subject
// because it conveys an actionable state for the user (waiting for input). // because it conveys an actionable state for the user (waiting for input).
const primaryText = const primaryText =
currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE
? currentLoadingPhrase ? currentLoadingPhrase
: thought?.subject || currentLoadingPhrase; : thought?.subject || currentLoadingPhrase || undefined;
const textColor =
streamingState === StreamingState.Idle
? theme.text.secondary
: theme.text.primary;
const italic = streamingState === StreamingState.Responding;
const cancelAndTimerContent = const cancelAndTimerContent =
showCancelAndTimer && showCancelAndTimer &&
streamingState !== StreamingState.WaitingForConfirmation streamingState !== StreamingState.WaitingForConfirmation &&
streamingState !== StreamingState.Idle
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})` ? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null; : null;
if (inline) { if (inline) {
return ( return (
<Box> <Box>
<Box marginRight={1}> {streamingState !== StreamingState.Idle && (
<GeminiRespondingSpinner <Box marginRight={1}>
nonRespondingDisplay={ <GeminiRespondingSpinner
streamingState === StreamingState.WaitingForConfirmation nonRespondingDisplay={
? '⠏' streamingState === StreamingState.WaitingForConfirmation
: '' ? ''
} : ''
/> }
</Box> />
</Box>
)}
{primaryText && ( {primaryText && (
<Text color={theme.text.accent} wrap="truncate-end"> <Text color={textColor} italic={italic} wrap="truncate-end">
{primaryText} {primaryText}
</Text> </Text>
)} )}
@@ -94,17 +96,19 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
alignItems={isNarrow ? 'flex-start' : 'center'} alignItems={isNarrow ? 'flex-start' : 'center'}
> >
<Box> <Box>
<Box marginRight={1}> {streamingState !== StreamingState.Idle && (
<GeminiRespondingSpinner <Box marginRight={1}>
nonRespondingDisplay={ <GeminiRespondingSpinner
streamingState === StreamingState.WaitingForConfirmation nonRespondingDisplay={
? '⠏' streamingState === StreamingState.WaitingForConfirmation
: '' ? ''
} : ''
/> }
</Box> />
</Box>
)}
{primaryText && ( {primaryText && (
<Text color={theme.text.accent} wrap="truncate-end"> <Text color={textColor} italic={italic} wrap="truncate-end">
{primaryText} {primaryText}
</Text> </Text>
)} )}
@@ -110,7 +110,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[];
+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;
} }