Files
claudecodeui/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
Menny Even Danan a367edd515 feat: Google's gemini-cli integration (#422)
* feat: integrate Gemini AI agent provider

- Core Backend: Ported gemini-cli.js and gemini-response-handler.js to establish the CLI bridge. Registered 'gemini' as an active provider within index.js.
- Core Frontend: Extended QuickSettingsPanel.jsx, Settings.jsx, and AgentListItem.jsx to render the Gemini provider option, models (gemini-pro, gemini-flash, etc.), and handle OAuth states.
- WebSocket Pipeline: Added support for gemini-command executions in backend and payload processing of gemini-response and gemini-error streams in useChatRealtimeHandlers.ts. Resolved JSON double-stringification and sessionId stripping issues in the transmission handler.
- Platform Compatibility: Added scripts/fix-node-pty.js postinstall script and modified posix_spawnp calls with sh -c wrapper to prevent ENOEXEC and MacOS permission errors when spawning the gemini headless binary.
- UX & Design: Imported official Google Gemini branding via GeminiLogo.jsx and gemini-ai-icon.svg. Updated translations (chat.json) for en, zh-CN, and ko locales.

* fix: propagate gemini permission mode from settings to cli

- Added Gemini Permissions UI in Settings to toggle Auto Edit and YOLO modes
- Synced gemini permission mode to localStorage
- Passed permissionMode in useChatComposerState for Gemini commands
- Mapped frontend permission modes to --yolo and --approval-mode options in gemini-cli.js

* feat(gemini): Refactor Gemini CLI integration to use stream-json

- Replaced regex buffering text-system with NDJSON stream parsing
- Added fallback for restricted models like gemini-3.1-pro-preview

* feat(gemini): Render tool_use and tool_result UI bubbles

- Forwarded gemini tool NDJSON objects to the websocket
- Added React state handlers in useChatRealtimeHandlers to match Claude's tool UI behavior

* feat(gemini): Add native session resumption and UI token tracking

- Captured cliSessionId from init events to map ClaudeCodeUI's chat sessionId directly into Gemini's internal session manager.
- Updated gemini-cli.js spawn arguments to append the --resume proxy flag instead of naively dumping the accumulated chat history into the command prompt.
- Handled result stream objects by proxying total_tokens back into the frontend's claude-status tracker to natively populate the UI label.
- Eliminated gemini-3 model proxy filter entirely.

* fix(gemini): Fix static 'Claude' name rendering in chat UI header

- Added "gemini": "Gemini" translation strings to messageTypes across English, Korean, and Chinese loc dictionaries.
- Updated AssistantThinkingIndicator and MessageComponent ternary checks to identify provider === 'gemini' and render the appropriate brand label instead of statically defaulting to Claude.

* feat: Add Gemini session persistence API mapping and Sidebar UI

* fix(gemini): Watch ~/.gemini/sessions for live UI updates

Added the .gemini/sessions directory to PROVIDER_WATCH_PATHS so that Chokidar emits projects_updated websocket events when new Gemini sessions are created or modified, fixing live sidebar updates.

* fix(gemini): Fix Gemini authentication status display in Settings UI

- Injected 'checkGeminiAuthStatus' into the Settings.jsx React effect hook so that the UI can poll and render the 'geminiAuthStatus' state.
- Updated 'checkGeminiCredentials()' inside server/routes/cli-auth.js to read from '~/.gemini/oauth_creds.json' and '~/.gemini/google_accounts.json', resolving the email address correctly.

* Use logo-only icon for gemini

* feat(gemini): Add Gemini 3 preview models to UI selection list

* Fix Gemini CLI session resume bug and PR #422 review nitpicks

* Fix Gemini tool calls disappearing from UI after completion

* fix(gemini): resolve outstanding PR #422 feedback and stabilize gemini CLI timeouts

* fix(gemini): resolve resume flag and shell session initialization issues

This commit addresses the remaining PR comments for the Gemini CLI integration:

- Moves the `--resume` flag logic outside the prompt command block, ensuring Gemini sessions correctly resume even when a new prompt isn't passed.

- Updates `handleShellConnection` to correctly lookup the native `cliSessionId` from the internal `sessionId` when spawning Gemini sessions in a plain shell.

- Refactors dynamic import of `sessionManager.js` back to a native static import for code consistency.

* chore: fix TypeScript errors and remove gemini CLI dependency

* fix: use cross-spawn on Windows to resolve gemini.cmd correctly

---------

Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
2026-02-27 17:36:35 +03:00

239 lines
8.9 KiB
TypeScript

