mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-14 10:27:24 +00:00
* 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>
234 lines
6.6 KiB
TypeScript
234 lines
6.6 KiB
TypeScript
import type { TFunction } from 'i18next';
|
|
import type { Project } from '../../../types/app';
|
|
import type {
|
|
AdditionalSessionsByProject,
|
|
ProjectSortOrder,
|
|
SettingsProject,
|
|
SessionViewModel,
|
|
SessionWithProvider,
|
|
} from '../types/types';
|
|
|
|
export const readProjectSortOrder = (): ProjectSortOrder => {
|
|
try {
|
|
const rawSettings = localStorage.getItem('claude-settings');
|
|
if (!rawSettings) {
|
|
return 'name';
|
|
}
|
|
|
|
const settings = JSON.parse(rawSettings) as { projectSortOrder?: ProjectSortOrder };
|
|
return settings.projectSortOrder === 'date' ? 'date' : 'name';
|
|
} catch {
|
|
return 'name';
|
|
}
|
|
};
|
|
|
|
export const loadStarredProjects = (): Set<string> => {
|
|
try {
|
|
const saved = localStorage.getItem('starredProjects');
|
|
return saved ? new Set<string>(JSON.parse(saved)) : new Set<string>();
|
|
} catch {
|
|
return new Set<string>();
|
|
}
|
|
};
|
|
|
|
export const persistStarredProjects = (starredProjects: Set<string>) => {
|
|
try {
|
|
localStorage.setItem('starredProjects', JSON.stringify([...starredProjects]));
|
|
} catch {
|
|
// Keep UI responsive even if storage fails.
|
|
}
|
|
};
|
|
|
|
export const getSessionDate = (session: SessionWithProvider): Date => {
|
|
if (session.__provider === 'cursor') {
|
|
return new Date(session.createdAt || 0);
|
|
}
|
|
|
|
if (session.__provider === 'codex') {
|
|
return new Date(session.createdAt || session.lastActivity || 0);
|
|
}
|
|
|
|
return new Date(session.lastActivity || session.createdAt || 0);
|
|
};
|
|
|
|
export const getSessionName = (session: SessionWithProvider, t: TFunction): string => {
|
|
if (session.__provider === 'cursor') {
|
|
return session.name || t('projects.untitledSession');
|
|
}
|
|
|
|
if (session.__provider === 'codex') {
|
|
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');
|
|
};
|
|
|
|
export const getSessionTime = (session: SessionWithProvider): string => {
|
|
if (session.__provider === 'cursor') {
|
|
return String(session.createdAt || '');
|
|
}
|
|
|
|
if (session.__provider === 'codex') {
|
|
return String(session.createdAt || session.lastActivity || '');
|
|
}
|
|
|
|
return String(session.lastActivity || session.createdAt || '');
|
|
};
|
|
|
|
export const createSessionViewModel = (
|
|
session: SessionWithProvider,
|
|
currentTime: Date,
|
|
t: TFunction,
|
|
): SessionViewModel => {
|
|
const sessionDate = getSessionDate(session);
|
|
const diffInMinutes = Math.floor((currentTime.getTime() - sessionDate.getTime()) / (1000 * 60));
|
|
|
|
return {
|
|
isCursorSession: session.__provider === 'cursor',
|
|
isCodexSession: session.__provider === 'codex',
|
|
isGeminiSession: session.__provider === 'gemini',
|
|
isActive: diffInMinutes < 10,
|
|
sessionName: getSessionName(session, t),
|
|
sessionTime: getSessionTime(session),
|
|
messageCount: Number(session.messageCount || 0),
|
|
};
|
|
};
|
|
|
|
export const getAllSessions = (
|
|
project: Project,
|
|
additionalSessions: AdditionalSessionsByProject,
|
|
): SessionWithProvider[] => {
|
|
const claudeSessions = [
|
|
...(project.sessions || []),
|
|
...(additionalSessions[project.name] || []),
|
|
].map((session) => ({ ...session, __provider: 'claude' as const }));
|
|
|
|
const cursorSessions = (project.cursorSessions || []).map((session) => ({
|
|
...session,
|
|
__provider: 'cursor' as const,
|
|
}));
|
|
|
|
const codexSessions = (project.codexSessions || []).map((session) => ({
|
|
...session,
|
|
__provider: 'codex' as const,
|
|
}));
|
|
|
|
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(),
|
|
);
|
|
};
|
|
|
|
export const getProjectLastActivity = (
|
|
project: Project,
|
|
additionalSessions: AdditionalSessionsByProject,
|
|
): Date => {
|
|
const sessions = getAllSessions(project, additionalSessions);
|
|
if (sessions.length === 0) {
|
|
return new Date(0);
|
|
}
|
|
|
|
return sessions.reduce((latest, session) => {
|
|
const sessionDate = getSessionDate(session);
|
|
return sessionDate > latest ? sessionDate : latest;
|
|
}, new Date(0));
|
|
};
|
|
|
|
export const sortProjects = (
|
|
projects: Project[],
|
|
projectSortOrder: ProjectSortOrder,
|
|
starredProjects: Set<string>,
|
|
additionalSessions: AdditionalSessionsByProject,
|
|
): Project[] => {
|
|
const byName = [...projects];
|
|
|
|
byName.sort((projectA, projectB) => {
|
|
const aStarred = starredProjects.has(projectA.name);
|
|
const bStarred = starredProjects.has(projectB.name);
|
|
|
|
if (aStarred && !bStarred) {
|
|
return -1;
|
|
}
|
|
|
|
if (!aStarred && bStarred) {
|
|
return 1;
|
|
}
|
|
|
|
if (projectSortOrder === 'date') {
|
|
return (
|
|
getProjectLastActivity(projectB, additionalSessions).getTime() -
|
|
getProjectLastActivity(projectA, additionalSessions).getTime()
|
|
);
|
|
}
|
|
|
|
return (projectA.displayName || projectA.name).localeCompare(projectB.displayName || projectB.name);
|
|
});
|
|
|
|
return byName;
|
|
};
|
|
|
|
export const filterProjects = (projects: Project[], searchFilter: string): Project[] => {
|
|
const normalizedSearch = searchFilter.trim().toLowerCase();
|
|
if (!normalizedSearch) {
|
|
return projects;
|
|
}
|
|
|
|
return projects.filter((project) => {
|
|
const displayName = (project.displayName || project.name).toLowerCase();
|
|
const projectName = project.name.toLowerCase();
|
|
return displayName.includes(normalizedSearch) || projectName.includes(normalizedSearch);
|
|
});
|
|
};
|
|
|
|
export const getTaskIndicatorStatus = (
|
|
project: Project,
|
|
mcpServerStatus: { hasMCPServer?: boolean; isConfigured?: boolean } | null,
|
|
) => {
|
|
const projectConfigured = Boolean(project.taskmaster?.hasTaskmaster);
|
|
const mcpConfigured = Boolean(mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured);
|
|
|
|
if (projectConfigured && mcpConfigured) {
|
|
return 'fully-configured';
|
|
}
|
|
|
|
if (projectConfigured) {
|
|
return 'taskmaster-only';
|
|
}
|
|
|
|
if (mcpConfigured) {
|
|
return 'mcp-only';
|
|
}
|
|
|
|
return 'not-configured';
|
|
};
|
|
|
|
export const normalizeProjectForSettings = (project: Project): SettingsProject => {
|
|
const fallbackPath =
|
|
typeof project.fullPath === 'string' && project.fullPath.length > 0
|
|
? project.fullPath
|
|
: typeof project.path === 'string'
|
|
? project.path
|
|
: '';
|
|
|
|
return {
|
|
name: project.name,
|
|
displayName:
|
|
typeof project.displayName === 'string' && project.displayName.trim().length > 0
|
|
? project.displayName
|
|
: project.name,
|
|
fullPath: fallbackPath,
|
|
path:
|
|
typeof project.path === 'string' && project.path.length > 0
|
|
? project.path
|
|
: fallbackPath,
|
|
};
|
|
};
|