From 7f066fd3a70165dbb8897c1497133fccfa67bd0e Mon Sep 17 00:00:00 2001 From: Yuna Seol Date: Thu, 29 Jan 2026 00:07:52 -0500 Subject: [PATCH] feat(cli): Display user identity (auth, email, tier) on startup (#17591) Co-authored-by: Keith Guerin Co-authored-by: Yuna Seol --- packages/cli/src/ui/AppContainer.test.tsx | 168 ++++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 50 +++++- .../src/ui/hooks/useHistoryManager.test.ts | 36 ++++ .../cli/src/ui/hooks/useHistoryManager.ts | 4 +- packages/core/src/config/config.test.ts | 31 ++++ 5 files changed, 287 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index baeace8f65..cfa1d658e8 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -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; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 8c4141a66a..42f64321c7 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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(''); diff --git a/packages/cli/src/ui/hooks/useHistoryManager.test.ts b/packages/cli/src/ui/hooks/useHistoryManager.test.ts index 79a708ec41..696f9d60c0 100644 --- a/packages/cli/src/ui/hooks/useHistoryManager.test.ts +++ b/packages/cli/src/ui/hooks/useHistoryManager.test.ts @@ -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'); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useHistoryManager.ts b/packages/cli/src/ui/hooks/useHistoryManager.ts index 3c7abaacc6..bbcf5c3794 100644 --- a/packages/cli/src/ui/hooks/useHistoryManager.ts +++ b/packages/cli/src/ui/hooks/useHistoryManager.ts @@ -36,10 +36,12 @@ export interface UseHistoryManagerReturn { */ export function useHistory({ chatRecordingService, + initialItems = [], }: { chatRecordingService?: ChatRecordingService | null; + initialItems?: HistoryItem[]; } = {}): UseHistoryManagerReturn { - const [history, setHistory] = useState([]); + const [history, setHistory] = useState(initialItems); const messageIdCounterRef = useRef(0); // Generates a unique message ID based on a timestamp and a counter. diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index b6308974fb..cd2e975751 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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);