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

@@ -395,6 +395,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
cursor: CursorIcon,
codex: OpenAIIcon,
opencode: OpenCodeIcon,
gemini: GeminiIcon,
};
/**

View File

@@ -4,9 +4,16 @@ import {
CURSOR_MODEL_MAP,
CODEX_MODEL_MAP,
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
GEMINI_MODEL_MAP,
} from '@automaker/types';
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
GeminiIcon,
} from '@/components/ui/provider-icon';
export type ModelOption = {
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
@@ -118,13 +125,29 @@ export const OPENCODE_MODELS: ModelOption[] = OPENCODE_MODEL_CONFIGS.map((config
}));
/**
* All available models (Claude + Cursor + Codex + OpenCode)
* Gemini models derived from GEMINI_MODEL_MAP
* Model IDs already have 'gemini-' prefix (like Cursor models)
*/
export const GEMINI_MODELS: ModelOption[] = Object.entries(GEMINI_MODEL_MAP).map(
([id, config]) => ({
id, // IDs already have gemini- prefix (e.g., 'gemini-2.5-flash')
label: config.label,
description: config.description,
badge: config.supportsThinking ? 'Thinking' : 'Speed',
provider: 'gemini' as ModelProvider,
hasThinking: config.supportsThinking,
})
);
/**
* All available models (Claude + Cursor + Codex + OpenCode + Gemini)
*/
export const ALL_MODELS: ModelOption[] = [
...CLAUDE_MODELS,
...CURSOR_MODELS,
...CODEX_MODELS,
...OPENCODE_MODELS,
...GEMINI_MODELS,
];
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
@@ -171,4 +194,5 @@ export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: str
Cursor: CursorIcon,
Codex: OpenAIIcon,
OpenCode: OpenCodeIcon,
Gemini: GeminiIcon,
};

View File