import React from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import NextTaskBanner from '../../../NextTaskBanner.jsx';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants';
import type { ProjectSession, SessionProvider } from '../../../../types/app';
interface ProviderSelectionEmptyStateProps {
selectedSession: ProjectSession | null;
currentSessionId: string | null;
provider: SessionProvider;
setProvider: (next: SessionProvider) => void;
textareaRef: React.RefObject<HTMLTextAreaElement>;
claudeModel: string;
setClaudeModel: (model: string) => void;
cursorModel: string;
setCursorModel: (model: string) => void;
codexModel: string;
setCodexModel: (model: string) => void;
geminiModel: string;
setGeminiModel: (model: string) => void;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
setInput: React.Dispatch<React.SetStateAction<string>>;
}
type ProviderDef = {
id: SessionProvider;
name: string;
infoKey: string;
accent: string;
ring: string;
check: string;
};
const PROVIDERS: ProviderDef[] = [
{
id: 'claude',
name: 'Claude Code',
infoKey: 'providerSelection.providerInfo.anthropic',
accent: 'border-primary',
ring: 'ring-primary/15',
check: 'bg-primary text-primary-foreground',
},
{
id: 'cursor',
name: 'Cursor',
infoKey: 'providerSelection.providerInfo.cursorEditor',
accent: 'border-violet-500 dark:border-violet-400',
ring: 'ring-violet-500/15',
check: 'bg-violet-500 text-white',
},
{
id: 'codex',
name: 'Codex',
infoKey: 'providerSelection.providerInfo.openai',
accent: 'border-emerald-600 dark:border-emerald-400',
ring: 'ring-emerald-600/15',
check: 'bg-emerald-600 dark:bg-emerald-500 text-white',
},
{
id: 'gemini',
name: 'Gemini',
infoKey: 'providerSelection.providerInfo.google',
accent: 'border-blue-500 dark:border-blue-400',
ring: 'ring-blue-500/15',
check: 'bg-blue-500 text-white',
},
];
function getModelConfig(p: SessionProvider) {
if (p === 'claude') return CLAUDE_MODELS;
if (p === 'codex') return CODEX_MODELS;
if (p === 'gemini') return GEMINI_MODELS;
return CURSOR_MODELS;
}
function getModelValue(p: SessionProvider, c: string, cu: string, co: string, g: string) {
if (p === 'claude') return c;
if (p === 'codex') return co;
if (p === 'gemini') return g;
return cu;
}
export default function ProviderSelectionEmptyState({
selectedSession,
currentSessionId,
provider,
setProvider,
textareaRef,
claudeModel,
setClaudeModel,
cursorModel,
setCursorModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
setInput,
}: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation('chat');
const nextTaskPrompt = t('tasks.nextTaskPrompt', { defaultValue: 'Start the next task' });
const selectProvider = (next: SessionProvider) => {
setProvider(next);
localStorage.setItem('selected-provider', next);
setTimeout(() => textareaRef.current?.focus(), 100);
};
const handleModelChange = (value: string) => {
if (provider === 'claude') { setClaudeModel(value); localStorage.setItem('claude-model', value); }
else if (provider === 'codex') { setCodexModel(value); localStorage.setItem('codex-model', value); }
else if (provider === 'gemini') { setGeminiModel(value); localStorage.setItem('gemini-model', value); }
else { setCursorModel(value); localStorage.setItem('cursor-model', value); }
};
const modelConfig = getModelConfig(provider);
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel, geminiModel);
/* ── New session — provider picker ── */
if (!selectedSession && !currentSessionId) {
return (
<div className="flex items-center justify-center h-full px-4">
<div className="w-full max-w-md">
{/* Heading */}
<div className="text-center mb-8">
<h2 className="text-lg sm:text-xl font-semibold text-foreground tracking-tight">
{t('providerSelection.title')}
</h2>
<p className="text-[13px] text-muted-foreground mt-1">
{t('providerSelection.description')}
</p>
</div>
{/* Provider cards — horizontal row, equal width */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-2.5 mb-6">
{PROVIDERS.map((p) => {
const active = provider === p.id;
return (
<button
key={p.id}
onClick={() => selectProvider(p.id)}
className={`
relative flex flex-col items-center gap-2.5 pt-5 pb-4 px-2
rounded-xl border-[1.5px] transition-all duration-150
active:scale-[0.97]
${active
? `${p.accent} ${p.ring} ring-2 bg-card shadow-sm`
: 'border-border bg-card/60 hover:bg-card hover:border-border/80'
}
`}
>
<SessionProviderLogo
provider={p.id}
className={`w-9 h-9 transition-transform duration-150 ${active ? 'scale-110' : ''}`}
/>
<div className="text-center">
<p className="text-[13px] font-semibold text-foreground leading-none">{p.name}</p>
<p className="text-[10px] text-muted-foreground mt-1 leading-tight">{t(p.infoKey)}</p>
</div>
{/* Check badge */}
{active && (
<div className={`absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}>
<Check className="w-2.5 h-2.5" strokeWidth={3} />
</div>
)}
</button>
);
})}
</div>
{/* Model picker — appears after provider is chosen */}
<div className={`transition-all duration-200 ${provider ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-1 pointer-events-none'}`}>
<div className="flex items-center justify-center gap-2 mb-5">
<span className="text-sm text-muted-foreground">{t('providerSelection.selectModel')}</span>
<div className="relative">
<select
value={currentModel}
onChange={(e) => handleModelChange(e.target.value)}
tabIndex={-1}
className="appearance-none pl-3 pr-7 py-1.5 text-sm font-medium bg-muted/50 border border-border/60 rounded-lg text-foreground cursor-pointer hover:bg-muted transition-colors focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{modelConfig.OPTIONS.map(({ value, label }: { value: string; label: string }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
</div>
</div>
<p className="text-center text-sm text-muted-foreground/70">
{
{
claude: t('providerSelection.readyPrompt.claude', { model: claudeModel }),
cursor: t('providerSelection.readyPrompt.cursor', { model: cursorModel }),
codex: t('providerSelection.readyPrompt.codex', { model: codexModel }),
gemini: t('providerSelection.readyPrompt.gemini', { model: geminiModel }),
}[provider]
}
</p>
</div>
{/* Task banner */}
{provider && tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5">
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
</div>
)}
</div>
</div>
);
}
/* ── Existing session — continue prompt ── */
if (selectedSession) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center px-6 max-w-md">
<p className="text-lg font-semibold text-foreground mb-1.5">{t('session.continue.title')}</p>
<p className="text-sm text-muted-foreground leading-relaxed">{t('session.continue.description')}</p>
{tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5">
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
</div>
)}
</div>
</div>
);
}
return null;
}