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>
This commit is contained in:
Menny Even Danan
2026-02-27 09:36:35 -05:00
committed by GitHub
parent 917c353115
commit a367edd515
44 changed files with 2399 additions and 796 deletions

View File

@@ -0,0 +1,9 @@
import React from 'react';
const GeminiLogo = ({className = 'w-5 h-5'}) => {
return (
<img src="/icons/gemini-ai-icon.svg" alt="Gemini" className={className} />
);
};
export default GeminiLogo;

View File

@@ -0,0 +1,90 @@
import React, { useState, useEffect } from 'react';
import { cn } from '../lib/utils';
function GeminiStatus({ status, onAbort, isLoading }) {
const [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0);
// Update elapsed time every second
useEffect(() => {
if (!isLoading) {
setElapsedTime(0);
return;
}
const startTime = Date.now();
const timer = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setElapsedTime(elapsed);
}, 1000);
return () => clearInterval(timer);
}, [isLoading]);
// Animate the status indicator
useEffect(() => {
if (!isLoading) return;
const timer = setInterval(() => {
setAnimationPhase(prev => (prev + 1) % 4);
}, 500);
return () => clearInterval(timer);
}, [isLoading]);
if (!isLoading) return null;
// Clever action words that cycle
const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
// Parse status data
const statusText = status?.text || actionWords[actionIndex];
const canInterrupt = status?.can_interrupt !== false;
// Animation characters
const spinners = ['✻', '✹', '✸', '✶'];
const currentSpinner = spinners[animationPhase];
return (
<div className="w-full mb-6 animate-in slide-in-from-bottom duration-300">
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gradient-to-r from-cyan-900 to-blue-900 dark:from-cyan-950 dark:to-blue-950 text-white rounded-lg shadow-lg px-4 py-3">
<div className="flex-1">
<div className="flex items-center gap-3">
{/* Animated spinner */}
<span className={cn(
"text-xl transition-all duration-500",
animationPhase % 2 === 0 ? "text-cyan-400 scale-110" : "text-cyan-300"
)}>
{currentSpinner}
</span>
{/* Status text - first line */}
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{statusText}...</span>
<span className="text-gray-400 text-sm">({elapsedTime}s)</span>
</div>
</div>
</div>
</div>
{/* Interrupt button */}
{canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
className="ml-3 text-xs bg-red-600 hover:bg-red-700 text-white px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1.5 flex-shrink-0"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="hidden sm:inline">Stop</span>
</button>
)}
</div>
</div>
);
}
export default GeminiStatus;

View File

