mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-01 12:07:37 +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>
401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
import React, { useCallback, useEffect, useRef } from 'react';
|
|
import QuickSettingsPanel from '../../QuickSettingsPanel';
|
|
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
|
import { useTranslation } from 'react-i18next';
|
|
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
|
|
import ChatComposer from './subcomponents/ChatComposer';
|
|
import type { ChatInterfaceProps } from '../types/types';
|
|
import { useChatProviderState } from '../hooks/useChatProviderState';
|
|
import { useChatSessionState } from '../hooks/useChatSessionState';
|
|
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
|
|
import { useChatComposerState } from '../hooks/useChatComposerState';
|
|
import type { Provider } from '../types/types';
|
|
|
|
type PendingViewSession = {
|
|
sessionId: string | null;
|
|
startedAt: number;
|
|
};
|
|
|
|
function ChatInterface({
|
|
selectedProject,
|
|
selectedSession,
|
|
ws,
|
|
sendMessage,
|
|
latestMessage,
|
|
onFileOpen,
|
|
onInputFocusChange,
|
|
onSessionActive,
|
|
onSessionInactive,
|
|
onSessionProcessing,
|
|
onSessionNotProcessing,
|
|
processingSessions,
|
|
onReplaceTemporarySession,
|
|
onNavigateToSession,
|
|
onShowSettings,
|
|
autoExpandTools,
|
|
showRawParameters,
|
|
showThinking,
|
|
autoScrollToBottom,
|
|
sendByCtrlEnter,
|
|
externalMessageUpdate,
|
|
onShowAllTasks,
|
|
}: ChatInterfaceProps) {
|
|
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
|
const { t } = useTranslation('chat');
|
|
|
|
const streamBufferRef = useRef('');
|
|
const streamTimerRef = useRef<number | null>(null);
|
|
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
|
|
|
|
const resetStreamingState = useCallback(() => {
|
|
if (streamTimerRef.current) {
|
|
clearTimeout(streamTimerRef.current);
|
|
streamTimerRef.current = null;
|
|
}
|
|
streamBufferRef.current = '';
|
|
}, []);
|
|
|
|
const {
|
|
provider,
|
|
setProvider,
|
|
cursorModel,
|
|
setCursorModel,
|
|
claudeModel,
|
|
setClaudeModel,
|
|
codexModel,
|
|
setCodexModel,
|
|
geminiModel,
|
|
setGeminiModel,
|
|
permissionMode,
|
|
pendingPermissionRequests,
|
|
setPendingPermissionRequests,
|
|
cyclePermissionMode,
|
|
} = useChatProviderState({
|
|
selectedSession,
|
|
});
|
|
|
|
const {
|
|
chatMessages,
|
|
setChatMessages,
|
|
isLoading,
|
|
setIsLoading,
|
|
currentSessionId,
|
|
setCurrentSessionId,
|
|
sessionMessages,
|
|
setSessionMessages,
|
|
isLoadingSessionMessages,
|
|
isLoadingMoreMessages,
|
|
hasMoreMessages,
|
|
totalMessages,
|
|
isSystemSessionChange,
|
|
setIsSystemSessionChange,
|
|
canAbortSession,
|
|
setCanAbortSession,
|
|
isUserScrolledUp,
|
|
setIsUserScrolledUp,
|
|
tokenBudget,
|
|
setTokenBudget,
|
|
visibleMessageCount,
|
|
visibleMessages,
|
|
loadEarlierMessages,
|
|
loadAllMessages,
|
|
allMessagesLoaded,
|
|
isLoadingAllMessages,
|
|
loadAllJustFinished,
|
|
showLoadAllOverlay,
|
|
claudeStatus,
|
|
setClaudeStatus,
|
|
createDiff,
|
|
scrollContainerRef,
|
|
scrollToBottom,
|
|
scrollToBottomAndReset,
|
|
handleScroll,
|
|
} = useChatSessionState({
|
|
selectedProject,
|
|
selectedSession,
|
|
ws,
|
|
sendMessage,
|
|
autoScrollToBottom,
|
|
externalMessageUpdate,
|
|
processingSessions,
|
|
resetStreamingState,
|
|
pendingViewSessionRef,
|
|
});
|
|
|
|
const {
|
|
input,
|
|
setInput,
|
|
textareaRef,
|
|
inputHighlightRef,
|
|
isTextareaExpanded,
|
|
thinkingMode,
|
|
setThinkingMode,
|
|
slashCommandsCount,
|
|
filteredCommands,
|
|
frequentCommands,
|
|
commandQuery,
|
|
showCommandMenu,
|
|
selectedCommandIndex,
|
|
resetCommandMenuState,
|
|
handleCommandSelect,
|
|
handleToggleCommandMenu,
|
|
showFileDropdown,
|
|
filteredFiles,
|
|
selectedFileIndex,
|
|
renderInputWithMentions,
|
|
selectFile,
|
|
attachedImages,
|
|
setAttachedImages,
|
|
uploadingImages,
|
|
imageErrors,
|
|
getRootProps,
|
|
getInputProps,
|
|
isDragActive,
|
|
openImagePicker,
|
|
handleSubmit,
|
|
handleInputChange,
|
|
handleKeyDown,
|
|
handlePaste,
|
|
handleTextareaClick,
|
|
handleTextareaInput,
|
|
syncInputOverlayScroll,
|
|
handleClearInput,
|
|
handleAbortSession,
|
|
handleTranscript,
|
|
handlePermissionDecision,
|
|
handleGrantToolPermission,
|
|
handleInputFocusChange,
|
|
isInputFocused,
|
|
} = useChatComposerState({
|
|
selectedProject,
|
|
selectedSession,
|
|
currentSessionId,
|
|
provider,
|
|
permissionMode,
|
|
cyclePermissionMode,
|
|
cursorModel,
|
|
claudeModel,
|
|
codexModel,
|
|
geminiModel,
|
|
isLoading,
|
|
canAbortSession,
|
|
tokenBudget,
|
|
sendMessage,
|
|
sendByCtrlEnter,
|
|
onSessionActive,
|
|
onSessionProcessing,
|
|
onInputFocusChange,
|
|
onFileOpen,
|
|
onShowSettings,
|
|
pendingViewSessionRef,
|
|
scrollToBottom,
|
|
setChatMessages,
|
|
setSessionMessages,
|
|
setIsLoading,
|
|
setCanAbortSession,
|
|
setClaudeStatus,
|
|
setIsUserScrolledUp,
|
|
setPendingPermissionRequests,
|
|
});
|
|
|
|
useChatRealtimeHandlers({
|
|
latestMessage,
|
|
provider,
|
|
selectedProject,
|
|
selectedSession,
|
|
currentSessionId,
|
|
setCurrentSessionId,
|
|
setChatMessages,
|
|
setIsLoading,
|
|
setCanAbortSession,
|
|
setClaudeStatus,
|
|
setTokenBudget,
|
|
setIsSystemSessionChange,
|
|
setPendingPermissionRequests,
|
|
pendingViewSessionRef,
|
|
streamBufferRef,
|
|
streamTimerRef,
|
|
onSessionInactive,
|
|
onSessionProcessing,
|
|
onSessionNotProcessing,
|
|
onReplaceTemporarySession,
|
|
onNavigateToSession,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!isLoading || !canAbortSession) {
|
|
return;
|
|
}
|
|
|
|
const handleGlobalEscape = (event: KeyboardEvent) => {
|
|
if (event.key !== 'Escape' || event.repeat || event.defaultPrevented) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
handleAbortSession();
|
|
};
|
|
|
|
document.addEventListener('keydown', handleGlobalEscape, { capture: true });
|
|
return () => {
|
|
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
|
|
};
|
|
}, [canAbortSession, handleAbortSession, isLoading]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
resetStreamingState();
|
|
};
|
|
}, [resetStreamingState]);
|
|
|
|
if (!selectedProject) {
|
|
const selectedProviderLabel =
|
|
provider === 'cursor'
|
|
? t('messageTypes.cursor')
|
|
: provider === 'codex'
|
|
? t('messageTypes.codex')
|
|
: provider === 'gemini'
|
|
? t('messageTypes.gemini')
|
|
: t('messageTypes.claude');
|
|
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="text-center text-muted-foreground">
|
|
<p className="text-sm">
|
|
{t('projectSelection.startChatWithProvider', {
|
|
provider: selectedProviderLabel,
|
|
defaultValue: 'Select a project to start chatting with {{provider}}',
|
|
})}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="h-full flex flex-col">
|
|
<ChatMessagesPane
|
|
scrollContainerRef={scrollContainerRef}
|
|
onWheel={handleScroll}
|
|
onTouchMove={handleScroll}
|
|
isLoadingSessionMessages={isLoadingSessionMessages}
|
|
chatMessages={chatMessages}
|
|
selectedSession={selectedSession}
|
|
currentSessionId={currentSessionId}
|
|
provider={provider}
|
|
setProvider={(nextProvider) => setProvider(nextProvider as Provider)}
|
|
textareaRef={textareaRef}
|
|
claudeModel={claudeModel}
|
|
setClaudeModel={setClaudeModel}
|
|
cursorModel={cursorModel}
|
|
setCursorModel={setCursorModel}
|
|
codexModel={codexModel}
|
|
setCodexModel={setCodexModel}
|
|
geminiModel={geminiModel}
|
|
setGeminiModel={setGeminiModel}
|
|
tasksEnabled={tasksEnabled}
|
|
isTaskMasterInstalled={isTaskMasterInstalled}
|
|
onShowAllTasks={onShowAllTasks}
|
|
setInput={setInput}
|
|
isLoadingMoreMessages={isLoadingMoreMessages}
|
|
hasMoreMessages={hasMoreMessages}
|
|
totalMessages={totalMessages}
|
|
sessionMessagesCount={sessionMessages.length}
|
|
visibleMessageCount={visibleMessageCount}
|
|
visibleMessages={visibleMessages}
|
|
loadEarlierMessages={loadEarlierMessages}
|
|
loadAllMessages={loadAllMessages}
|
|
allMessagesLoaded={allMessagesLoaded}
|
|
isLoadingAllMessages={isLoadingAllMessages}
|
|
loadAllJustFinished={loadAllJustFinished}
|
|
showLoadAllOverlay={showLoadAllOverlay}
|
|
createDiff={createDiff}
|
|
onFileOpen={onFileOpen}
|
|
onShowSettings={onShowSettings}
|
|
onGrantToolPermission={handleGrantToolPermission}
|
|
autoExpandTools={autoExpandTools}
|
|
showRawParameters={showRawParameters}
|
|
showThinking={showThinking}
|
|
selectedProject={selectedProject}
|
|
isLoading={isLoading}
|
|
/>
|
|
|
|
<ChatComposer
|
|
pendingPermissionRequests={pendingPermissionRequests}
|
|
handlePermissionDecision={handlePermissionDecision}
|
|
handleGrantToolPermission={handleGrantToolPermission}
|
|
claudeStatus={claudeStatus}
|
|
isLoading={isLoading}
|
|
onAbortSession={handleAbortSession}
|
|
provider={provider}
|
|
permissionMode={permissionMode}
|
|
onModeSwitch={cyclePermissionMode}
|
|
thinkingMode={thinkingMode}
|
|
setThinkingMode={setThinkingMode}
|
|
tokenBudget={tokenBudget}
|
|
slashCommandsCount={slashCommandsCount}
|
|
onToggleCommandMenu={handleToggleCommandMenu}
|
|
hasInput={Boolean(input.trim())}
|
|
onClearInput={handleClearInput}
|
|
isUserScrolledUp={isUserScrolledUp}
|
|
hasMessages={chatMessages.length > 0}
|
|
onScrollToBottom={scrollToBottomAndReset}
|
|
onSubmit={handleSubmit}
|
|
isDragActive={isDragActive}
|
|
attachedImages={attachedImages}
|
|
onRemoveImage={(index) =>
|
|
setAttachedImages((previous) =>
|
|
previous.filter((_, currentIndex) => currentIndex !== index),
|
|
)
|
|
}
|
|
uploadingImages={uploadingImages}
|
|
imageErrors={imageErrors}
|
|
showFileDropdown={showFileDropdown}
|
|
filteredFiles={filteredFiles}
|
|
selectedFileIndex={selectedFileIndex}
|
|
onSelectFile={selectFile}
|
|
filteredCommands={filteredCommands}
|
|
selectedCommandIndex={selectedCommandIndex}
|
|
onCommandSelect={handleCommandSelect}
|
|
onCloseCommandMenu={resetCommandMenuState}
|
|
isCommandMenuOpen={showCommandMenu}
|
|
frequentCommands={commandQuery ? [] : frequentCommands}
|
|
getRootProps={getRootProps as (...args: unknown[]) => Record<string, unknown>}
|
|
getInputProps={getInputProps as (...args: unknown[]) => Record<string, unknown>}
|
|
openImagePicker={openImagePicker}
|
|
inputHighlightRef={inputHighlightRef}
|
|
renderInputWithMentions={renderInputWithMentions}
|
|
textareaRef={textareaRef}
|
|
input={input}
|
|
onInputChange={handleInputChange}
|
|
onTextareaClick={handleTextareaClick}
|
|
onTextareaKeyDown={handleKeyDown}
|
|
onTextareaPaste={handlePaste}
|
|
onTextareaScrollSync={syncInputOverlayScroll}
|
|
onTextareaInput={handleTextareaInput}
|
|
onInputFocusChange={handleInputFocusChange}
|
|
isInputFocused={isInputFocused}
|
|
placeholder={t('input.placeholder', {
|
|
provider:
|
|
provider === 'cursor'
|
|
? t('messageTypes.cursor')
|
|
: provider === 'codex'
|
|
? t('messageTypes.codex')
|
|
: provider === 'gemini'
|
|
? t('messageTypes.gemini')
|
|
: t('messageTypes.claude'),
|
|
})}
|
|
isTextareaExpanded={isTextareaExpanded}
|
|
sendByCtrlEnter={sendByCtrlEnter}
|
|
onTranscript={handleTranscript}
|
|
/>
|
|
</div>
|
|
|
|
<QuickSettingsPanel />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default React.memo(ChatInterface);
|