feat: add Gemini CLI provider integration (#647)

* feat: add Gemini CLI provider for AI model execution

- Add GeminiProvider class extending CliProvider for Gemini CLI integration
- Add Gemini models (Gemini 3 Pro/Flash Preview, 2.5 Pro/Flash/Flash-Lite)
- Add gemini-models.ts with model definitions and types
- Update ModelProvider type to include 'gemini'
- Add isGeminiModel() to provider-utils.ts for model detection
- Register Gemini provider in provider-factory with priority 4
- Add Gemini setup detection routes (status, auth, deauth)
- Add GeminiCliStatus to setup store for UI state management
- Add Gemini to PROVIDER_ICON_COMPONENTS for UI icon display
- Add GEMINI_MODELS to model-display for dropdown population
- Support thinking levels: off, low, medium, high

Based on https://github.com/google-gemini/gemini-cli

* chore: update package-lock.json

* feat(ui): add Gemini provider to settings and setup wizard

- Add GeminiCliStatus component for CLI detection display
- Add GeminiSettingsTab component for global settings
- Update provider-tabs.tsx to include Gemini as 5th tab
- Update providers-setup-step.tsx with Gemini provider detection
- Add useGeminiCliStatus hook for querying CLI status
- Add getGeminiStatus, authGemini, deauthGemini to HTTP API client
- Add gemini query key for React Query
- Fix GeminiModelId type to not double-prefix model IDs

* feat(ui): add Gemini to settings sidebar navigation

- Add 'gemini-provider' to SettingsViewId type
- Add GeminiIcon and gemini-provider to navigation config
- Add gemini-provider to NAV_ID_TO_PROVIDER mapping
- Add gemini-provider case in settings-view switch
- Export GeminiSettingsTab from providers index

This fixes the missing Gemini entry in the AI Providers sidebar menu.

* feat(ui): add Gemini model configuration in settings

- Create GeminiModelConfiguration component for model selection
- Add enabledGeminiModels and geminiDefaultModel state to app-store
- Add setEnabledGeminiModels, setGeminiDefaultModel, toggleGeminiModel actions
- Update GeminiSettingsTab to show model configuration when CLI is installed
- Import GeminiModelId and getAllGeminiModelIds from types

This adds the ability to configure which Gemini models are available
in the feature modal, similar to other providers like Codex and OpenCode.

* feat(ui): add Gemini models to all model dropdowns

- Add GEMINI_MODELS to model-constants.ts for UI dropdowns
- Add Gemini to ALL_MODELS array used throughout the app
- Add GeminiIcon to PROFILE_ICONS mapping
- Fix GEMINI_MODELS in model-display.ts to use correct model IDs
- Update getModelDisplayName to handle Gemini models correctly

Gemini models now appear in all model selection dropdowns including
Model Defaults, Feature Defaults, and feature card settings.

* fix(gemini): fix CLI integration and event handling

- Fix model ID prefix handling: strip gemini- prefix in agent-service,
  add it back in buildCliArgs for CLI invocation
- Fix event normalization to match actual Gemini CLI output format:
  - type: 'init' (not 'system')
  - type: 'message' with role (not 'assistant')
  - tool_name/tool_id/parameters/output field names
- Add --sandbox false and --approval-mode yolo for faster execution
- Remove thinking level selector from UI (Gemini CLI doesn't support it)
- Update auth status to show errors properly

* test: update provider-factory tests for Gemini provider

- Add GeminiProvider import and spy mock
- Update expected provider count from 4 to 5
- Add test for GeminiProvider inclusion
- Add gemini key to checkAllProviders test

* fix(gemini): address PR review feedback

- Fix npm package name from @anthropic-ai/gemini-cli to @google/gemini-cli
- Fix comments in gemini-provider.ts to match actual CLI output format
- Convert sync fs operations to async using fs/promises

* fix(settings): add Gemini and Codex settings to sync

Add enabledGeminiModels, geminiDefaultModel, enabledCodexModels, and
codexDefaultModel to SETTINGS_FIELDS_TO_SYNC for persistence across sessions.

* fix(gemini): address additional PR review feedback

- Use 'Speed' badge for non-thinking Gemini models (consistency)
- Fix installCommand mapping in gemini-settings-tab.tsx
- Add hasEnvApiKey to GeminiCliStatus interface for API parity
- Clarify GeminiThinkingLevel comment (CLI doesn't support --thinking-level)

* fix(settings): restore Codex and Gemini settings from server

Add sanitization and restoration logic for enabledCodexModels,
codexDefaultModel, enabledGeminiModels, and geminiDefaultModel
in refreshSettingsFromServer() to match the fields in SETTINGS_FIELDS_TO_SYNC.

* feat(gemini): normalize tool names and fix workspace restrictions

- Add tool name mapping to normalize Gemini CLI tool names to standard
  names (e.g., write_todos -> TodoWrite, read_file -> Read)
- Add normalizeGeminiToolInput to convert write_todos format to TodoWrite
  format (description -> content, handle cancelled status)
- Pass --include-directories with cwd to fix workspace restriction errors
  when Gemini CLI has a different cached workspace from previous sessions

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Stefan de Vogelaere
2026-01-23 01:42:17 +01:00
committed by GitHub
parent 7773db559d
commit f480386905
33 changed files with 2408 additions and 27 deletions

View File

@@ -31,7 +31,13 @@ import {
import { Spinner } from '@/components/ui/spinner';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
GeminiIcon,
} from '@/components/ui/provider-icon';
import { TerminalOutput } from '../components';
import { useCliInstallation, useTokenSave } from '../hooks';
@@ -40,7 +46,7 @@ interface ProvidersSetupStepProps {
onBack: () => void;
}
type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode';
type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini';
// ============================================================================
// Claude Content
@@ -1209,6 +1215,318 @@ function OpencodeContent() {
);
}
// ============================================================================
// Gemini Content
// ============================================================================
function GeminiContent() {
const { geminiCliStatus, setGeminiCliStatus } = useSetupStore();
const { setApiKeys, apiKeys } = useAppStore();
const [isChecking, setIsChecking] = useState(false);
const [apiKey, setApiKey] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isLoggingIn, setIsLoggingIn] = useState(false);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const checkStatus = useCallback(async () => {
setIsChecking(true);
try {
const api = getElectronAPI();
if (!api.setup?.getGeminiStatus) return;
const result = await api.setup.getGeminiStatus();
if (result.success) {
setGeminiCliStatus({
installed: result.installed ?? false,
version: result.version,
path: result.path,
auth: result.auth,
installCommand: result.installCommand,
loginCommand: result.loginCommand,
});
if (result.auth?.authenticated) {
toast.success('Gemini CLI is ready!');
}
}
} catch {
// Ignore
} finally {
setIsChecking(false);
}
}, [setGeminiCliStatus]);
useEffect(() => {
checkStatus();
return () => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
};
}, [checkStatus]);
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success('Command copied to clipboard');
};
const handleSaveApiKey = async () => {
if (!apiKey.trim()) return;
setIsSaving(true);
try {
const api = getElectronAPI();
if (!api.setup?.saveApiKey) {
toast.error('Save API not available');
return;
}
const result = await api.setup.saveApiKey('google', apiKey);
if (result.success) {
setApiKeys({ ...apiKeys, google: apiKey });
setGeminiCliStatus({
...geminiCliStatus,
installed: geminiCliStatus?.installed ?? false,
auth: { authenticated: true, method: 'api_key' },
});
toast.success('API key saved successfully!');
}
} catch {
toast.error('Failed to save API key');
} finally {
setIsSaving(false);
}
};
const handleLogin = async () => {
setIsLoggingIn(true);
try {
const loginCommand = geminiCliStatus?.loginCommand || 'gemini auth login';
await navigator.clipboard.writeText(loginCommand);
toast.info('Login command copied! Paste in terminal to authenticate.');
let attempts = 0;
pollIntervalRef.current = setInterval(async () => {
attempts++;
try {
const api = getElectronAPI();
if (!api.setup?.getGeminiStatus) return;
const result = await api.setup.getGeminiStatus();
if (result.auth?.authenticated) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setGeminiCliStatus({
...geminiCliStatus,
installed: result.installed ?? true,
version: result.version,
path: result.path,
auth: result.auth,
});
setIsLoggingIn(false);
toast.success('Successfully logged in to Gemini!');
}
} catch {
// Ignore
}
if (attempts >= 60) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setIsLoggingIn(false);
toast.error('Login timed out. Please try again.');
}
}, 2000);
} catch {
toast.error('Failed to start login process');
setIsLoggingIn(false);
}
};
const isReady = geminiCliStatus?.installed && geminiCliStatus?.auth?.authenticated;
return (
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<GeminiIcon className="w-5 h-5" />
Gemini CLI Status
</CardTitle>
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<CardDescription>
{geminiCliStatus?.installed
? geminiCliStatus.auth?.authenticated
? `Authenticated${geminiCliStatus.version ? ` (v${geminiCliStatus.version})` : ''}`
: 'Installed but not authenticated'
: 'Not installed on your system'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isReady && (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">CLI Installed</p>
<p className="text-sm text-muted-foreground">
{geminiCliStatus?.version && `Version: ${geminiCliStatus.version}`}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<p className="font-medium text-foreground">Authenticated</p>
</div>
</div>
)}
{!geminiCliStatus?.installed && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border">
<XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">Gemini CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install the Gemini CLI to use Google Gemini models.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<p className="font-medium text-foreground text-sm">Install Gemini CLI:</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto">
{geminiCliStatus?.installCommand || 'npm install -g @google/gemini-cli'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(
geminiCliStatus?.installCommand || 'npm install -g @google/gemini-cli'
)
}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
</div>
)}
{geminiCliStatus?.installed && !geminiCliStatus?.auth?.authenticated && !isChecking && (
<div className="space-y-4">
{/* Show CLI installed toast */}
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">CLI Installed</p>
<p className="text-sm text-muted-foreground">
{geminiCliStatus?.version && `Version: ${geminiCliStatus.version}`}
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">Gemini CLI not authenticated</p>
<p className="text-sm text-muted-foreground mt-1">
Run the login command or provide a Google API key below.
</p>
</div>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="cli" className="border-border">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<Terminal className="w-5 h-5 text-muted-foreground" />
<span className="font-medium">Google OAuth Login</span>
</div>
</AccordionTrigger>
<AccordionContent className="pt-4 space-y-4">
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
{geminiCliStatus?.loginCommand || 'gemini auth login'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(geminiCliStatus?.loginCommand || 'gemini auth login')
}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<Button
onClick={handleLogin}
disabled={isLoggingIn}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
Waiting for login...
</>
) : (
'Copy Command & Wait for Login'
)}
</Button>
</AccordionContent>
</AccordionItem>
<AccordionItem value="api-key" className="border-border">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<Key className="w-5 h-5 text-muted-foreground" />
<span className="font-medium">Google API Key</span>
</div>
</AccordionTrigger>
<AccordionContent className="pt-4 space-y-4">
<div className="space-y-2">
<Input
type="password"
placeholder="AIza..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="bg-input border-border text-foreground"
/>
<p className="text-xs text-muted-foreground">
<a
href="https://aistudio.google.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 hover:underline"
>
Get an API key from Google AI Studio
<ExternalLink className="w-3 h-3 inline ml-1" />
</a>
</p>
</div>
<Button
onClick={handleSaveApiKey}
disabled={isSaving || !apiKey.trim()}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
>
{isSaving ? <Spinner size="sm" /> : 'Save API Key'}
</Button>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)}
{isChecking && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Spinner size="md" />
<p className="font-medium text-foreground">Checking Gemini CLI status...</p>
</div>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// Main Component
// ============================================================================
@@ -1225,11 +1543,13 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
codexCliStatus,
codexAuthStatus,
opencodeCliStatus,
geminiCliStatus,
setClaudeCliStatus,
setCursorCliStatus,
setCodexCliStatus,
setCodexAuthStatus,
setOpencodeCliStatus,
setGeminiCliStatus,
} = useSetupStore();
// Check all providers on mount
@@ -1319,8 +1639,28 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
}
};
// Check Gemini
const checkGemini = async () => {
try {
if (!api.setup?.getGeminiStatus) return;
const result = await api.setup.getGeminiStatus();
if (result.success) {
setGeminiCliStatus({
installed: result.installed ?? false,
version: result.version,
path: result.path,
auth: result.auth,
installCommand: result.installCommand,
loginCommand: result.loginCommand,
});
}
} catch {
// Ignore errors
}
};
// Run all checks in parallel
await Promise.all([checkClaude(), checkCursor(), checkCodex(), checkOpencode()]);
await Promise.all([checkClaude(), checkCursor(), checkCodex(), checkOpencode(), checkGemini()]);
setIsInitialChecking(false);
}, [
setClaudeCliStatus,
@@ -1328,6 +1668,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
setCodexCliStatus,
setCodexAuthStatus,
setOpencodeCliStatus,
setGeminiCliStatus,
]);
useEffect(() => {
@@ -1354,11 +1695,15 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
const isOpencodeInstalled = opencodeCliStatus?.installed === true;
const isOpencodeAuthenticated = opencodeCliStatus?.auth?.authenticated === true;
const isGeminiInstalled = geminiCliStatus?.installed === true;
const isGeminiAuthenticated = geminiCliStatus?.auth?.authenticated === true;
const hasAtLeastOneProvider =
isClaudeAuthenticated ||
isCursorAuthenticated ||
isCodexAuthenticated ||
isOpencodeAuthenticated;
isOpencodeAuthenticated ||
isGeminiAuthenticated;
type ProviderStatus = 'not_installed' | 'installed_not_auth' | 'authenticated' | 'verifying';
@@ -1402,6 +1747,13 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
status: getProviderStatus(isOpencodeInstalled, isOpencodeAuthenticated),
color: 'text-green-500',
},
{
id: 'gemini' as const,
label: 'Gemini',
icon: GeminiIcon,
status: getProviderStatus(isGeminiInstalled, isGeminiAuthenticated),
color: 'text-blue-500',
},
];
const renderStatusIcon = (status: ProviderStatus) => {
@@ -1438,7 +1790,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
)}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ProviderTab)}>
<TabsList className="grid w-full grid-cols-4 h-auto p-1">
<TabsList className="grid w-full grid-cols-5 h-auto p-1">
{providers.map((provider) => {
const Icon = provider.icon;
return (
@@ -1484,6 +1836,9 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
<TabsContent value="opencode" className="mt-0">
<OpencodeContent />
</TabsContent>
<TabsContent value="gemini" className="mt-0">
<GeminiContent />
</TabsContent>
</div>
</Tabs>