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

@@ -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) => (