feat(cli): Display user identity (auth, email, tier) on startup (#17591)

Co-authored-by: Keith Guerin <keithguerin@gmail.com>
Co-authored-by: Yuna Seol <yunaseol@google.com>
This commit is contained in:
Yuna Seol
2026-01-29 00:07:52 -05:00
committed by GitHub
parent 6d36219e55
commit 7f066fd3a7
5 changed files with 287 additions and 2 deletions

View File

@@ -21,6 +21,7 @@ import { act, useContext, type ReactElement } from 'react';
import { AppContainer } from './AppContainer.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import { type TrackedToolCall } from './hooks/useReactToolScheduler.js';
import { MessageType } from './types.js';
import {
type Config,
makeFakeConfig,
@@ -28,6 +29,8 @@ import {
type UserFeedbackPayload,
type ResumedSessionData,
AuthType,
UserAccountManager,
type ContentGeneratorConfig,
type AgentDefinition,
MessageBusType,
QuestionType,
@@ -50,6 +53,11 @@ const mockIdeClient = vi.hoisted(() => ({
getInstance: vi.fn().mockReturnValue(new Promise(() => {})),
}));
// Mock UserAccountManager
const mockUserAccountManager = vi.hoisted(() => ({
getCachedGoogleAccount: vi.fn().mockReturnValue(null),
}));
// Mock stdout
const mocks = vi.hoisted(() => ({
mockStdout: { write: vi.fn() },
@@ -79,6 +87,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
})),
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
UserAccountManager: vi
.fn()
.mockImplementation(() => mockUserAccountManager),
FileDiscoveryService: vi.fn().mockImplementation(() => ({
initialize: vi.fn(),
})),
@@ -405,6 +416,7 @@ describe('AppContainer State Management', () => {
...defaultMergedSettings.ui,
showStatusInTitle: false,
hideWindowTitle: false,
showUserIdentity: true,
},
useAlternateBuffer: false,
},
@@ -476,6 +488,162 @@ describe('AppContainer State Management', () => {
});
});
describe('Authentication Check', () => {
it('displays correct message for LOGIN_WITH_GOOGLE auth type', async () => {
// Explicitly mock implementation to ensure we control the instance
(UserAccountManager as unknown as Mock).mockImplementation(
() => mockUserAccountManager,
);
mockUserAccountManager.getCachedGoogleAccount.mockReturnValue(
'test@example.com',
);
const mockAddItem = vi.fn();
mockedUseHistory.mockReturnValue({
history: [],
addItem: mockAddItem,
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
});
// Explicitly enable showUserIdentity
mockSettings.merged.ui = {
...mockSettings.merged.ui,
showUserIdentity: true,
};
// Need to ensure config.getContentGeneratorConfig() returns appropriate authType
const authConfig = makeFakeConfig();
// Mock getTargetDir as well since makeFakeConfig might not set it up fully for the component
vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace');
vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined);
vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue(
mockExtensionManager,
);
vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
} as unknown as ContentGeneratorConfig);
vi.spyOn(authConfig, 'getUserTierName').mockReturnValue('Standard Tier');
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ config: authConfig });
unmount = result.unmount;
});
await waitFor(() => {
expect(UserAccountManager).toHaveBeenCalled();
expect(
mockUserAccountManager.getCachedGoogleAccount,
).toHaveBeenCalled();
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
text: 'Logged in with Google: test@example.com (Plan: Standard Tier)',
}),
);
});
await act(async () => {
unmount!();
});
});
it('displays correct message for USE_GEMINI auth type', async () => {
// Explicitly mock implementation to ensure we control the instance
(UserAccountManager as unknown as Mock).mockImplementation(
() => mockUserAccountManager,
);
mockUserAccountManager.getCachedGoogleAccount.mockReturnValue(null);
const mockAddItem = vi.fn();
mockedUseHistory.mockReturnValue({
history: [],
addItem: mockAddItem,
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
});
const authConfig = makeFakeConfig();
vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace');
vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined);
vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue(
mockExtensionManager,
);
vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.USE_GEMINI,
} as unknown as ContentGeneratorConfig);
vi.spyOn(authConfig, 'getUserTierName').mockReturnValue('Standard Tier');
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ config: authConfig });
unmount = result.unmount;
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('Authenticated with gemini-api-key'),
}),
);
});
await act(async () => {
unmount!();
});
});
it('does not display authentication message if showUserIdentity is false', async () => {
mockUserAccountManager.getCachedGoogleAccount.mockReturnValue(
'test@example.com',
);
const mockAddItem = vi.fn();
mockedUseHistory.mockReturnValue({
history: [],
addItem: mockAddItem,
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
});
mockSettings.merged.ui = {
...mockSettings.merged.ui,
showUserIdentity: false,
};
const authConfig = makeFakeConfig();
vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace');
vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined);
vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue(
mockExtensionManager,
);
vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
} as unknown as ContentGeneratorConfig);
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ config: authConfig });
unmount = result.unmount;
});
// Give it some time to potentially call addItem
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
}),
);
await act(async () => {
unmount!();
});
});
});
describe('Context Providers', () => {
it('provides AppContext with correct values', async () => {
let unmount: () => void;

View File

@@ -47,6 +47,7 @@ import {
getErrorMessage,
getAllGeminiMdFilenames,
AuthType,
UserAccountManager,
clearCachedCredentialFile,
type ResumedSessionData,
recordExitFail,
@@ -186,11 +187,58 @@ const SHELL_HEIGHT_PADDING = 10;
export const AppContainer = (props: AppContainerProps) => {
const { config, initializationResult, resumedSessionData } = props;
const settings = useSettings();
const historyManager = useHistory({
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
});
const { addItem } = historyManager;
const authCheckPerformed = useRef(false);
useEffect(() => {
if (authCheckPerformed.current) return;
authCheckPerformed.current = true;
if (resumedSessionData || settings.merged.ui.showUserIdentity === false) {
return;
}
const authType = config.getContentGeneratorConfig()?.authType;
// Run this asynchronously to avoid blocking the event loop.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
try {
const userAccountManager = new UserAccountManager();
const email = userAccountManager.getCachedGoogleAccount();
const tierName = config.getUserTierName();
if (authType) {
let message =
authType === AuthType.LOGIN_WITH_GOOGLE
? email
? `Logged in with Google: ${email}`
: 'Logged in with Google'
: `Authenticated with ${authType}`;
if (tierName) {
message += ` (Plan: ${tierName})`;
}
addItem({
type: MessageType.INFO,
text: message,
});
}
} catch (_e) {
// Ignore errors during initial auth check
}
})();
}, [
config,
resumedSessionData,
settings.merged.ui.showUserIdentity,
addItem,
]);
useMemoryMonitor(historyManager);
const settings = useSettings();
const isAlternateBuffer = useAlternateBuffer();
const [corgiMode, setCorgiMode] = useState(false);
const [debugMessage, setDebugMessage] = useState<string>('');

View File

@@ -219,4 +219,40 @@ describe('useHistoryManager', () => {
expect(result.current.history[0].id).toBeGreaterThanOrEqual(before + 1);
expect(result.current.history[0].id).toBeLessThanOrEqual(after + 1);
});
describe('initialItems with auth information', () => {
it('should initialize with auth information', () => {
const email = 'user@example.com';
const tier = 'Pro';
const authMessage = `Authenticated as: ${email} (Plan: ${tier})`;
const initialItems: HistoryItem[] = [
{
id: 1,
type: 'info',
text: authMessage,
},
];
const { result } = renderHook(() => useHistory({ initialItems }));
expect(result.current.history).toHaveLength(1);
expect(result.current.history[0].text).toBe(authMessage);
});
it('should add items with auth information via addItem', () => {
const { result } = renderHook(() => useHistory());
const email = 'user@example.com';
const tier = 'Pro';
const authMessage = `Authenticated as: ${email} (Plan: ${tier})`;
act(() => {
result.current.addItem({
type: 'info',
text: authMessage,
});
});
expect(result.current.history).toHaveLength(1);
expect(result.current.history[0].text).toBe(authMessage);
expect(result.current.history[0].type).toBe('info');
});
});
});

View File

@@ -36,10 +36,12 @@ export interface UseHistoryManagerReturn {
*/
export function useHistory({
chatRecordingService,
initialItems = [],
}: {
chatRecordingService?: ChatRecordingService | null;
initialItems?: HistoryItem[];
} = {}): UseHistoryManagerReturn {
const [history, setHistory] = useState<HistoryItem[]>([]);
const [history, setHistory] = useState<HistoryItem[]>(initialItems);
const messageIdCounterRef = useRef(0);
// Generates a unique message ID based on a timestamp and a counter.

View File

@@ -24,6 +24,7 @@ import {
import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
import {
AuthType,
createContentGenerator,
createContentGeneratorConfig,
} from '../core/contentGenerator.js';
import { GeminiClient } from '../core/client.js';
@@ -197,6 +198,7 @@ import { getCodeAssistServer } from '../code_assist/codeAssist.js';
import { getExperiments } from '../code_assist/experiments/experiments.js';
import type { CodeAssistServer } from '../code_assist/server.js';
import { ContextManager } from '../services/contextManager.js';
import { UserTierId } from 'src/code_assist/types.js';
vi.mock('../core/baseLlmClient.js');
vi.mock('../core/tokenLimits.js', () => ({
@@ -2061,6 +2063,35 @@ describe('Config Quota & Preview Model Access', () => {
});
});
describe('getUserTier and getUserTierName', () => {
it('should return undefined if contentGenerator is not initialized', () => {
const config = new Config(baseParams);
expect(config.getUserTier()).toBeUndefined();
expect(config.getUserTierName()).toBeUndefined();
});
it('should return values from contentGenerator after refreshAuth', async () => {
const config = new Config(baseParams);
const mockTier = UserTierId.STANDARD;
const mockTierName = 'Standard Tier';
vi.mocked(createContentGeneratorConfig).mockResolvedValue({
authType: AuthType.USE_GEMINI,
} as ContentGeneratorConfig);
vi.mocked(createContentGenerator).mockResolvedValue({
userTier: mockTier,
userTierName: mockTierName,
} as unknown as CodeAssistServer);
await config.refreshAuth(AuthType.USE_GEMINI);
expect(config.getUserTier()).toBe(mockTier);
// TODO(#1275): User tier name is disabled until re-enabled.
expect(config.getUserTierName()).toBeUndefined();
});
});
describe('setPreviewFeatures', () => {
it('should reset model to default auto if disabling preview features while using a preview model', () => {
config.setPreviewFeatures(true);