@@ -1,14 +1,14 @@
import { X } from 'lucide-react';
import { X, ExternalLink, KeyRound } from 'lucide-react';
import StandaloneShell from './standalone-shell/view/StandaloneShell';
import { IS_PLATFORM } from '../constants/config';
/**
* Reusable login modal component for Claude, Cursor, and Codex CLI authentication
* Reusable login modal component for Claude, Cursor, Codex, and Gemini CLI authentication
*
* @param {Object} props
* @param {boolean} props.isOpen - Whether the modal is visible
* @param {Function} props.onClose - Callback when modal is closed
* @param {'claude'|'cursor'|'codex'} props.provider - Which CLI provider to authenticate with
* @param {'claude'|'cursor'|'codex'|'gemini'} props.provider - Which CLI provider to authenticate with
* @param {Object} props.project - Project object containing name and path information
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
* @param {string} props.customCommand - Optional custom command to override defaults
@@ -36,6 +36,9 @@ function LoginModal({
return 'cursor-agent login';
case 'codex':
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
case 'gemini':
// No explicit interactive login command for gemini CLI exists yet similar to Claude, so we'll just check status or instruct the user to configure `.gemini.json`
return 'gemini status';
default:
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
}
@@ -49,6 +52,8 @@ function LoginModal({
return 'Cursor CLI Login';
case 'codex':
return 'Codex CLI Login';
case 'gemini':
return 'Gemini CLI Configuration';
default:
return 'CLI Login';
}
@@ -77,12 +82,68 @@ function LoginModal({
</button>
</div>
<div className="flex-1 overflow-hidden">
<StandaloneShell
project={project}
command={getCommand()}
onComplete={handleComplete}
minimal={true}
/>
{provider === 'gemini' ? (
<div className="flex flex-col items-center justify-center h-full p-8 text-center bg-gray-50 dark:bg-gray-900/50">
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mb-6">
<KeyRound className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<h4 className="text-xl font-medium text-gray-900 dark:text-white mb-3">
Setup Gemini API Access
</h4>
<p className="text-gray-600 dark:text-gray-400 max-w-md mb-8">
The Gemini CLI requires an API key to function. Unlike Claude, you'll need to configure this directly in your terminal first.
</p>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 max-w-lg w-full text-left shadow-sm">
<ol className="space-y-4">
<li className="flex gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
1
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Get your API Key</p>
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noreferrer"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 inline-flex"
>
Google AI Studio <ExternalLink className="w-3 h-3" />
</a>
</div>
</li>
<li className="flex gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
2
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Run configuration</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">Open your terminal and run:</p>
<code className="block bg-gray-100 dark:bg-gray-900 px-3 py-2 rounded text-sm text-pink-600 dark:text-pink-400 font-mono">
gemini config set api_key YOUR_KEY
</code>
</div>
</li>
</ol>
</div>
<button
onClick={onClose}
className="mt-8 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
Done
</button>
</div>
) : (
<StandaloneShell
project={project}
command={getCommand()}
onComplete={handleComplete}
minimal={true}
/>
)}
</div>
</div>
</div>

View File

@@ -37,6 +37,13 @@ const Onboarding = ({ onComplete }) => {
error: null
});
const [geminiAuthStatus, setGeminiAuthStatus] = useState({
authenticated: false,
email: null,
loading: true,
error: null
});
const { user } = useAuth();
const prevActiveLoginProviderRef = useRef(undefined);
@@ -69,22 +76,23 @@ const Onboarding = ({ onComplete }) => {
checkClaudeAuthStatus();
checkCursorAuthStatus();
checkCodexAuthStatus();
checkGeminiAuthStatus();
}
}, [activeLoginProvider]);
const checkClaudeAuthStatus = async () => {
const checkProviderAuthStatus = async (provider, setter) => {
try {
const response = await authenticatedFetch('/api/cli/claude/status');
const response = await authenticatedFetch(`/api/cli/${provider}/status`);
if (response.ok) {
const data = await response.json();
setClaudeAuthStatus({
setter({
authenticated: data.authenticated,
email: data.email,
loading: false,
error: data.error || null
});
} else {
setClaudeAuthStatus({
setter({
authenticated: false,
email: null,
loading: false,
@@ -92,8 +100,8 @@ const Onboarding = ({ onComplete }) => {
});
}
} catch (error) {
console.error('Error checking Claude auth status:', error);
setClaudeAuthStatus({
console.error(`Error checking ${provider} auth status:`, error);
setter({
authenticated: false,
email: null,
loading: false,
@@ -102,69 +110,15 @@ const Onboarding = ({ onComplete }) => {
}
};
const checkCursorAuthStatus = async () => {
try {
const response = await authenticatedFetch('/api/cli/cursor/status');
if (response.ok) {
const data = await response.json();
setCursorAuthStatus({
authenticated: data.authenticated,
email: data.email,
loading: false,
error: data.error || null
});
} else {
setCursorAuthStatus({
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status'
});
}
} catch (error) {
console.error('Error checking Cursor auth status:', error);
setCursorAuthStatus({
authenticated: false,
email: null,
loading: false,
error: error.message
});
}
};
const checkCodexAuthStatus = async () => {
try {
const response = await authenticatedFetch('/api/cli/codex/status');
if (response.ok) {
const data = await response.json();
setCodexAuthStatus({
authenticated: data.authenticated,
email: data.email,
loading: false,
error: data.error || null
});
} else {
setCodexAuthStatus({
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status'
});
}
} catch (error) {
console.error('Error checking Codex auth status:', error);
setCodexAuthStatus({
authenticated: false,
email: null,
loading: false,
error: error.message
});
}
};
const checkClaudeAuthStatus = () => checkProviderAuthStatus('claude', setClaudeAuthStatus);
const checkCursorAuthStatus = () => checkProviderAuthStatus('cursor', setCursorAuthStatus);
const checkCodexAuthStatus = () => checkProviderAuthStatus('codex', setCodexAuthStatus);
const checkGeminiAuthStatus = () => checkProviderAuthStatus('gemini', setGeminiAuthStatus);
const handleClaudeLogin = () => setActiveLoginProvider('claude');
const handleCursorLogin = () => setActiveLoginProvider('cursor');
const handleCodexLogin = () => setActiveLoginProvider('codex');
const handleGeminiLogin = () => setActiveLoginProvider('gemini');
const handleLoginComplete = (exitCode) => {
if (exitCode === 0) {
@@ -174,6 +128,8 @@ const Onboarding = ({ onComplete }) => {
checkCursorAuthStatus();
} else if (activeLoginProvider === 'codex') {
checkCodexAuthStatus();
} else if (activeLoginProvider === 'gemini') {
checkGeminiAuthStatus();
}
}
};
@@ -337,11 +293,10 @@ const Onboarding = ({ onComplete }) => {
{/* Agent Cards Grid */}
<div className="space-y-3">
{/* Claude */}
<div className={`border rounded-lg p-4 transition-colors ${
claudeAuthStatus.authenticated
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: 'border-border bg-card'
}`}>
<div className={`border rounded-lg p-4 transition-colors ${claudeAuthStatus.authenticated
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
@@ -354,7 +309,7 @@ const Onboarding = ({ onComplete }) => {
</div>
<div className="text-xs text-muted-foreground">
{claudeAuthStatus.loading ? 'Checking...' :
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
@@ -370,11 +325,10 @@ const Onboarding = ({ onComplete }) => {
</div>
{/* Cursor */}
<div className={`border rounded-lg p-4 transition-colors ${
cursorAuthStatus.authenticated
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
: 'border-border bg-card'
}`}>
<div className={`border rounded-lg p-4 transition-colors ${cursorAuthStatus.authenticated
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
@@ -387,7 +341,7 @@ const Onboarding = ({ onComplete }) => {
</div>
<div className="text-xs text-muted-foreground">
{cursorAuthStatus.loading ? 'Checking...' :
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
@@ -403,11 +357,10 @@ const Onboarding = ({ onComplete }) => {
</div>
{/* Codex */}
<div className={`border rounded-lg p-4 transition-colors ${
codexAuthStatus.authenticated
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
: 'border-border bg-card'
}`}>
<div className={`border rounded-lg p-4 transition-colors ${codexAuthStatus.authenticated
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
@@ -420,7 +373,7 @@ const Onboarding = ({ onComplete }) => {
</div>
<div className="text-xs text-muted-foreground">
{codexAuthStatus.loading ? 'Checking...' :
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
@@ -434,6 +387,38 @@ const Onboarding = ({ onComplete }) => {
)}
</div>
</div>
{/* Gemini */}
<div className={`border rounded-lg p-4 transition-colors ${geminiAuthStatus.authenticated
? 'bg-teal-50 dark:bg-teal-900/20 border-teal-200 dark:border-teal-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-teal-100 dark:bg-teal-900/30 rounded-full flex items-center justify-center">
<SessionProviderLogo provider="gemini" className="w-5 h-5 text-teal-600 dark:text-teal-400" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
Gemini
{geminiAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
</div>
<div className="text-xs text-muted-foreground">
{geminiAuthStatus.loading ? 'Checking...' :
geminiAuthStatus.authenticated ? geminiAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
{!geminiAuthStatus.authenticated && !geminiAuthStatus.loading && (
<button
onClick={handleGeminiLogin}
className="bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
>
Login
</button>
)}
</div>
</div>
</div>
<div className="text-center text-sm text-muted-foreground pt-2">
@@ -452,7 +437,7 @@ const Onboarding = ({ onComplete }) => {
case 0:
return gitName.trim() && gitEmail.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(gitEmail);
case 1:
return true;
return true;
default:
return false;
}
@@ -468,11 +453,10 @@ const Onboarding = ({ onComplete }) => {
{steps.map((step, index) => (
<React.Fragment key={index}>
<div className="flex flex-col items-center flex-1">
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${
index < currentStep ? 'bg-green-500 border-green-500 text-white' :
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500 border-green-500 text-white' :
index === currentStep ? 'bg-blue-600 border-blue-600 text-white' :
'bg-background border-border text-muted-foreground'
}`}>
'bg-background border-border text-muted-foreground'
}`}>
{index < currentStep ? (
<Check className="w-6 h-6" />
) : typeof step.icon === 'function' ? (
@@ -482,9 +466,8 @@ const Onboarding = ({ onComplete }) => {
)}
</div>
<div className="mt-2 text-center">
<p className={`text-sm font-medium ${
index === currentStep ? 'text-foreground' : 'text-muted-foreground'
}`}>
<p className={`text-sm font-medium ${index === currentStep ? 'text-foreground' : 'text-muted-foreground'
}`}>
{step.title}
</p>
{step.required && (
@@ -493,9 +476,8 @@ const Onboarding = ({ onComplete }) => {
</div>
</div>
{index < steps.length - 1 && (
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${
index < currentStep ? 'bg-green-500' : 'bg-border'
}`} />
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500' : 'bg-border'
}`} />
)}
</React.Fragment>
))}

View File

@@ -41,6 +41,7 @@ interface UseChatComposerStateArgs {
cursorModel: string;
claudeModel: string;
codexModel: string;
geminiModel: string;
isLoading: boolean;
canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null;
@@ -93,6 +94,7 @@ export function useChatComposerState({
cursorModel,
claudeModel,
codexModel,
geminiModel,
isLoading,
canAbortSession,
tokenBudget,
@@ -289,7 +291,7 @@ export function useChatComposerState({
projectName: selectedProject.name,
sessionId: currentSessionId,
provider,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : claudeModel,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
tokenUsage: tokenBudget,
};
@@ -343,6 +345,7 @@ export function useChatComposerState({
codexModel,
currentSessionId,
cursorModel,
geminiModel,
handleBuiltInCommand,
handleCustomCommand,
input,
@@ -581,8 +584,10 @@ export function useChatComposerState({
provider === 'cursor'
? 'cursor-tools-settings'
: provider === 'codex'
? 'codex-settings'
: 'claude-settings';
? 'codex-settings'
: provider === 'gemini'
? 'gemini-settings'
: 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
return JSON.parse(savedSettings);
@@ -630,6 +635,21 @@ export function useChatComposerState({
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
},
});
} else if (provider === 'gemini') {
sendMessage({
type: 'gemini-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: geminiModel,
permissionMode,
toolsSettings,
},
});
} else {
sendMessage({
type: 'claude-command',
@@ -669,6 +689,7 @@ export function useChatComposerState({
currentSessionId,
cursorModel,
executeCommand,
geminiModel,
isLoading,
onSessionActive,
onSessionProcessing,

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS } from '../../../../shared/modelConstants';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types';
import type { ProjectSession, SessionProvider } from '../../../types/app';
@@ -23,6 +23,9 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
const [codexModel, setCodexModel] = useState<string>(() => {
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
});
const [geminiModel, setGeminiModel] = useState<string>(() => {
return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT;
});
const lastProviderRef = useRef(provider);
@@ -105,6 +108,8 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
setClaudeModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
permissionMode,
setPermissionMode,
pendingPermissionRequests,

View File

@@ -145,6 +145,7 @@ export function useChatRealtimeHandlers({
'claude-error',
'cursor-error',
'codex-error',
'gemini-error',
]);
const isClaudeSystemInit =
@@ -162,8 +163,8 @@ export function useChatRealtimeHandlers({
const systemInitSessionId = isClaudeSystemInit
? structuredMessageData?.session_id
: isCursorSystemInit
? rawStructuredData?.session_id
: null;
? rawStructuredData?.session_id
: null;
const activeViewSessionId =
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
@@ -176,7 +177,8 @@ export function useChatRealtimeHandlers({
!pendingViewSessionRef.current.sessionId &&
(latestMessage.type === 'claude-error' ||
latestMessage.type === 'cursor-error' ||
latestMessage.type === 'codex-error');
latestMessage.type === 'codex-error' ||
latestMessage.type === 'gemini-error');
const handleBackgroundLifecycle = (sessionId?: string) => {
if (!sessionId) {
@@ -225,12 +227,6 @@ export function useChatRealtimeHandlers({
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
console.log(
'Skipping message for different session:',
latestMessage.sessionId,
'current:',
activeViewSessionId,
);
return;
}
}
@@ -297,11 +293,6 @@ export function useChatRealtimeHandlers({
structuredMessageData.session_id !== currentSessionId &&
isSystemInitForView
) {
console.log('Claude CLI session duplication detected:', {
originalSession: currentSessionId,
newSession: structuredMessageData.session_id,
});
setIsSystemSessionChange(true);
onNavigateToSession?.(structuredMessageData.session_id);
return;
@@ -314,10 +305,6 @@ export function useChatRealtimeHandlers({
!currentSessionId &&
isSystemInitForView
) {
console.log('New session init detected:', {
newSession: structuredMessageData.session_id,
});
setIsSystemSessionChange(true);
onNavigateToSession?.(structuredMessageData.session_id);
return;
@@ -331,7 +318,6 @@ export function useChatRealtimeHandlers({
structuredMessageData.session_id === currentSessionId &&
isSystemInitForView
) {
console.log('System init message for current session, ignoring');
return;
}
@@ -583,17 +569,12 @@ export function useChatRealtimeHandlers({
}
if (currentSessionId && cursorData.session_id !== currentSessionId) {
console.log('Cursor session switch detected:', {
originalSession: currentSessionId,
newSession: cursorData.session_id,
});
setIsSystemSessionChange(true);
onNavigateToSession?.(cursorData.session_id);
return;
}
if (!currentSessionId) {
console.log('Cursor new session init detected:', { newSession: cursorData.session_id });
setIsSystemSessionChange(true);
onNavigateToSession?.(cursorData.session_id);
return;
@@ -612,9 +593,8 @@ export function useChatRealtimeHandlers({
...previous,
{
type: 'assistant',
content: `Using tool: ${latestMessage.tool} ${
latestMessage.input ? `with ${latestMessage.input}` : ''
}`,
content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ''
}`,
timestamp: new Date(),
isToolUse: true,
toolName: latestMessage.tool,
@@ -897,7 +877,6 @@ export function useChatRealtimeHandlers({
onNavigateToSession?.(codexActualSessionId);
}
sessionStorage.removeItem('pendingSessionId');
console.log('Codex session complete, ID set to:', codexPendingSessionId);
}
if (selectedProject) {
@@ -919,6 +898,91 @@ export function useChatRealtimeHandlers({
]);
break;
case 'gemini-response': {
const geminiData = latestMessage.data;
if (geminiData && geminiData.type === 'message' && typeof geminiData.content === 'string') {
const content = decodeHtmlEntities(geminiData.content);
if (content) {
streamBufferRef.current += streamBufferRef.current ? `\n${content}` : content;
}
if (!geminiData.isPartial) {
// Immediate flush and finalization for the last chunk
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
if (chunk) {
appendStreamingChunk(setChatMessages, chunk, true);
}
finalizeStreamingMessage(setChatMessages);
} else if (!streamTimerRef.current && streamBufferRef.current) {
streamTimerRef.current = window.setTimeout(() => {
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
streamTimerRef.current = null;
if (chunk) {
appendStreamingChunk(setChatMessages, chunk, true);
}
}, 100);
}
}
break;
}
case 'gemini-error':
setIsLoading(false);
setCanAbortSession(false);
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: latestMessage.error || 'An error occurred with Gemini',
timestamp: new Date(),
},
]);
break;
case 'gemini-tool-use':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: latestMessage.toolName,
toolInput: latestMessage.parameters ? JSON.stringify(latestMessage.parameters, null, 2) : '',
toolId: latestMessage.toolId,
toolResult: null,
}
]);
break;
case 'gemini-tool-result':
setChatMessages((previous) =>
previous.map((message) => {
if (message.isToolUse && message.toolId === latestMessage.toolId) {
return {
...message,
toolResult: {
content: latestMessage.output || `Status: ${latestMessage.status}`,
isError: latestMessage.status === 'error',
timestamp: new Date(),
},
};
}
return message;
}),
);
break;
case 'session-aborted': {
const pendingSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;

View File

@@ -64,6 +64,8 @@ function ChatInterface({
setClaudeModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
permissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
@@ -174,6 +176,7 @@ function ChatInterface({
cursorModel,
claudeModel,
codexModel,
geminiModel,
isLoading,
canAbortSession,
tokenBudget,
@@ -251,7 +254,9 @@ function ChatInterface({
? t('messageTypes.cursor')
: provider === 'codex'
? t('messageTypes.codex')
: t('messageTypes.claude');
: provider === 'gemini'
? t('messageTypes.gemini')
: t('messageTypes.claude');
return (
<div className="flex items-center justify-center h-full">
@@ -287,6 +292,8 @@ function ChatInterface({
setCursorModel={setCursorModel}
codexModel={codexModel}
setCodexModel={setCodexModel}
geminiModel={geminiModel}
setGeminiModel={setGeminiModel}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}
@@ -374,8 +381,10 @@ function ChatInterface({
provider === 'cursor'
? t('messageTypes.cursor')
: provider === 'codex'
? t('messageTypes.codex')
: t('messageTypes.claude'),
? t('messageTypes.codex')
: provider === 'gemini'
? t('messageTypes.gemini')
: t('messageTypes.claude'),
})}
isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter}

View File

@@ -16,7 +16,7 @@ export default function AssistantThinkingIndicator({ selectedProvider }: Assista
<SessionProviderLogo provider={selectedProvider} className="w-full h-full" />
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : 'Claude'}
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : selectedProvider === 'gemini' ? 'Gemini' : 'Claude'}
</div>
</div>
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">

View File

@@ -26,6 +26,8 @@ interface ChatMessagesPaneProps {
setCursorModel: (model: string) => void;
codexModel: string;
setCodexModel: (model: string) => void;
geminiModel: string;
setGeminiModel: (model: string) => void;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
@@ -70,6 +72,8 @@ export default function ChatMessagesPane({
setCursorModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
@@ -152,6 +156,8 @@ export default function ChatMessagesPane({
setCursorModel={setCursorModel}
codexModel={codexModel}
setCodexModel={setCodexModel}
geminiModel={geminiModel}
setGeminiModel={setGeminiModel}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}

View File

@@ -60,6 +60,7 @@ export default function ClaudeStatus({
return null;
}
// Note: showThinking only controls the reasoning accordion in messages, not this processing indicator
const actionIndex = Math.floor(elapsedTime / 3) % ACTION_WORDS.length;
const statusText = status?.text || ACTION_WORDS[actionIndex];
const tokens = status?.tokens || fakeTokens;
@@ -101,6 +102,7 @@ export default function ClaudeStatus({
{canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
className="ml-2 sm:ml-3 text-xs bg-red-600 hover:bg-red-700 active:bg-red-800 text-white px-2 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1 sm:gap-1.5 flex-shrink-0 font-medium"
>

View File

@@ -45,10 +45,10 @@ type PermissionGrantState = 'idle' | 'granted' | 'error';
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') ||
(prevMessage.type === 'user') ||
(prevMessage.type === 'tool') ||
(prevMessage.type === 'error'));
((prevMessage.type === 'assistant') ||
(prevMessage.type === 'user') ||
(prevMessage.type === 'tool') ||
(prevMessage.type === 'error'));
const messageRef = React.useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = React.useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
@@ -154,11 +154,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div>
)}
<div className="text-sm font-medium text-gray-900 dark:text-white">
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))}
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : t('messageTypes.claude'))}
</div>
</div>
)}
<div className="w-full">
{message.isToolUse ? (
@@ -188,7 +188,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
subagentState={message.subagentState}
/>
)}
{/* Tool Result Section */}
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
message.toolResult.isError ? (
@@ -222,11 +222,10 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
}
}}
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${
permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default'
: 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70'
}`}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default'
: 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70'
}`}
>
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? t('permissions.added')
@@ -294,7 +293,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
const lines = (message.content || '').split('\n').filter((line) => line.trim());
const questionLine = lines.find((line) => line.includes('?')) || lines[0] || '';
const options: InteractiveOption[] = [];
// Parse the menu options
lines.forEach((line) => {
// Match lines like " 1. Yes" or " 2. No"
@@ -308,31 +307,29 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
});
}
});
return (
<>
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4">
{questionLine}
</p>
{/* Option buttons */}
<div className="space-y-2 mb-4">
{options.map((option) => (
<button
key={option.number}
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
option.isSelected
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700'
} cursor-not-allowed opacity-75`}
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${option.isSelected
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700'
} cursor-not-allowed opacity-75`}
disabled
>
<div className="flex items-center gap-3">
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
option.isSelected
? 'bg-white/20'
: 'bg-amber-100 dark:bg-amber-800/50'
}`}>
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${option.isSelected
? 'bg-white/20'
: 'bg-amber-100 dark:bg-amber-800/50'
}`}>
{option.number}
</span>
<span className="text-sm sm:text-base font-medium flex-1">
@@ -345,7 +342,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</button>
))}
</div>
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
{t('interactive.waiting')}
@@ -399,7 +396,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
// Detect if content is pure JSON (starts with { or [)
const trimmedContent = content.trim();
if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) &&
(trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {
(trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {
try {
const parsed = JSON.parse(trimmedContent);
const formatted = JSON.stringify(parsed, null, 2);
@@ -439,7 +436,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
})()}
</div>
)}
{!isGrouped && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1">
{formattedTime}

View File

@@ -3,7 +3,7 @@ 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 } from '../../../../../shared/modelConstants';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants';
import type { ProjectSession, SessionProvider } from '../../../../types/app';
interface ProviderSelectionEmptyStateProps {
@@ -18,6 +18,8 @@ interface ProviderSelectionEmptyStateProps {
setCursorModel: (model: string) => void;
codexModel: string;
setCodexModel: (model: string) => void;
geminiModel: string;
setGeminiModel: (model: string) => void;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
@@ -58,17 +60,27 @@ const PROVIDERS: ProviderDef[] = [
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) {
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;
}
@@ -84,6 +96,8 @@ export default function ProviderSelectionEmptyState({
setCursorModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
@@ -101,11 +115,12 @@ export default function ProviderSelectionEmptyState({
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);
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel, geminiModel);
/* ── New session — provider picker ── */
if (!selectedSession && !currentSessionId) {
@@ -123,7 +138,7 @@ export default function ProviderSelectionEmptyState({
</div>
{/* Provider cards — horizontal row, equal width */}
<div className="grid grid-cols-3 gap-2 sm:gap-2.5 mb-6">
<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 (
@@ -179,13 +194,14 @@ export default function ProviderSelectionEmptyState({
</div>
<p className="text-center text-sm text-muted-foreground/70">
{provider === 'claude'
? t('providerSelection.readyPrompt.claude', { model: claudeModel })
: provider === 'cursor'
? t('providerSelection.readyPrompt.cursor', { model: cursorModel })
: provider === 'codex'
? t('providerSelection.readyPrompt.codex', { model: codexModel })
: t('providerSelection.readyPrompt.default')}
{
{
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>

View File

@@ -2,6 +2,7 @@ import type { SessionProvider } from '../../types/app';
import ClaudeLogo from './ClaudeLogo';
import CodexLogo from './CodexLogo';
import CursorLogo from './CursorLogo';
import GeminiLogo from '../GeminiLogo';
type SessionProviderLogoProps = {
provider?: SessionProvider | string | null;
@@ -20,5 +21,9 @@ export default function SessionProviderLogo({
return <CodexLogo className={className} />;
}
if (provider === 'gemini') {
return <GeminiLogo className={className} />;
}
return <ClaudeLogo className={className} />;
}

View File

@@ -91,4 +91,5 @@ export const AUTH_STATUS_ENDPOINTS: Record<AgentProvider, string> = {
claude: '/api/cli/claude/status',
cursor: '/api/cli/cursor/status',
codex: '/api/cli/codex/status',
gemini: '/api/cli/gemini/status',
};

View File

@@ -16,6 +16,7 @@ import type {
CodexMcpFormState,
CodexPermissionMode,
CursorPermissionsState,
GeminiPermissionMode,
McpServer,
McpToolsResult,
McpTestResult,
@@ -204,6 +205,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
createEmptyCursorPermissions()
));
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
const [cursorMcpServers, setCursorMcpServers] = useState<McpServer[]>([]);
@@ -224,6 +226,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
const [claudeAuthStatus, setClaudeAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
const [cursorAuthStatus, setCursorAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
const [codexAuthStatus, setCodexAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
const [geminiAuthStatus, setGeminiAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
const setAuthStatusByProvider = useCallback((provider: AgentProvider, status: AuthStatus) => {
if (provider === 'claude') {
@@ -236,6 +239,11 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
return;
}
if (provider === 'gemini') {
setGeminiAuthStatus(status);
return;
}
setCodexAuthStatus(status);
}, []);
@@ -655,6 +663,12 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
);
setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode));
const savedGeminiSettings = parseJson<{ permissionMode?: GeminiPermissionMode }>(
localStorage.getItem('gemini-settings'),
{},
);
setGeminiPermissionMode(savedGeminiSettings.permissionMode || 'default');
await Promise.all([
fetchMcpServers(),
fetchCursorMcpServers(),
@@ -710,6 +724,11 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
lastUpdated: now,
}));
localStorage.setItem('gemini-settings', JSON.stringify({
permissionMode: geminiPermissionMode,
lastUpdated: now,
}));
setSaveStatus('success');
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
@@ -771,6 +790,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
void checkAuthStatus('claude');
void checkAuthStatus('cursor');
void checkAuthStatus('codex');
void checkAuthStatus('gemini');
}, [checkAuthStatus, initialTab, isOpen, loadSettings]);
useEffect(() => {
@@ -830,6 +850,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
claudeAuthStatus,
cursorAuthStatus,
codexAuthStatus,
geminiAuthStatus,
geminiPermissionMode,
setGeminiPermissionMode,
openLoginForProvider,
showLoginModal,
setShowLoginModal,

View File

@@ -1,11 +1,12 @@
import type { Dispatch, SetStateAction } from 'react';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks';
export type AgentProvider = 'claude' | 'cursor' | 'codex';
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date';
export type SaveStatus = 'success' | 'error' | null;
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
export type GeminiPermissionMode = 'default' | 'auto_edit' | 'yolo';
export type McpImportMode = 'form' | 'json';
export type McpScope = 'user' | 'local';
export type McpTransportType = 'stdio' | 'sse' | 'http';

View File

@@ -65,6 +65,9 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
claudeAuthStatus,
cursorAuthStatus,
codexAuthStatus,
geminiAuthStatus,
geminiPermissionMode,
setGeminiPermissionMode,
openLoginForProvider,
showLoginModal,
setShowLoginModal,
@@ -86,10 +89,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
const isAuthenticated = loginProvider === 'claude'
? claudeAuthStatus.authenticated
: loginProvider === 'cursor'
? cursorAuthStatus.authenticated
: loginProvider === 'codex'
? codexAuthStatus.authenticated
: false;
? cursorAuthStatus.authenticated
: loginProvider === 'codex'
? codexAuthStatus.authenticated
: false;
return (
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[9999] md:p-4 bg-background/95">
@@ -133,15 +136,19 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
claudeAuthStatus={claudeAuthStatus}
cursorAuthStatus={cursorAuthStatus}
codexAuthStatus={codexAuthStatus}
geminiAuthStatus={geminiAuthStatus}
onClaudeLogin={() => openLoginForProvider('claude')}
onCursorLogin={() => openLoginForProvider('cursor')}
onCodexLogin={() => openLoginForProvider('codex')}
onGeminiLogin={() => openLoginForProvider('gemini')}
claudePermissions={claudePermissions}
onClaudePermissionsChange={setClaudePermissions}
cursorPermissions={cursorPermissions}
onCursorPermissionsChange={setCursorPermissions}
codexPermissionMode={codexPermissionMode}
onCodexPermissionModeChange={setCodexPermissionMode}
geminiPermissionMode={geminiPermissionMode}
onGeminiPermissionModeChange={setGeminiPermissionMode}
mcpServers={mcpServers}
cursorMcpServers={cursorMcpServers}
codexMcpServers={codexMcpServers}

View File

@@ -12,7 +12,7 @@ type AgentListItemProps = {
type AgentConfig = {
name: string;
color: 'blue' | 'purple' | 'gray';
color: 'blue' | 'purple' | 'gray' | 'indigo';
};
const agentConfig: Record<AgentProvider, AgentConfig> = {
@@ -28,6 +28,10 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
name: 'Codex',
color: 'gray',
},
gemini: {
name: 'Gemini',
color: 'indigo',
}
};
const colorClasses = {
@@ -49,6 +53,12 @@ const colorClasses = {
bg: 'bg-gray-100 dark:bg-gray-800/50',
dot: 'bg-gray-700 dark:bg-gray-300',
},
indigo: {
border: 'border-l-indigo-500 md:border-l-indigo-500',
borderBottom: 'border-b-indigo-500',
bg: 'bg-indigo-50 dark:bg-indigo-900/20',
dot: 'bg-indigo-500',
},
} as const;
export default function AgentListItem({
@@ -66,11 +76,10 @@ export default function AgentListItem({
return (
<button
onClick={onClick}
className={`flex-1 text-center py-3 px-2 border-b-2 transition-colors ${
isSelected
? `${colors.borderBottom} ${colors.bg}`
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
className={`flex-1 text-center py-3 px-2 border-b-2 transition-colors ${isSelected
? `${colors.borderBottom} ${colors.bg}`
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
>
<div className="flex flex-col items-center gap-1">
<SessionProviderLogo provider={agentId} className="w-5 h-5" />
@@ -86,11 +95,10 @@ export default function AgentListItem({
return (
<button
onClick={onClick}
className={`w-full text-left p-3 border-l-4 transition-colors ${
isSelected
? `${colors.border} ${colors.bg}`
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
className={`w-full text-left p-3 border-l-4 transition-colors ${isSelected
? `${colors.border} ${colors.bg}`
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
>
<div className="flex items-center gap-2 mb-1">
<SessionProviderLogo provider={agentId} className="w-4 h-4" />

View File

@@ -9,15 +9,19 @@ export default function AgentsSettingsTab({
claudeAuthStatus,
cursorAuthStatus,
codexAuthStatus,
geminiAuthStatus,
onClaudeLogin,
onCursorLogin,
onCodexLogin,
onGeminiLogin,
claudePermissions,
onClaudePermissionsChange,
cursorPermissions,
onCursorPermissionsChange,
codexPermissionMode,
onCodexPermissionModeChange,
geminiPermissionMode,
onGeminiPermissionModeChange,
mcpServers,
cursorMcpServers,
codexMcpServers,
@@ -48,13 +52,19 @@ export default function AgentsSettingsTab({
authStatus: codexAuthStatus,
onLogin: onCodexLogin,
},
gemini: {
authStatus: geminiAuthStatus,
onLogin: onGeminiLogin,
},
}), [
claudeAuthStatus,
codexAuthStatus,
cursorAuthStatus,
geminiAuthStatus,
onClaudeLogin,
onCodexLogin,
onCursorLogin,
onGeminiLogin,
]);
return (
@@ -81,6 +91,8 @@ export default function AgentsSettingsTab({
onCursorPermissionsChange={onCursorPermissionsChange}
codexPermissionMode={codexPermissionMode}
onCodexPermissionModeChange={onCodexPermissionModeChange}
geminiPermissionMode={geminiPermissionMode}
onGeminiPermissionModeChange={onGeminiPermissionModeChange}
mcpServers={mcpServers}
cursorMcpServers={cursorMcpServers}
codexMcpServers={codexMcpServers}

View File

@@ -18,6 +18,7 @@ type AgentVisualConfig = {
textClass: string;
subtextClass: string;
buttonClass: string;
description?: string;
};
const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
@@ -45,6 +46,15 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
subtextClass: 'text-gray-700 dark:text-gray-300',
buttonClass: 'bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
},
gemini: {
name: 'Gemini',
description: 'Google Gemini AI assistant',
bgClass: 'bg-indigo-50 dark:bg-indigo-900/20',
borderClass: 'border-indigo-200 dark:border-indigo-800',
textClass: 'text-indigo-900 dark:text-indigo-100',
subtextClass: 'text-indigo-700 dark:text-indigo-300',
buttonClass: 'bg-indigo-600 hover:bg-indigo-700',
},
};
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {

View File

@@ -3,7 +3,7 @@ import { AlertTriangle, Plus, Shield, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../../../../../ui/button';
import { Input } from '../../../../../../ui/input';
import type { CodexPermissionMode } from '../../../../../types/types';
import type { CodexPermissionMode, GeminiPermissionMode } from '../../../../../types/types';
const COMMON_CLAUDE_TOOLS = [
'Bash(git log:*)',
@@ -489,11 +489,10 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
<p className="text-sm text-muted-foreground">{t('permissions.codex.description')}</p>
<div
className={`border rounded-lg p-4 cursor-pointer transition-all ${
permissionMode === 'default'
? 'bg-gray-100 dark:bg-gray-800 border-gray-400 dark:border-gray-500'
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'default'
? 'bg-gray-100 dark:bg-gray-800 border-gray-400 dark:border-gray-500'
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={() => onPermissionModeChange('default')}
>
<label className="flex items-start gap-3 cursor-pointer">
@@ -514,11 +513,10 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
</div>
<div
className={`border rounded-lg p-4 cursor-pointer transition-all ${
permissionMode === 'acceptEdits'
? 'bg-green-50 dark:bg-green-900/20 border-green-400 dark:border-green-600'
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'acceptEdits'
? 'bg-green-50 dark:bg-green-900/20 border-green-400 dark:border-green-600'
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={() => onPermissionModeChange('acceptEdits')}
>
<label className="flex items-start gap-3 cursor-pointer">
@@ -539,11 +537,10 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
</div>
<div
className={`border rounded-lg p-4 cursor-pointer transition-all ${
permissionMode === 'bypassPermissions'
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-400 dark:border-orange-600'
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'bypassPermissions'
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-400 dark:border-orange-600'
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={() => onPermissionModeChange('bypassPermissions')}
>
<label className="flex items-start gap-3 cursor-pointer">
@@ -582,7 +579,111 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
);
}
type PermissionsContentProps = ClaudePermissionsProps | CursorPermissionsProps | CodexPermissionsProps;
type GeminiPermissionsProps = {
agent: 'gemini';
permissionMode: GeminiPermissionMode;
onPermissionModeChange: (value: GeminiPermissionMode) => void;
};
// Gemini Permissions
function GeminiPermissions({ permissionMode, onPermissionModeChange }: Omit<GeminiPermissionsProps, 'agent'>) {
const { t } = useTranslation(['settings', 'chat']);
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-green-500" />
<h3 className="text-lg font-medium text-foreground">
{t('gemini.permissionMode')}
</h3>
</div>
<p className="text-sm text-muted-foreground">
{t('gemini.description')}
</p>
{/* Default Mode */}
<div
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'default'
? 'bg-gray-100 dark:bg-gray-800 border-gray-400 dark:border-gray-500'
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={() => onPermissionModeChange('default')}
>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="geminiPermissionMode"
checked={permissionMode === 'default'}
onChange={() => onPermissionModeChange('default')}
className="mt-1 w-4 h-4 text-green-600"
/>
<div>
<div className="font-medium text-foreground">{t('gemini.modes.default.title')}</div>
<div className="text-sm text-muted-foreground">
{t('gemini.modes.default.description')}
</div>
</div>
</label>
</div>
{/* Auto Edit Mode */}
<div
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'auto_edit'
? 'bg-green-50 dark:bg-green-900/20 border-green-400 dark:border-green-600'
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={() => onPermissionModeChange('auto_edit')}
>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="geminiPermissionMode"
checked={permissionMode === 'auto_edit'}
onChange={() => onPermissionModeChange('auto_edit')}
className="mt-1 w-4 h-4 text-green-600"
/>
<div>
<div className="font-medium text-green-900 dark:text-green-100">{t('gemini.modes.autoEdit.title')}</div>
<div className="text-sm text-green-700 dark:text-green-300">
{t('gemini.modes.autoEdit.description')}
</div>
</div>
</label>
</div>
{/* YOLO Mode */}
<div
className={`border rounded-lg p-4 cursor-pointer transition-all ${permissionMode === 'yolo'
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-400 dark:border-orange-600'
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={() => onPermissionModeChange('yolo')}
>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="geminiPermissionMode"
checked={permissionMode === 'yolo'}
onChange={() => onPermissionModeChange('yolo')}
className="mt-1 w-4 h-4 text-orange-600"
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100 flex items-center gap-2">
{t('gemini.modes.yolo.title')}
<AlertTriangle className="w-4 h-4" />
</div>
<div className="text-sm text-orange-700 dark:text-orange-300">
{t('gemini.modes.yolo.description')}
</div>
</div>
</label>
</div>
</div>
</div>
);
}
type PermissionsContentProps = ClaudePermissionsProps | CursorPermissionsProps | CodexPermissionsProps | GeminiPermissionsProps;
export default function PermissionsContent(props: PermissionsContentProps) {
if (props.agent === 'claude') {
@@ -593,5 +694,9 @@ export default function PermissionsContent(props: PermissionsContentProps) {
return <CursorPermissions {...props} />;
}
if (props.agent === 'gemini') {
return <GeminiPermissions {...props} />;
}
return <CodexPermissions {...props} />;
}

View File

@@ -3,8 +3,9 @@ import type {
AuthStatus,
AgentCategory,
ClaudePermissionsState,
CodexPermissionMode,
CursorPermissionsState,
CodexPermissionMode,
GeminiPermissionMode,
McpServer,
McpToolsResult,
McpTestResult,
@@ -21,15 +22,19 @@ export type AgentsSettingsTabProps = {
claudeAuthStatus: AuthStatus;
cursorAuthStatus: AuthStatus;
codexAuthStatus: AuthStatus;
geminiAuthStatus: AuthStatus;
onClaudeLogin: () => void;
onCursorLogin: () => void;
onCodexLogin: () => void;
onGeminiLogin: () => void;
claudePermissions: ClaudePermissionsState;
onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
cursorPermissions: CursorPermissionsState;
onCursorPermissionsChange: (value: CursorPermissionsState) => void;
codexPermissionMode: CodexPermissionMode;
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
geminiPermissionMode: GeminiPermissionMode;
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
mcpServers: McpServer[];
cursorMcpServers: McpServer[];
codexMcpServers: McpServer[];
@@ -66,6 +71,8 @@ export type AgentCategoryContentSectionProps = {
onCursorPermissionsChange: (value: CursorPermissionsState) => void;
codexPermissionMode: CodexPermissionMode;
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
geminiPermissionMode: GeminiPermissionMode;
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
mcpServers: McpServer[];
cursorMcpServers: McpServer[];
codexMcpServers: McpServer[];

View File

@@ -277,10 +277,14 @@ export function useSidebarController({
setSessionDeleteConfirmation(null);
try {
const response =
provider === 'codex'
? await api.deleteCodexSession(sessionId)
: await api.deleteSession(projectName, sessionId);
let response;
if (provider === 'codex') {
response = await api.deleteCodexSession(sessionId);
} else if (provider === 'gemini') {
response = await api.deleteGeminiSession(sessionId);
} else {
response = await api.deleteSession(projectName, sessionId);
}
if (response.ok) {
onSessionDelete?.(sessionId);

View File

@@ -44,6 +44,7 @@ export type SidebarProps = {
export type SessionViewModel = {
isCursorSession: boolean;
isCodexSession: boolean;
isGeminiSession: boolean;
isActive: boolean;
sessionName: string;
sessionTime: string;

View File

@@ -48,7 +48,7 @@ export const getSessionDate = (session: SessionWithProvider): Date => {
return new Date(session.createdAt || session.lastActivity || 0);
}
return new Date(session.lastActivity || 0);
return new Date(session.lastActivity || session.createdAt || 0);
};
export const getSessionName = (session: SessionWithProvider, t: TFunction): string => {
@@ -60,6 +60,10 @@ export const getSessionName = (session: SessionWithProvider, t: TFunction): stri
return session.summary || session.name || t('projects.codexSession');
}
if (session.__provider === 'gemini') {
return session.summary || session.name || t('projects.newSession');
}
return session.summary || t('projects.newSession');
};
@@ -72,7 +76,7 @@ export const getSessionTime = (session: SessionWithProvider): string => {
return String(session.createdAt || session.lastActivity || '');
}
return String(session.lastActivity || '');
return String(session.lastActivity || session.createdAt || '');
};
export const createSessionViewModel = (
@@ -86,6 +90,7 @@ export const createSessionViewModel = (
return {
isCursorSession: session.__provider === 'cursor',
isCodexSession: session.__provider === 'codex',
isGeminiSession: session.__provider === 'gemini',
isActive: diffInMinutes < 10,
sessionName: getSessionName(session, t),
sessionTime: getSessionTime(session),
@@ -112,7 +117,12 @@ export const getAllSessions = (
__provider: 'codex' as const,
}));
return [...claudeSessions, ...cursorSessions, ...codexSessions].sort(
const geminiSessions = (project.geminiSessions || []).map((session) => ({
...session,
__provider: 'gemini' as const,
}));
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions].sort(
(a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),
);
};
@@ -205,8 +215,8 @@ export const normalizeProjectForSettings = (project: Project): SettingsProject =
typeof project.fullPath === 'string' && project.fullPath.length > 0
? project.fullPath
: typeof project.path === 'string'
? project.path
: '';
? project.path
: '';
return {
name: project.name,

View File

@@ -40,7 +40,8 @@ const projectsHaveChanges = (
nextProject.displayName !== prevProject.displayName ||
nextProject.fullPath !== prevProject.fullPath ||
serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) ||
serialize(nextProject.sessions) !== serialize(prevProject.sessions);
serialize(nextProject.sessions) !== serialize(prevProject.sessions) ||
serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster);
if (baseChanged) {
return true;
@@ -52,7 +53,8 @@ const projectsHaveChanges = (
return (
serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) ||
serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions)
serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) ||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions)
);
});
};
@@ -62,6 +64,7 @@ const getProjectSessions = (project: Project): ProjectSession[] => {
...(project.sessions ?? []),
...(project.codexSessions ?? []),
...(project.cursorSessions ?? []),
...(project.geminiSessions ?? []),
];
};
@@ -333,6 +336,21 @@ export function useProjectsState({
}
return;
}
const geminiSession = project.geminiSessions?.find((session) => session.id === sessionId);
if (geminiSession) {
const shouldUpdateProject = selectedProject?.name !== project.name;
const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'gemini';
if (shouldUpdateProject) {
setSelectedProject(project);
}
if (shouldUpdateSession) {
setSelectedSession({ ...geminiSession, __provider: 'gemini' });
}
return;
}
}
}, [sessionId, projects, selectedProject?.name, selectedSession?.id, selectedSession?.__provider]);

View File

@@ -10,7 +10,8 @@
"tool": "Tool",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex"
"codex": "Codex",
"gemini": "Gemini"
},
"tools": {
"settings": "Tool Settings",
@@ -93,6 +94,24 @@
},
"technicalDetails": "Technical details"
},
"gemini": {
"permissionMode": "Gemini Permission Mode",
"description": "Control how Gemini CLI handles operation approvals.",
"modes": {
"default": {
"title": "Standard (Ask for Approval)",
"description": "Gemini will prompt for approval before executing commands, writing files, and fetching web resources."
},
"autoEdit": {
"title": "Auto Edit (Skip File Approvals)",
"description": "Gemini will automatically approve file edits and web fetches, but will still prompt for shell commands."
},
"yolo": {
"title": "YOLO (Bypass All Permissions)",
"description": "Gemini will execute all operations without asking for approval. Exercise caution."
}
}
},
"input": {
"placeholder": "Type / for commands, @ for files, or ask {{provider}} anything...",
"placeholderDefault": "Type your message...",
@@ -153,12 +172,14 @@
"providerInfo": {
"anthropic": "by Anthropic",
"openai": "by OpenAI",
"cursorEditor": "AI Code Editor"
"cursorEditor": "AI Code Editor",
"google": "by Google"
},
"readyPrompt": {
"claude": "Ready to use Claude with {{model}}. Start typing your message below.",
"cursor": "Ready to use Cursor with {{model}}. Start typing your message below.",
"codex": "Ready to use Codex with {{model}}. Start typing your message below.",
"gemini": "Ready to use Gemini with {{model}}. Start typing your message below.",
"default": "Select a provider above to begin"
}
},
@@ -214,4 +235,4 @@
"tasks": {
"nextTaskPrompt": "Start the next task"
}
}
}

View File

@@ -10,7 +10,8 @@
"tool": "도구",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex"
"codex": "Codex",
"gemini": "Gemini"
},
"tools": {
"settings": "도구 설정",
@@ -151,15 +152,17 @@
"description": "새 대화를 시작할 프로바이더를 선택하세요",
"selectModel": "모델 선택",
"providerInfo": {
"anthropic": "by Anthropic",
"openai": "by OpenAI",
"cursorEditor": "AI 코드 에디터"
"anthropic": "Anthropic 제공",
"openai": "OpenAI 제공",
"cursorEditor": "AI 코드 에디터",
"google": "Google 제공"
},
"readyPrompt": {
"claude": "{{model}}로 Claude를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
"cursor": "{{model}}로 Cursor를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
"codex": "{{model}}로 Codex를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
"default": "시작하려면 위에서 프로바이더를 선택하세요"
"claude": "{{model}} 모델로 Claude를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
"cursor": "{{model}} 모델로 Cursor를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
"codex": "{{model}} 모델로 Codex를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
"gemini": "{{model}} 모델로 Gemini를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
"default": "시작하려면 위에서 제공자를 선택하세요"
}
},
"session": {
@@ -214,4 +217,4 @@
"tasks": {
"nextTaskPrompt": "다음 작업 시작"
}
}
}

View File

@@ -10,7 +10,8 @@
"tool": "工具",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex"
"codex": "Codex",
"gemini": "Gemini"
},
"tools": {
"settings": "工具设置",
@@ -151,15 +152,17 @@
"description": "选择一个供应商以开始新对话",
"selectModel": "选择模型",
"providerInfo": {
"anthropic": "Anthropic",
"openai": "OpenAI",
"cursorEditor": "AI 代码编辑器"
"anthropic": "Anthropic 提供",
"openai": "OpenAI 提供",
"cursorEditor": "AI 代码编辑器",
"google": "由 Google 提供"
},
"readyPrompt": {
"claude": "准备好使用 Claude {{model}}在下方输入您的消息。",
"cursor": "准备好使用 Cursor {{model}}在下方输入您的消息。",
"codex": "准备好使用 Codex {{model}}在下方输入您的消息。",
"default": "请在上方选择一个供应商以开始"
"claude": "准备好使用带有 {{model}} 的 Claude。请在下方开始输入您的消息。",
"cursor": "准备好使用带有 {{model}} 的 Cursor。请在下方开始输入您的消息。",
"codex": "准备好使用带有 {{model}} 的 Codex。请在下方开始输入您的消息。",
"gemini": "准备好使用带有 {{model}} 的 Gemini。请在下方开始输入您的消息。",
"default": "请在上方选择一个提供者以开始"
}
},
"session": {
@@ -214,4 +217,4 @@
"tasks": {
"nextTaskPrompt": "开始下一个任务"
}
}
}

View File

@@ -1,4 +1,4 @@
export type SessionProvider = 'claude' | 'cursor' | 'codex';
export type SessionProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview';
@@ -38,6 +38,7 @@ export interface Project {
sessions?: ProjectSession[];
cursorSessions?: ProjectSession[];
codexSessions?: ProjectSession[];
geminiSessions?: ProjectSession[];
sessionMeta?: ProjectSessionMeta;
taskmaster?: ProjectTaskmasterInfo;
[key: string]: unknown;
@@ -66,4 +67,4 @@ export interface LoadingProgressMessage extends LoadingProgress {
export type AppSocketMessage =
| LoadingProgressMessage
| ProjectsUpdatedMessage
| { type?: string; [key: string]: unknown };
| { type?: string;[key: string]: unknown };

View File

@@ -46,7 +46,7 @@ export const api = {
// Protected endpoints
// config endpoint removed - no longer needed (frontend uses window.location)
projects: () => authenticatedFetch('/api/projects'),
sessions: (projectName, limit = 5, offset = 0) =>
sessions: (projectName, limit = 5, offset = 0) =>
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
sessionMessages: (projectName, sessionId, limit = null, offset = 0, provider = 'claude') => {
const params = new URLSearchParams();
@@ -56,12 +56,13 @@ export const api = {
}
const queryString = params.toString();
// Route to the correct endpoint based on provider
let url;
if (provider === 'codex') {
url = `/api/codex/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
} else if (provider === 'cursor') {
url = `/api/cursor/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
} else if (provider === 'gemini') {
url = `/api/gemini/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
} else {
url = `/api/projects/${projectName}/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
}
@@ -80,6 +81,10 @@ export const api = {
authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
method: 'DELETE',
}),
deleteGeminiSession: (sessionId) =>
authenticatedFetch(`/api/gemini/sessions/${sessionId}`, {
method: 'DELETE',
}),
deleteProject: (projectName, force = false) =>
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
method: 'DELETE',
@@ -113,18 +118,18 @@ export const api = {
// TaskMaster endpoints
taskmaster: {
// Initialize TaskMaster in a project
init: (projectName) =>
init: (projectName) =>
authenticatedFetch(`/api/taskmaster/init/${projectName}`, {
method: 'POST',
}),
// Add a new task
addTask: (projectName, { prompt, title, description, priority, dependencies }) =>
authenticatedFetch(`/api/taskmaster/add-task/${projectName}`, {
method: 'POST',
body: JSON.stringify({ prompt, title, description, priority, dependencies }),
}),
// Parse PRD to generate tasks
parsePRD: (projectName, { fileName, numTasks, append }) =>
authenticatedFetch(`/api/taskmaster/parse-prd/${projectName}`, {
@@ -150,7 +155,7 @@ export const api = {
body: JSON.stringify(updates),
}),
},
// Browse filesystem for project suggestions
browseFilesystem: (dirPath = null) => {
const params = new URLSearchParams();