summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui')
-rw-r--r--packages/cli/src/ui/App.test.tsx19
-rw-r--r--packages/cli/src/ui/App.tsx42
-rw-r--r--packages/cli/src/ui/components/AuthDialog.test.tsx41
-rw-r--r--packages/cli/src/ui/components/AuthDialog.tsx94
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.test.ts3
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.ts9
-rw-r--r--packages/cli/src/ui/hooks/useAuthCommand.ts57
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.test.tsx1
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts7
9 files changed, 266 insertions, 7 deletions
diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx
index 0ebaa34d..dca24b5c 100644
--- a/packages/cli/src/ui/App.test.tsx
+++ b/packages/cli/src/ui/App.test.tsx
@@ -145,6 +145,15 @@ vi.mock('./hooks/useGeminiStream', () => ({
})),
}));
+vi.mock('./hooks/useAuthCommand', () => ({
+ useAuthCommand: vi.fn(() => ({
+ isAuthDialogOpen: false,
+ openAuthDialog: vi.fn(),
+ handleAuthSelect: vi.fn(),
+ handleAuthHighlight: vi.fn(),
+ })),
+}));
+
vi.mock('./hooks/useLogger', () => ({
useLogger: vi.fn(() => ({
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
@@ -176,7 +185,9 @@ describe('App UI', () => {
};
const workspaceSettingsFile: SettingsFile = {
path: '/workspace/.gemini/settings.json',
- settings,
+ settings: {
+ ...settings,
+ },
};
return new LoadedSettings(userSettingsFile, workspaceSettingsFile, []);
};
@@ -184,10 +195,6 @@ describe('App UI', () => {
beforeEach(() => {
const ServerConfigMocked = vi.mocked(ServerConfig, true);
mockConfig = new ServerConfigMocked({
- contentGeneratorConfig: {
- apiKey: 'test-key',
- model: 'test-model',
- },
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: '/test/dir',
@@ -197,7 +204,7 @@ describe('App UI', () => {
showMemoryUsage: false,
sessionId: 'test-session-id',
cwd: '/tmp',
- // Provide other required fields for ConfigParameters if necessary
+ model: 'model',
}) as unknown as MockServerConfig;
// Ensure the getShowMemoryUsage mock function is specifically set up if not covered by constructor mock
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index a9c5f0e7..c481ebd3 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -20,6 +20,7 @@ import { useTerminalSize } from './hooks/useTerminalSize.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
+import { useAuthCommand } from './hooks/useAuthCommand.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
@@ -31,6 +32,7 @@ import { ShellModeIndicator } from './components/ShellModeIndicator.js';
import { InputPrompt } from './components/InputPrompt.js';
import { Footer } from './components/Footer.js';
import { ThemeDialog } from './components/ThemeDialog.js';
+import { AuthDialog } from './components/AuthDialog.js';
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
import { Colors } from './colors.js';
import { Help } from './components/Help.js';
@@ -51,6 +53,7 @@ import {
isEditorAvailable,
EditorType,
} from '@gemini-cli/core';
+import { validateAuthMethod } from '../config/auth.js';
import { useLogger } from './hooks/useLogger.js';
import { StreamingContext } from './contexts/StreamingContext.js';
import {
@@ -101,6 +104,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
const [debugMessage, setDebugMessage] = useState<string>('');
const [showHelp, setShowHelp] = useState<boolean>(false);
const [themeError, setThemeError] = useState<string | null>(null);
+ const [authError, setAuthError] = useState<string | null>(null);
const [editorError, setEditorError] = useState<string | null>(null);
const [footerHeight, setFooterHeight] = useState<number>(0);
const [corgiMode, setCorgiMode] = useState(false);
@@ -130,6 +134,23 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
} = useThemeCommand(settings, setThemeError, addItem);
const {
+ isAuthDialogOpen,
+ openAuthDialog,
+ handleAuthSelect,
+ handleAuthHighlight,
+ } = useAuthCommand(settings, setAuthError, config);
+
+ useEffect(() => {
+ if (settings.merged.selectedAuthType) {
+ const error = validateAuthMethod(settings.merged.selectedAuthType);
+ if (error) {
+ setAuthError(error);
+ openAuthDialog();
+ }
+ }
+ }, [settings.merged.selectedAuthType, openAuthDialog, setAuthError]);
+
+ const {
isEditorDialogOpen,
openEditorDialog,
handleEditorSelect,
@@ -197,6 +218,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
setShowHelp,
setDebugMessage,
openThemeDialog,
+ openAuthDialog,
openEditorDialog,
performMemoryRefresh,
toggleCorgiMode,
@@ -306,6 +328,11 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
return editorType as EditorType;
}, [settings, openEditorDialog]);
+ const onAuthError = useCallback(() => {
+ setAuthError('reauth required');
+ openAuthDialog();
+ }, [openAuthDialog, setAuthError]);
+
const {
streamingState,
submitQuery,
@@ -322,6 +349,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
handleSlashCommand,
shellModeActive,
getPreferredEditor,
+ onAuthError,
);
pendingHistoryItems.push(...pendingGeminiHistoryItems);
const { elapsedTime, currentLoadingPhrase } =
@@ -557,6 +585,20 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
terminalWidth={mainAreaWidth}
/>
</Box>
+ ) : isAuthDialogOpen ? (
+ <Box flexDirection="column">
+ {authError && (
+ <Box marginBottom={1}>
+ <Text color={Colors.AccentRed}>{authError}</Text>
+ </Box>
+ )}
+ <AuthDialog
+ onSelect={handleAuthSelect}
+ onHighlight={handleAuthHighlight}
+ settings={settings}
+ initialErrorMessage={authError}
+ />
+ </Box>
) : isEditorDialogOpen ? (
<Box flexDirection="column">
{editorError && (
diff --git a/packages/cli/src/ui/components/AuthDialog.test.tsx b/packages/cli/src/ui/components/AuthDialog.test.tsx
new file mode 100644
index 00000000..a5f46d93
--- /dev/null
+++ b/packages/cli/src/ui/components/AuthDialog.test.tsx
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from 'ink-testing-library';
+import { AuthDialog } from './AuthDialog.js';
+import { LoadedSettings } from '../../config/settings.js';
+import { AuthType } from '@gemini-cli/core';
+
+describe('AuthDialog', () => {
+ it('should show an error if the initial auth type is invalid', () => {
+ const settings: LoadedSettings = new LoadedSettings(
+ {
+ settings: {
+ selectedAuthType: AuthType.USE_GEMINI,
+ },
+ path: '',
+ },
+ {
+ settings: {},
+ path: '',
+ },
+ [],
+ );
+
+ const { lastFrame } = render(
+ <AuthDialog
+ onSelect={() => {}}
+ onHighlight={() => {}}
+ settings={settings}
+ initialErrorMessage="GEMINI_API_KEY environment variable not found"
+ />,
+ );
+
+ expect(lastFrame()).toContain(
+ 'GEMINI_API_KEY environment variable not found',
+ );
+ });
+});
diff --git a/packages/cli/src/ui/components/AuthDialog.tsx b/packages/cli/src/ui/components/AuthDialog.tsx
new file mode 100644
index 00000000..b16529cf
--- /dev/null
+++ b/packages/cli/src/ui/components/AuthDialog.tsx
@@ -0,0 +1,94 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import { Colors } from '../colors.js';
+import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import { LoadedSettings, SettingScope } from '../../config/settings.js';
+import { AuthType } from '@gemini-cli/core';
+import { validateAuthMethod } from '../../config/auth.js';
+
+interface AuthDialogProps {
+ onSelect: (authMethod: string | undefined, scope: SettingScope) => void;
+ onHighlight: (authMethod: string | undefined) => void;
+ settings: LoadedSettings;
+ initialErrorMessage?: string | null;
+}
+
+export function AuthDialog({
+ onSelect,
+ onHighlight,
+ settings,
+ initialErrorMessage,
+}: AuthDialogProps): React.JSX.Element {
+ const [errorMessage, setErrorMessage] = useState<string | null>(
+ initialErrorMessage || null,
+ );
+ const authItems = [
+ {
+ label: 'Login with Google Personal Account',
+ value: AuthType.LOGIN_WITH_GOOGLE_PERSONAL,
+ },
+ { label: 'Gemini API Key', value: AuthType.USE_GEMINI },
+ {
+ label: 'Login with GCP Project and Google Work Account',
+ value: AuthType.LOGIN_WITH_GOOGLE_ENTERPRISE,
+ },
+ { label: 'Vertex AI', value: AuthType.USE_VERTEX_AI },
+ ];
+
+ let initialAuthIndex = authItems.findIndex(
+ (item) => item.value === settings.merged.selectedAuthType,
+ );
+
+ if (initialAuthIndex === -1) {
+ initialAuthIndex = 0;
+ }
+
+ const handleAuthSelect = (authMethod: string) => {
+ const error = validateAuthMethod(authMethod);
+ if (error) {
+ setErrorMessage(error);
+ } else {
+ setErrorMessage(null);
+ onSelect(authMethod, SettingScope.User);
+ }
+ };
+
+ useInput((_input, key) => {
+ if (key.escape) {
+ onSelect(undefined, SettingScope.User);
+ }
+ });
+
+ return (
+ <Box
+ borderStyle="round"
+ borderColor={Colors.Gray}
+ flexDirection="column"
+ padding={1}
+ width="100%"
+ >
+ <Text bold>Select Auth Method</Text>
+ <RadioButtonSelect
+ items={authItems}
+ initialIndex={initialAuthIndex}
+ onSelect={handleAuthSelect}
+ onHighlight={onHighlight}
+ isFocused={true}
+ />
+ {errorMessage && (
+ <Box marginTop={1}>
+ <Text color={Colors.AccentRed}>{errorMessage}</Text>
+ </Box>
+ )}
+ <Box marginTop={1}>
+ <Text color={Colors.Gray}>(Use Enter to select)</Text>
+ </Box>
+ </Box>
+ );
+}
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
index 7c750af1..04931c7f 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
@@ -103,6 +103,7 @@ describe('useSlashCommandProcessor', () => {
let mockSetShowHelp: ReturnType<typeof vi.fn>;
let mockOnDebugMessage: ReturnType<typeof vi.fn>;
let mockOpenThemeDialog: ReturnType<typeof vi.fn>;
+ let mockOpenAuthDialog: ReturnType<typeof vi.fn>;
let mockOpenEditorDialog: ReturnType<typeof vi.fn>;
let mockPerformMemoryRefresh: ReturnType<typeof vi.fn>;
let mockSetQuittingMessages: ReturnType<typeof vi.fn>;
@@ -120,6 +121,7 @@ describe('useSlashCommandProcessor', () => {
mockSetShowHelp = vi.fn();
mockOnDebugMessage = vi.fn();
mockOpenThemeDialog = vi.fn();
+ mockOpenAuthDialog = vi.fn();
mockOpenEditorDialog = vi.fn();
mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined);
mockSetQuittingMessages = vi.fn();
@@ -171,6 +173,7 @@ describe('useSlashCommandProcessor', () => {
mockSetShowHelp,
mockOnDebugMessage,
mockOpenThemeDialog,
+ mockOpenAuthDialog,
mockOpenEditorDialog,
mockPerformMemoryRefresh,
mockCorgiMode,
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index 0e622f23..ee7b55cb 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -68,6 +68,7 @@ export const useSlashCommandProcessor = (
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
onDebugMessage: (message: string) => void,
openThemeDialog: () => void,
+ openAuthDialog: () => void,
openEditorDialog: () => void,
performMemoryRefresh: () => Promise<void>,
toggleCorgiMode: () => void,
@@ -198,6 +199,13 @@ export const useSlashCommandProcessor = (
},
},
{
+ name: 'auth',
+ description: 'change the auth method',
+ action: (_mainCommand, _subCommand, _args) => {
+ openAuthDialog();
+ },
+ },
+ {
name: 'editor',
description: 'set external editor preference',
action: (_mainCommand, _subCommand, _args) => {
@@ -907,6 +915,7 @@ Add any other context about the problem here.
setShowHelp,
refreshStatic,
openThemeDialog,
+ openAuthDialog,
openEditorDialog,
clearItems,
performMemoryRefresh,
diff --git a/packages/cli/src/ui/hooks/useAuthCommand.ts b/packages/cli/src/ui/hooks/useAuthCommand.ts
new file mode 100644
index 00000000..a9b1cb1e
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useAuthCommand.ts
@@ -0,0 +1,57 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useCallback, useEffect } from 'react';
+import { LoadedSettings, SettingScope } from '../../config/settings.js';
+import { AuthType, Config, clearCachedCredentialFile } from '@gemini-cli/core';
+
+async function performAuthFlow(authMethod: AuthType, config: Config) {
+ await config.refreshAuth(authMethod);
+ console.log(`Authenticated via "${authMethod}".`);
+}
+
+export const useAuthCommand = (
+ settings: LoadedSettings,
+ setAuthError: (error: string | null) => void,
+ config: Config,
+) => {
+ const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(
+ settings.merged.selectedAuthType === undefined,
+ );
+
+ useEffect(() => {
+ if (!isAuthDialogOpen) {
+ performAuthFlow(settings.merged.selectedAuthType as AuthType, config);
+ }
+ }, [isAuthDialogOpen, settings, config]);
+
+ const openAuthDialog = useCallback(() => {
+ setIsAuthDialogOpen(true);
+ }, []);
+
+ const handleAuthSelect = useCallback(
+ async (authMethod: string | undefined, scope: SettingScope) => {
+ if (authMethod) {
+ await clearCachedCredentialFile();
+ settings.setValue(scope, 'selectedAuthType', authMethod);
+ }
+ setIsAuthDialogOpen(false);
+ setAuthError(null);
+ },
+ [settings, setAuthError],
+ );
+
+ const handleAuthHighlight = useCallback((_authMethod: string | undefined) => {
+ // For now, we don't do anything on highlight.
+ }, []);
+
+ return {
+ isAuthDialogOpen,
+ openAuthDialog,
+ handleAuthSelect,
+ handleAuthHighlight,
+ };
+};
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index 96dd6aef..36f420e4 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -359,6 +359,7 @@ describe('useGeminiStream', () => {
props.handleSlashCommand,
props.shellModeActive,
() => 'vscode' as EditorType,
+ () => {},
),
{
initialProps: {
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 6d92af0d..4049c884 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -22,6 +22,7 @@ import {
GitService,
EditorType,
ThoughtSummary,
+ isAuthError,
} from '@gemini-cli/core';
import { type Part, type PartListUnion } from '@google/genai';
import {
@@ -87,6 +88,7 @@ export const useGeminiStream = (
>,
shellModeActive: boolean,
getPreferredEditor: () => EditorType | undefined,
+ onAuthError: () => void,
) => {
const [initError, setInitError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
@@ -496,7 +498,9 @@ export const useGeminiStream = (
setPendingHistoryItem(null);
}
} catch (error: unknown) {
- if (!isNodeError(error) || error.name !== 'AbortError') {
+ if (isAuthError(error)) {
+ onAuthError();
+ } else if (!isNodeError(error) || error.name !== 'AbortError') {
addItem(
{
type: MessageType.ERROR,
@@ -522,6 +526,7 @@ export const useGeminiStream = (
setInitError,
geminiClient,
startNewTurn,
+ onAuthError,
],
);