@@ -23,6 +23,7 @@ import {
CursorSettingsTab,
CodexSettingsTab,
OpencodeSettingsTab,
GeminiSettingsTab,
} from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
@@ -123,6 +124,8 @@ export function SettingsView() {
return <CodexSettingsTab />;
case 'opencode-provider':
return <OpencodeSettingsTab />;
case 'gemini-provider':
return <GeminiSettingsTab />;
case 'providers':
case 'claude': // Backwards compatibility - redirect to claude-provider
return <ClaudeSettingsTab />;

View File

@@ -0,0 +1,250 @@
import { Button } from '@/components/ui/button';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, Key } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
import { GeminiIcon } from '@/components/ui/provider-icon';
export type GeminiAuthMethod =
| 'api_key' // API key authentication
| 'google_login' // Google OAuth authentication
| 'vertex_ai' // Vertex AI authentication
| 'none';
export interface GeminiAuthStatus {
authenticated: boolean;
method: GeminiAuthMethod;
hasApiKey?: boolean;
hasEnvApiKey?: boolean;
hasCredentialsFile?: boolean;
error?: string;
}
function getAuthMethodLabel(method: GeminiAuthMethod): string {
switch (method) {
case 'api_key':
return 'API Key';
case 'google_login':
return 'Google OAuth';
case 'vertex_ai':
return 'Vertex AI';
default:
return method || 'Unknown';
}
}
interface GeminiCliStatusProps {
status: CliStatus | null;
authStatus?: GeminiAuthStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
export function GeminiCliStatusSkeleton() {
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-36" />
</div>
<SkeletonPulse className="w-9 h-9 rounded-lg" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-80" />
</div>
</div>
<div className="p-6 space-y-4">
{/* Installation status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-40" />
<SkeletonPulse className="h-3 w-32" />
<SkeletonPulse className="h-3 w-48" />
</div>
</div>
{/* Auth status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-28" />
<SkeletonPulse className="h-3 w-36" />
</div>
</div>
</div>
</div>
);
}
export function GeminiCliStatus({
status,
authStatus,
isChecking,
onRefresh,
}: GeminiCliStatusProps) {
if (!status) return <GeminiCliStatusSkeleton />;
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 flex items-center justify-center border border-blue-500/20">
<GeminiIcon className="w-5 h-5 text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Gemini CLI</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid="refresh-gemini-cli"
title="Refresh Gemini CLI detection"
className={cn(
'h-9 w-9 rounded-lg',
'hover:bg-accent/50 hover:scale-105',
'transition-all duration-200'
)}
>
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Gemini CLI provides access to Google&apos;s Gemini AI models with thinking capabilities.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === 'installed' ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Gemini CLI Installed</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
</p>
)}
{status.version && (
<p>
Version: <span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path: <span className="font-mono text-[10px]">{status.path}</span>
</p>
)}
</div>
</div>
</div>
{/* Authentication Status */}
{authStatus?.authenticated ? (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5">
{authStatus.method !== 'none' && (
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
)}
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<div className="w-10 h-10 rounded-xl bg-red-500/15 flex items-center justify-center border border-red-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-red-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-red-400">Authentication Failed</p>
{authStatus?.error && (
<p className="text-xs text-red-400/70 mt-1">{authStatus.error}</p>
)}
<p className="text-xs text-red-400/70 mt-2">
Run <code className="font-mono bg-red-500/10 px-1 rounded">gemini</code>{' '}
interactively in your terminal to log in with Google, or set the{' '}
<code className="font-mono bg-red-500/10 px-1 rounded">GEMINI_API_KEY</code>{' '}
environment variable.
</p>
</div>
</div>
)}
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Gemini CLI Not Detected</p>
<p className="text-xs text-amber-400/70 mt-1">
{status.recommendation || 'Install Gemini CLI to use Google Gemini models.'}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
<div className="space-y-2">
{status.installCommands.npm && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
npm
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
{status.installCommands.macos && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
macOS/Linux
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.macos}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -17,6 +17,7 @@ const NAV_ID_TO_PROVIDER: Record<string, ModelProvider> = {
'cursor-provider': 'cursor',
'codex-provider': 'codex',
'opencode-provider': 'opencode',
'gemini-provider': 'gemini',
};
interface SettingsNavigationProps {

View File

@@ -17,7 +17,13 @@ import {
Code2,
Webhook,
} from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
GeminiIcon,
} from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view';
export interface NavigationItem {
@@ -51,6 +57,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
{ id: 'cursor-provider', label: 'Cursor', icon: CursorIcon },
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
{ id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon },
{ id: 'gemini-provider', label: 'Gemini', icon: GeminiIcon },
],
},
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },

View File

@@ -8,6 +8,7 @@ export type SettingsViewId =
| 'cursor-provider'
| 'codex-provider'
| 'opencode-provider'
| 'gemini-provider'
| 'mcp-servers'
| 'prompts'
| 'model-defaults'

View File

@@ -7,6 +7,7 @@ import type {
CursorModelId,
CodexModelId,
OpencodeModelId,
GeminiModelId,
GroupedModel,
PhaseModelEntry,
ClaudeCompatibleProvider,
@@ -25,6 +26,7 @@ import {
CLAUDE_MODELS,
CURSOR_MODELS,
OPENCODE_MODELS,
GEMINI_MODELS,
THINKING_LEVELS,
THINKING_LEVEL_LABELS,
REASONING_EFFORT_LEVELS,
@@ -39,6 +41,7 @@ import {
OpenRouterIcon,
GlmIcon,
MiniMaxIcon,
GeminiIcon,
getProviderIconForModel,
} from '@/components/ui/provider-icon';
import { Button } from '@/components/ui/button';
@@ -168,6 +171,7 @@ export function PhaseModelSelector({
const expandedProviderTriggerRef = useRef<HTMLDivElement>(null);
const {
enabledCursorModels,
enabledGeminiModels,
favoriteModels,
toggleFavoriteModel,
codexModels,
@@ -322,6 +326,11 @@ export function PhaseModelSelector({
return enabledCursorModels.includes(model.id as CursorModelId);
});
// Filter Gemini models to only show enabled ones
const availableGeminiModels = GEMINI_MODELS.filter((model) => {
return enabledGeminiModels.includes(model.id as GeminiModelId);
});
// Helper to find current selected model details
const currentModel = useMemo(() => {
const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel);
@@ -359,6 +368,16 @@ export function PhaseModelSelector({
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
// Check Gemini models
// Note: Gemini CLI doesn't support thinking level configuration
const geminiModel = availableGeminiModels.find((m) => m.id === selectedModel);
if (geminiModel) {
return {
...geminiModel,
icon: GeminiIcon,
};
}
// Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons
const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) };
@@ -459,6 +478,7 @@ export function PhaseModelSelector({
selectedProviderId,
selectedThinkingLevel,
availableCursorModels,
availableGeminiModels,
transformedCodexModels,
dynamicOpencodeModels,
enabledProviders,
@@ -524,17 +544,20 @@ export function PhaseModelSelector({
// Check if providers are disabled (needed for rendering conditions)
const isCursorDisabled = disabledProviders.includes('cursor');
const isGeminiDisabled = disabledProviders.includes('gemini');
// Group models (filtering out disabled providers)
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
const { favorites, claude, cursor, codex, gemini, opencode } = useMemo(() => {
const favs: typeof CLAUDE_MODELS = [];
const cModels: typeof CLAUDE_MODELS = [];
const curModels: typeof CURSOR_MODELS = [];
const codModels: typeof transformedCodexModels = [];
const gemModels: typeof GEMINI_MODELS = [];
const ocModels: ModelOption[] = [];
const isClaudeDisabled = disabledProviders.includes('claude');
const isCodexDisabled = disabledProviders.includes('codex');
const isGeminiDisabledInner = disabledProviders.includes('gemini');
const isOpencodeDisabled = disabledProviders.includes('opencode');
// Process Claude Models (skip if provider is disabled)
@@ -570,6 +593,17 @@ export function PhaseModelSelector({
});
}
// Process Gemini Models (skip if provider is disabled)
if (!isGeminiDisabledInner) {
availableGeminiModels.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
gemModels.push(model);
}
});
}
// Process OpenCode Models (skip if provider is disabled)
if (!isOpencodeDisabled) {
allOpencodeModels.forEach((model) => {
@@ -586,11 +620,13 @@ export function PhaseModelSelector({
claude: cModels,
cursor: curModels,
codex: codModels,
gemini: gemModels,
opencode: ocModels,
};
}, [
favoriteModels,
availableCursorModels,
availableGeminiModels,
transformedCodexModels,
allOpencodeModels,
disabledProviders,
@@ -1027,6 +1063,60 @@ export function PhaseModelSelector({
);
};
// Render Gemini model item - simple selector without thinking level
// Note: Gemini CLI doesn't support a --thinking-level flag, thinking is model-internal
const renderGeminiModelItem = (model: (typeof GEMINI_MODELS)[0]) => {
const isSelected = selectedModel === model.id;
const isFavorite = favoriteModels.includes(model.id);
return (
<CommandItem
key={model.id}
value={model.label}
onSelect={() => {
onChange({ model: model.id as GeminiModelId });
setOpen(false);
}}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<GeminiIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">{model.description}</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite
? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation();
toggleFavoriteModel(model.id);
}}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
</div>
</CommandItem>
);
};
// Render ClaudeCompatibleProvider model item with thinking level support
const renderProviderModelItem = (
provider: ClaudeCompatibleProvider,
@@ -1839,6 +1929,10 @@ export function PhaseModelSelector({
if (model.provider === 'codex') {
return renderCodexModelItem(model as (typeof transformedCodexModels)[0]);
}
// Gemini model
if (model.provider === 'gemini') {
return renderGeminiModelItem(model as (typeof GEMINI_MODELS)[0]);
}
// OpenCode model
if (model.provider === 'opencode') {
return renderOpencodeModelItem(model);
@@ -1917,6 +2011,12 @@ export function PhaseModelSelector({
</CommandGroup>
)}
{!isGeminiDisabled && gemini.length > 0 && (
<CommandGroup heading="Gemini Models">
{gemini.map((model) => renderGeminiModelItem(model))}
</CommandGroup>
)}
{opencodeSections.length > 0 && (
<CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}>
{opencodeSections.map((section, sectionIndex) => (

View File

@@ -0,0 +1,146 @@
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { GeminiModelId } from '@automaker/types';
import { GeminiIcon } from '@/components/ui/provider-icon';
import { GEMINI_MODEL_MAP } from '@automaker/types';
interface GeminiModelConfigurationProps {
enabledGeminiModels: GeminiModelId[];
geminiDefaultModel: GeminiModelId;
isSaving: boolean;
onDefaultModelChange: (model: GeminiModelId) => void;
onModelToggle: (model: GeminiModelId, enabled: boolean) => void;
}
interface GeminiModelInfo {
id: GeminiModelId;
label: string;
description: string;
supportsThinking: boolean;
}
// Build model info from the GEMINI_MODEL_MAP
const GEMINI_MODEL_INFO: Record<GeminiModelId, GeminiModelInfo> = Object.fromEntries(
Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => [
id as GeminiModelId,
{
id: id as GeminiModelId,
label: config.label,
description: config.description,
supportsThinking: config.supportsThinking,
},
])
) as Record<GeminiModelId, GeminiModelInfo>;
export function GeminiModelConfiguration({
enabledGeminiModels,
geminiDefaultModel,
isSaving,
onDefaultModelChange,
onModelToggle,
}: GeminiModelConfigurationProps) {
const availableModels = Object.values(GEMINI_MODEL_INFO);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 flex items-center justify-center border border-blue-500/20">
<GeminiIcon className="w-5 h-5 text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Model Configuration
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure which Gemini models are available in the feature modal
</p>
</div>
<div className="p-6 space-y-6">
<div className="space-y-2">
<Label>Default Model</Label>
<Select
value={geminiDefaultModel}
onValueChange={(v) => onDefaultModelChange(v as GeminiModelId)}
disabled={isSaving}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span>{model.label}</span>
{model.supportsThinking && (
<Badge variant="outline" className="text-xs">
Thinking
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label>Available Models</Label>
<div className="grid gap-3">
{availableModels.map((model) => {
const isEnabled = enabledGeminiModels.includes(model.id);
const isDefault = model.id === geminiDefaultModel;
return (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) => onModelToggle(model.id, !!checked)}
disabled={isSaving || isDefault}
/>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.label}</span>
{model.supportsThinking && (
<Badge variant="outline" className="text-xs">
Thinking
</Badge>
)}
{isDefault && (
<Badge variant="secondary" className="text-xs">
Default
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{model.description}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useState, useCallback, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { GeminiCliStatus, GeminiCliStatusSkeleton } from '../cli-status/gemini-cli-status';
import { GeminiModelConfiguration } from './gemini-model-configuration';
import { ProviderToggle } from './provider-toggle';
import { useGeminiCliStatus } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { GeminiAuthStatus } from '../cli-status/gemini-cli-status';
import type { GeminiModelId } from '@automaker/types';
export function GeminiSettingsTab() {
const queryClient = useQueryClient();
const { enabledGeminiModels, geminiDefaultModel, setGeminiDefaultModel, toggleGeminiModel } =
useAppStore();
const [isSaving, setIsSaving] = useState(false);
// React Query hooks for data fetching
const {
data: cliStatusData,
isLoading: isCheckingGeminiCli,
refetch: refetchCliStatus,
} = useGeminiCliStatus();
const isCliInstalled = cliStatusData?.installed ?? false;
// Transform CLI status to the expected format
const cliStatus = useMemo((): SharedCliStatus | null => {
if (!cliStatusData) return null;
return {
success: cliStatusData.success ?? false,
status: cliStatusData.installed ? 'installed' : 'not_installed',
method: cliStatusData.auth?.method,
version: cliStatusData.version,
path: cliStatusData.path,
recommendation: cliStatusData.recommendation,
// Server sends installCommand (singular), transform to expected format
installCommands: cliStatusData.installCommand
? { npm: cliStatusData.installCommand }
: cliStatusData.installCommands,
};
}, [cliStatusData]);
// Transform auth status to the expected format
const authStatus = useMemo((): GeminiAuthStatus | null => {
if (!cliStatusData?.auth) return null;
return {
authenticated: cliStatusData.auth.authenticated,
method: (cliStatusData.auth.method as GeminiAuthStatus['method']) || 'none',
hasApiKey: cliStatusData.auth.hasApiKey,
hasEnvApiKey: cliStatusData.auth.hasEnvApiKey,
error: cliStatusData.auth.error,
};
}, [cliStatusData]);
// Refresh all gemini-related queries
const handleRefreshGeminiCli = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.cli.gemini() });
await refetchCliStatus();
toast.success('Gemini CLI refreshed');
}, [queryClient, refetchCliStatus]);
const handleDefaultModelChange = useCallback(
(model: GeminiModelId) => {
setIsSaving(true);
try {
setGeminiDefaultModel(model);
toast.success('Default model updated');
} catch {
toast.error('Failed to update default model');
} finally {
setIsSaving(false);
}
},
[setGeminiDefaultModel]
);
const handleModelToggle = useCallback(
(model: GeminiModelId, enabled: boolean) => {
setIsSaving(true);
try {
toggleGeminiModel(model, enabled);
} catch {
toast.error('Failed to update models');
} finally {
setIsSaving(false);
}
},
[toggleGeminiModel]
);
// Show skeleton only while checking CLI status initially
if (!cliStatus && isCheckingGeminiCli) {
return (
<div className="space-y-6">
<GeminiCliStatusSkeleton />
</div>
);
}
return (
<div className="space-y-6">
{/* Provider Visibility Toggle */}
<ProviderToggle provider="gemini" providerLabel="Gemini" />
<GeminiCliStatus
status={cliStatus}
authStatus={authStatus}
isChecking={isCheckingGeminiCli}
onRefresh={handleRefreshGeminiCli}
/>
{/* Model Configuration - Only show when CLI is installed */}
{isCliInstalled && (
<GeminiModelConfiguration
enabledGeminiModels={enabledGeminiModels}
geminiDefaultModel={geminiDefaultModel}
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
/>
)}
</div>
);
}
export default GeminiSettingsTab;

View File

@@ -3,3 +3,4 @@ export { ClaudeSettingsTab } from './claude-settings-tab';
export { CursorSettingsTab } from './cursor-settings-tab';
export { CodexSettingsTab } from './codex-settings-tab';
export { OpencodeSettingsTab } from './opencode-settings-tab';
export { GeminiSettingsTab } from './gemini-settings-tab';

View File

@@ -1,20 +1,26 @@
import React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { Cpu } from 'lucide-react';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
GeminiIcon,
OpenCodeIcon,
} from '@/components/ui/provider-icon';
import { CursorSettingsTab } from './cursor-settings-tab';
import { ClaudeSettingsTab } from './claude-settings-tab';
import { CodexSettingsTab } from './codex-settings-tab';
import { OpencodeSettingsTab } from './opencode-settings-tab';
import { GeminiSettingsTab } from './gemini-settings-tab';
interface ProviderTabsProps {
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode';
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini';
}
export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
return (
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-4 mb-6">
<TabsList className="grid w-full grid-cols-5 mb-6">
<TabsTrigger value="claude" className="flex items-center gap-2">
<AnthropicIcon className="w-4 h-4" />
Claude
@@ -28,9 +34,13 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
Codex
</TabsTrigger>
<TabsTrigger value="opencode" className="flex items-center gap-2">
<Cpu className="w-4 h-4" />
<OpenCodeIcon className="w-4 h-4" />
OpenCode
</TabsTrigger>
<TabsTrigger value="gemini" className="flex items-center gap-2">
<GeminiIcon className="w-4 h-4" />
Gemini
</TabsTrigger>
</TabsList>
<TabsContent value="claude">
@@ -48,6 +58,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
<TabsContent value="opencode">
<OpencodeSettingsTab />
</TabsContent>
<TabsContent value="gemini">
<GeminiSettingsTab />
</TabsContent>
</Tabs>
);
}

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>

View File

@@ -63,6 +63,7 @@ export {
useCursorCliStatus,
useCodexCliStatus,
useOpencodeCliStatus,
useGeminiCliStatus,
useGitHubCliStatus,
useApiKeysStatus,
usePlatformInfo,

View File

@@ -89,6 +89,26 @@ export function useOpencodeCliStatus() {
});
}
/**
* Fetch Gemini CLI status
*
* @returns Query result with Gemini CLI status
*/
export function useGeminiCliStatus() {
return useQuery({
queryKey: queryKeys.cli.gemini(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.setup.getGeminiStatus();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch Gemini status');
}
return result;
},
staleTime: STALE_TIMES.CLI_STATUS,
});
}
/**
* Fetch GitHub CLI status
*

View File

@@ -21,15 +21,20 @@ import { useAuthStore } from '@/store/auth-store';
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
import {
DEFAULT_OPENCODE_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_MAX_CONCURRENCY,
getAllOpencodeModelIds,
getAllCursorModelIds,
getAllCodexModelIds,
getAllGeminiModelIds,
migrateCursorModelIds,
migrateOpencodeModelIds,
migratePhaseModelEntry,
type GlobalSettings,
type CursorModelId,
type OpencodeModelId,
type CodexModelId,
type GeminiModelId,
} from '@automaker/types';
const logger = createLogger('SettingsSync');
@@ -66,6 +71,10 @@ const SETTINGS_FIELDS_TO_SYNC = [
'cursorDefaultModel',
'enabledOpencodeModels',
'opencodeDefaultModel',
'enabledCodexModels',
'codexDefaultModel',
'enabledGeminiModels',
'geminiDefaultModel',
'enabledDynamicModelIds',
'disabledProviders',
'autoLoadClaudeMd',
@@ -567,6 +576,37 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel);
}
// Sanitize Codex models
const validCodexModelIds = new Set(getAllCodexModelIds());
const DEFAULT_CODEX_MODEL: CodexModelId = 'codex-gpt-5.2-codex';
const sanitizedEnabledCodexModels = (serverSettings.enabledCodexModels ?? []).filter(
(id): id is CodexModelId => validCodexModelIds.has(id as CodexModelId)
);
const sanitizedCodexDefaultModel = validCodexModelIds.has(
serverSettings.codexDefaultModel as CodexModelId
)
? (serverSettings.codexDefaultModel as CodexModelId)
: DEFAULT_CODEX_MODEL;
if (!sanitizedEnabledCodexModels.includes(sanitizedCodexDefaultModel)) {
sanitizedEnabledCodexModels.push(sanitizedCodexDefaultModel);
}
// Sanitize Gemini models
const validGeminiModelIds = new Set(getAllGeminiModelIds());
const sanitizedEnabledGeminiModels = (serverSettings.enabledGeminiModels ?? []).filter(
(id): id is GeminiModelId => validGeminiModelIds.has(id as GeminiModelId)
);
const sanitizedGeminiDefaultModel = validGeminiModelIds.has(
serverSettings.geminiDefaultModel as GeminiModelId
)
? (serverSettings.geminiDefaultModel as GeminiModelId)
: DEFAULT_GEMINI_MODEL;
if (!sanitizedEnabledGeminiModels.includes(sanitizedGeminiDefaultModel)) {
sanitizedEnabledGeminiModels.push(sanitizedGeminiDefaultModel);
}
const persistedDynamicModelIds =
serverSettings.enabledDynamicModelIds ?? currentAppState.enabledDynamicModelIds;
const sanitizedDynamicModelIds = persistedDynamicModelIds.filter(
@@ -659,6 +699,10 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
cursorDefaultModel: sanitizedCursorDefault,
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledCodexModels: sanitizedEnabledCodexModels,
codexDefaultModel: sanitizedCodexDefaultModel,
enabledGeminiModels: sanitizedEnabledGeminiModels,
geminiDefaultModel: sanitizedGeminiDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds,
disabledProviders: serverSettings.disabledProviders ?? [],
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,

View File

@@ -1655,6 +1655,48 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
}> => this.post('/api/setup/opencode/cache/clear'),
// Gemini CLI methods
getGeminiStatus: (): Promise<{
success: boolean;
status?: string;
installed?: boolean;
method?: string;
version?: string;
path?: string;
recommendation?: string;
installCommands?: {
macos?: string;
linux?: string;
npm?: string;
};
auth?: {
authenticated: boolean;
method: string;
hasApiKey?: boolean;
hasEnvApiKey?: boolean;
error?: string;
};
loginCommand?: string;
installCommand?: string;
error?: string;
}> => this.get('/api/setup/gemini-status'),
authGemini: (): Promise<{
success: boolean;
requiresManualAuth?: boolean;
command?: string;
message?: string;
error?: string;
}> => this.post('/api/setup/auth-gemini'),
deauthGemini: (): Promise<{
success: boolean;
requiresManualDeauth?: boolean;
command?: string;
message?: string;
error?: string;
}> => this.post('/api/setup/deauth-gemini'),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent('agent:stream', callback);
},

View File

@@ -176,6 +176,8 @@ export const queryKeys = {
codex: () => ['cli', 'codex'] as const,
/** OpenCode CLI status */
opencode: () => ['cli', 'opencode'] as const,
/** Gemini CLI status */
gemini: () => ['cli', 'gemini'] as const,
/** GitHub CLI status */
github: () => ['cli', 'github'] as const,
/** API keys status */

View File

@@ -21,6 +21,7 @@ import type {
CursorModelId,
CodexModelId,
OpencodeModelId,
GeminiModelId,
PhaseModelConfig,
PhaseModelKey,
PhaseModelEntry,
@@ -39,8 +40,10 @@ import {
getAllCursorModelIds,
getAllCodexModelIds,
getAllOpencodeModelIds,
getAllGeminiModelIds,
DEFAULT_PHASE_MODELS,
DEFAULT_OPENCODE_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_MAX_CONCURRENCY,
DEFAULT_GLOBAL_SETTINGS,
} from '@automaker/types';
@@ -729,6 +732,10 @@ export interface AppState {
opencodeModelsLastFetched: number | null; // Timestamp of last successful fetch
opencodeModelsLastFailedAt: number | null; // Timestamp of last failed fetch
// Gemini CLI Settings (global)
enabledGeminiModels: GeminiModelId[]; // Which Gemini models are available in feature modal
geminiDefaultModel: GeminiModelId; // Default Gemini model selection
// Provider Visibility Settings
disabledProviders: ModelProvider[]; // Providers that are disabled and hidden from dropdowns
@@ -1218,6 +1225,11 @@ export interface AppActions {
providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }>
) => void;
// Gemini CLI Settings actions
setEnabledGeminiModels: (models: GeminiModelId[]) => void;
setGeminiDefaultModel: (model: GeminiModelId) => void;
toggleGeminiModel: (model: GeminiModelId, enabled: boolean) => void;
// Provider Visibility Settings actions
setDisabledProviders: (providers: ModelProvider[]) => void;
toggleProviderDisabled: (provider: ModelProvider, disabled: boolean) => void;
@@ -1503,6 +1515,8 @@ const initialState: AppState = {
opencodeModelsError: null,
opencodeModelsLastFetched: null,
opencodeModelsLastFailedAt: null,
enabledGeminiModels: getAllGeminiModelIds(), // All Gemini models enabled by default
geminiDefaultModel: DEFAULT_GEMINI_MODEL, // Default to Gemini 2.5 Flash
disabledProviders: [], // No providers disabled by default
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
@@ -2735,6 +2749,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
),
}),
// Gemini CLI Settings actions
setEnabledGeminiModels: (models) => set({ enabledGeminiModels: models }),
setGeminiDefaultModel: (model) => set({ geminiDefaultModel: model }),
toggleGeminiModel: (model, enabled) =>
set((state) => ({
enabledGeminiModels: enabled
? [...state.enabledGeminiModels, model]
: state.enabledGeminiModels.filter((m) => m !== model),
})),
// Provider Visibility Settings actions
setDisabledProviders: (providers) => set({ disabledProviders: providers }),
toggleProviderDisabled: (provider, disabled) =>

View File

@@ -63,6 +63,22 @@ export interface OpencodeCliStatus {
error?: string;
}
// Gemini CLI Status
export interface GeminiCliStatus {
installed: boolean;
version?: string | null;
path?: string | null;
auth?: {
authenticated: boolean;
method: string;
hasApiKey?: boolean;
hasEnvApiKey?: boolean;
};
installCommand?: string;
loginCommand?: string;
error?: string;
}
// Codex Auth Method
export type CodexAuthMethod =
| 'api_key_env' // OPENAI_API_KEY environment variable
@@ -120,6 +136,7 @@ export type SetupStep =
| 'cursor'
| 'codex'
| 'opencode'
| 'gemini'
| 'github'
| 'complete';
@@ -149,6 +166,9 @@ export interface SetupState {
// OpenCode CLI state
opencodeCliStatus: OpencodeCliStatus | null;
// Gemini CLI state
geminiCliStatus: GeminiCliStatus | null;
// Setup preferences
skipClaudeSetup: boolean;
}
@@ -183,6 +203,9 @@ export interface SetupActions {
// OpenCode CLI
setOpencodeCliStatus: (status: OpencodeCliStatus | null) => void;
// Gemini CLI
setGeminiCliStatus: (status: GeminiCliStatus | null) => void;
// Preferences
setSkipClaudeSetup: (skip: boolean) => void;
}
@@ -216,6 +239,8 @@ const initialState: SetupState = {
opencodeCliStatus: null,
geminiCliStatus: null,
skipClaudeSetup: shouldSkipSetup,
};
@@ -288,6 +313,9 @@ export const useSetupStore = create<SetupState & SetupActions>()((set, get) => (
// OpenCode CLI
setOpencodeCliStatus: (status) => set({ opencodeCliStatus: status }),
// Gemini CLI
setGeminiCliStatus: (status) => set({ geminiCliStatus: status }),
// Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
}));