/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { useMemo, useState, useCallback, useEffect, useRef, useLayoutEffect, } from 'react'; import { type DOMElement, measureElement } from 'ink'; import { App } from './App.js'; import { AppContext } from './contexts/AppContext.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; import { UIActionsContext, type UIActions, } from './contexts/UIActionsContext.js'; import { ConfigContext } from './contexts/ConfigContext.js'; import { type HistoryItem, ToolCallStatus, type HistoryItemWithoutId, AuthState, } from './types.js'; import { MessageType, StreamingState } from './types.js'; import { type EditorType, type Config, type IdeInfo, type IdeContext, type UserTierId, type UserFeedbackPayload, DEFAULT_GEMINI_FLASH_MODEL, IdeClient, ideContextStore, getErrorMessage, getAllGeminiMdFilenames, AuthType, clearCachedCredentialFile, recordExitFail, ShellExecutionService, debugLogger, coreEvents, CoreEvent, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; import { getPolicyErrorsForUI } from '../config/policy.js'; import process from 'node:process'; import { useHistory } from './hooks/useHistoryManager.js'; import { useMemoryMonitor } from './hooks/useMemoryMonitor.js'; import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useAuthCommand } from './auth/useAuth.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { calculatePromptWidths } from './components/InputPrompt.js'; import { useStdout, useStdin } from 'ink'; import { calculateMainAreaWidth } from './utils/ui-sizing.js'; import ansiEscapes from 'ansi-escapes'; import * as fs from 'node:fs'; import { basename } from 'node:path'; import { computeWindowTitle } from '../utils/windowTitle.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useVim } from './hooks/vim.js'; import { type LoadedSettings, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; import { useFocus } from './hooks/useFocus.js'; import { useBracketedPaste } from './hooks/useBracketedPaste.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { keyMatchers, Command } from './keyMatchers.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js'; import { appEvents, AppEvent } from '../utils/events.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { ConsolePatcher } from './utils/ConsolePatcher.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useConfirmUpdateRequests, useExtensionUpdates, } from './hooks/useExtensionUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { type ExtensionManager } from '../config/extension-manager.js'; import { requestConsentInteractive } from '../config/extensions/consent.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { if (item && item.type === 'tool_group') { return item.tools.some( (tool) => ToolCallStatus.Executing === tool.status, ); } return false; }); } interface AppContainerProps { config: Config; settings: LoadedSettings; startupWarnings?: string[]; version: string; initializationResult: InitializationResult; } /** * The fraction of the terminal width to allocate to the shell. * This provides horizontal padding. */ const SHELL_WIDTH_FRACTION = 0.89; /** * The number of lines to subtract from the available terminal height * for the shell. This provides vertical padding and space for other UI elements. */ const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { const { settings, config, initializationResult } = props; const historyManager = useHistory(); useMemoryMonitor(historyManager); const [corgiMode, setCorgiMode] = useState(false); const [debugMessage, setDebugMessage] = useState(''); const [quittingMessages, setQuittingMessages] = useState< HistoryItem[] | null >(null); const [showPrivacyNotice, setShowPrivacyNotice] = useState(false); const [themeError, setThemeError] = useState( initializationResult.themeError, ); const [isProcessing, setIsProcessing] = useState(false); const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false); const [showDebugProfiler, setShowDebugProfiler] = useState(false); const [geminiMdFileCount, setGeminiMdFileCount] = useState( initializationResult.geminiMdFileCount, ); const [shellModeActive, setShellModeActive] = useState(false); const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] = useState(false); const [historyRemountKey, setHistoryRemountKey] = useState(0); const [updateInfo, setUpdateInfo] = useState(null); const [isTrustedFolder, setIsTrustedFolder] = useState( config.isTrustedFolder(), ); const [queueErrorMessage, setQueueErrorMessage] = useState( null, ); const extensionManager = config.getExtensionLoader() as ExtensionManager; // We are in the interactive CLI, update how we request consent and settings. extensionManager.setRequestConsent((description) => requestConsentInteractive(description, addConfirmUpdateExtensionRequest), ); extensionManager.setRequestSetting(); const { addConfirmUpdateExtensionRequest, confirmUpdateExtensionRequests } = useConfirmUpdateRequests(); const { extensionsUpdateState, extensionsUpdateStateInternal, dispatchExtensionStateUpdate, } = useExtensionUpdates(extensionManager, historyManager.addItem); const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false); const openPermissionsDialog = useCallback( () => setPermissionsDialogOpen(true), [], ); const closePermissionsDialog = useCallback( () => setPermissionsDialogOpen(false), [], ); const toggleDebugProfiler = useCallback( () => setShowDebugProfiler((prev) => !prev), [], ); // Helper to determine the effective model, considering the fallback state. const getEffectiveModel = useCallback(() => { if (config.isInFallbackMode()) { return DEFAULT_GEMINI_FLASH_MODEL; } return config.getModel(); }, [config]); const [currentModel, setCurrentModel] = useState(getEffectiveModel()); const [userTier, setUserTier] = useState(undefined); const [isConfigInitialized, setConfigInitialized] = useState(false); const logger = useLogger(config.storage); const [userMessages, setUserMessages] = useState([]); // Terminal and layout hooks const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize(); const { stdin, setRawMode } = useStdin(); const { stdout } = useStdout(); // Additional hooks moved from App.tsx const { stats: sessionStats } = useSessionStats(); const branchName = useGitBranchName(config.getTargetDir()); // Layout measurements const mainControlsRef = useRef(null); // For performance profiling only const rootUiRef = useRef(null); const originalTitleRef = useRef( computeWindowTitle(basename(config.getTargetDir())), ); const lastTitleRef = useRef(null); const staticExtraHeight = 3; useEffect(() => { (async () => { // Note: the program will not work if this fails so let errors be // handled by the global catch. await config.initialize(); setConfigInitialized(true); })(); registerCleanup(async () => { const ideClient = await IdeClient.getInstance(); await ideClient.disconnect(); }); }, [config]); useEffect( () => setUpdateHandler(historyManager.addItem, setUpdateInfo), [historyManager.addItem], ); // Subscribe to fallback mode changes from core useEffect(() => { const handleFallbackModeChanged = () => { const effectiveModel = getEffectiveModel(); setCurrentModel(effectiveModel); }; coreEvents.on(CoreEvent.FallbackModeChanged, handleFallbackModeChanged); return () => { coreEvents.off(CoreEvent.FallbackModeChanged, handleFallbackModeChanged); }; }, [getEffectiveModel]); const { consoleMessages, handleNewMessage, clearConsoleMessages: clearConsoleMessagesState, } = useConsoleMessages(); useEffect(() => { const consolePatcher = new ConsolePatcher({ onNewMessage: handleNewMessage, debugMode: config.getDebugMode(), }); consolePatcher.patch(); registerCleanup(consolePatcher.cleanup); }, [handleNewMessage, config]); const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings); // Derive widths for InputPrompt using shared helper const { inputWidth, suggestionsWidth } = useMemo(() => { const { inputWidth, suggestionsWidth } = calculatePromptWidths(mainAreaWidth); return { inputWidth, suggestionsWidth }; }, [mainAreaWidth]); const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100); const isValidPath = useCallback((filePath: string): boolean => { try { return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); } catch (_e) { return false; } }, []); const buffer = useTextBuffer({ initialText: '', viewport: { height: 10, width: inputWidth }, stdin, setRawMode, isValidPath, shellModeActive, }); useEffect(() => { const fetchUserMessages = async () => { const pastMessagesRaw = (await logger?.getPreviousUserMessages()) || []; const currentSessionUserMessages = historyManager.history .filter( (item): item is HistoryItem & { type: 'user'; text: string } => item.type === 'user' && typeof item.text === 'string' && item.text.trim() !== '', ) .map((item) => item.text) .reverse(); const combinedMessages = [ ...currentSessionUserMessages, ...pastMessagesRaw, ]; const deduplicatedMessages: string[] = []; if (combinedMessages.length > 0) { deduplicatedMessages.push(combinedMessages[0]); for (let i = 1; i < combinedMessages.length; i++) { if (combinedMessages[i] !== combinedMessages[i - 1]) { deduplicatedMessages.push(combinedMessages[i]); } } } setUserMessages(deduplicatedMessages.reverse()); }; fetchUserMessages(); }, [historyManager.history, logger]); const refreshStatic = useCallback(() => { stdout.write(ansiEscapes.clearTerminal); setHistoryRemountKey((prev) => prev + 1); }, [setHistoryRemountKey, stdout]); const { isThemeDialogOpen, openThemeDialog, closeThemeDialog, handleThemeSelect, handleThemeHighlight, } = useThemeCommand( settings, setThemeError, historyManager.addItem, initializationResult.themeError, ); const { authState, setAuthState, authError, onAuthError } = useAuthCommand( settings, config, ); const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({ config, historyManager, userTier, setAuthState, setModelSwitchedFromQuotaError, }); // Derive auth state variables for backward compatibility with UIStateContext const isAuthDialogOpen = authState === AuthState.Updating; const isAuthenticating = authState === AuthState.Unauthenticated; // Create handleAuthSelect wrapper for backward compatibility const handleAuthSelect = useCallback( async (authType: AuthType | undefined, scope: SettingScope) => { if (authType) { await clearCachedCredentialFile(); settings.setValue(scope, 'security.auth.selectedType', authType); try { await config.refreshAuth(authType); setAuthState(AuthState.Authenticated); } catch (e) { onAuthError( `Failed to authenticate: ${e instanceof Error ? e.message : String(e)}`, ); return; } if ( authType === AuthType.LOGIN_WITH_GOOGLE && config.isBrowserLaunchSuppressed() ) { await runExitCleanup(); debugLogger.log(` ---------------------------------------------------------------- Logging in with Google... Please restart Gemini CLI to continue. ---------------------------------------------------------------- `); process.exit(0); } } setAuthState(AuthState.Authenticated); }, [settings, config, setAuthState, onAuthError], ); // Sync user tier from config when authentication changes useEffect(() => { // Only sync when not currently authenticating if (authState === AuthState.Authenticated) { setUserTier(config.getUserTier()); } }, [config, authState]); // Check for enforced auth type mismatch useEffect(() => { if ( settings.merged.security?.auth?.enforcedType && settings.merged.security?.auth.selectedType && settings.merged.security?.auth.enforcedType !== settings.merged.security?.auth.selectedType ) { onAuthError( `Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`, ); } else if ( settings.merged.security?.auth?.selectedType && !settings.merged.security?.auth?.useExternal ) { const error = validateAuthMethod( settings.merged.security.auth.selectedType, ); if (error) { onAuthError(error); } } }, [ settings.merged.security?.auth?.selectedType, settings.merged.security?.auth?.enforcedType, settings.merged.security?.auth?.useExternal, onAuthError, ]); const [editorError, setEditorError] = useState(null); const { isEditorDialogOpen, openEditorDialog, handleEditorSelect, exitEditorDialog, } = useEditorSettings(settings, setEditorError, historyManager.addItem); const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = useSettingsCommand(); const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); const { toggleVimEnabled } = useVimMode(); const slashCommandActions = useMemo( () => ({ openAuthDialog: () => setAuthState(AuthState.Updating), openThemeDialog, openEditorDialog, openPrivacyNotice: () => setShowPrivacyNotice(true), openSettingsDialog, openModelDialog, openPermissionsDialog, quit: (messages: HistoryItem[]) => { setQuittingMessages(messages); setTimeout(async () => { await runExitCleanup(); process.exit(0); }, 100); }, setDebugMessage, toggleCorgiMode: () => setCorgiMode((prev) => !prev), toggleDebugProfiler, dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest, }), [ setAuthState, openThemeDialog, openEditorDialog, openSettingsDialog, openModelDialog, setQuittingMessages, setDebugMessage, setShowPrivacyNotice, setCorgiMode, dispatchExtensionStateUpdate, openPermissionsDialog, addConfirmUpdateExtensionRequest, toggleDebugProfiler, ], ); const { handleSlashCommand, slashCommands, pendingHistoryItems: pendingSlashCommandHistoryItems, commandContext, shellConfirmationRequest, confirmationRequest, } = useSlashCommandProcessor( config, settings, historyManager.addItem, historyManager.clearItems, historyManager.loadHistory, refreshStatic, toggleVimEnabled, setIsProcessing, setGeminiMdFileCount, slashCommandActions, extensionsUpdateStateInternal, isConfigInitialized, ); const performMemoryRefresh = useCallback(async () => { historyManager.addItem( { type: MessageType.INFO, text: 'Refreshing hierarchical memory (GEMINI.md or other context files)...', }, Date.now(), ); try { const { memoryContent, fileCount, filePaths } = await loadHierarchicalGeminiMemory( process.cwd(), settings.merged.context?.loadMemoryFromIncludeDirectories ? config.getWorkspaceContext().getDirectories() : [], config.getDebugMode(), config.getFileService(), settings.merged, config.getExtensionLoader(), config.isTrustedFolder(), settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree' config.getFileFilteringOptions(), ); config.setUserMemory(memoryContent); config.setGeminiMdFileCount(fileCount); config.setGeminiMdFilePaths(filePaths); setGeminiMdFileCount(fileCount); historyManager.addItem( { type: MessageType.INFO, text: `Memory refreshed successfully. ${ memoryContent.length > 0 ? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).` : 'No memory content found.' }`, }, Date.now(), ); if (config.getDebugMode()) { debugLogger.log( `[DEBUG] Refreshed memory content in config: ${memoryContent.substring( 0, 200, )}...`, ); } } catch (error) { const errorMessage = getErrorMessage(error); historyManager.addItem( { type: MessageType.ERROR, text: `Error refreshing memory: ${errorMessage}`, }, Date.now(), ); debugLogger.warn('Error refreshing memory:', error); } }, [config, historyManager, settings.merged]); const cancelHandlerRef = useRef<() => void>(() => {}); const getPreferredEditor = useCallback( () => settings.merged.general?.preferredEditor as EditorType, [settings.merged.general?.preferredEditor], ); const onCancelSubmit = useCallback(() => { cancelHandlerRef.current(); }, []); const { streamingState, submitQuery, initError, pendingHistoryItems: pendingGeminiHistoryItems, thought, cancelOngoingRequest, handleApprovalModeChange, activePtyId, loopDetectionConfirmationRequest, } = useGeminiStream( config.getGeminiClient(), historyManager.history, historyManager.addItem, config, settings, setDebugMessage, handleSlashCommand, shellModeActive, getPreferredEditor, onAuthError, performMemoryRefresh, modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError, refreshStatic, onCancelSubmit, setEmbeddedShellFocused, terminalWidth, terminalHeight, embeddedShellFocused, ); // Auto-accept indicator const showAutoAcceptIndicator = useAutoAcceptIndicator({ config, addItem: historyManager.addItem, onApprovalModeChange: handleApprovalModeChange, }); const { messageQueue, addMessage, clearQueue, getQueuedMessagesText, popAllMessages, } = useMessageQueue({ isConfigInitialized, streamingState, submitQuery, }); cancelHandlerRef.current = useCallback(() => { const pendingHistoryItems = [ ...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems, ]; if (isToolExecuting(pendingHistoryItems)) { buffer.setText(''); // Just clear the prompt return; } const lastUserMessage = userMessages.at(-1); let textToSet = lastUserMessage || ''; const queuedText = getQueuedMessagesText(); if (queuedText) { textToSet = textToSet ? `${textToSet}\n\n${queuedText}` : queuedText; clearQueue(); } if (textToSet) { buffer.setText(textToSet); } }, [ buffer, userMessages, getQueuedMessagesText, clearQueue, pendingSlashCommandHistoryItems, pendingGeminiHistoryItems, ]); const handleFinalSubmit = useCallback( (submittedValue: string) => { addMessage(submittedValue); }, [addMessage], ); const handleClearScreen = useCallback(() => { historyManager.clearItems(); clearConsoleMessagesState(); console.clear(); refreshStatic(); }, [historyManager, clearConsoleMessagesState, refreshStatic]); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); /** * Determines if the input prompt should be active and accept user input. * Input is disabled during: * - Initialization errors * - Slash command processing * - Tool confirmations (WaitingForConfirmation state) * - Any future streaming states not explicitly allowed */ const isInputActive = !initError && !isProcessing && !!slashCommands && (streamingState === StreamingState.Idle || streamingState === StreamingState.Responding) && !proQuotaRequest; const [controlsHeight, setControlsHeight] = useState(0); useLayoutEffect(() => { if (mainControlsRef.current) { const fullFooterMeasurement = measureElement(mainControlsRef.current); if (fullFooterMeasurement.height > 0) { setControlsHeight(fullFooterMeasurement.height); } } }, [buffer, terminalWidth, terminalHeight]); // Compute available terminal height based on controls measurement const availableTerminalHeight = Math.max( 0, terminalHeight - controlsHeight - staticExtraHeight - 2, ); config.setShellExecutionConfig({ terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), terminalHeight: Math.max( Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), 1, ), pager: settings.merged.tools?.shell?.pager, showColor: settings.merged.tools?.shell?.showColor, }); const isFocused = useFocus(); useBracketedPaste(); // Context file names computation const contextFileNames = useMemo(() => { const fromSettings = settings.merged.context?.fileName; return fromSettings ? Array.isArray(fromSettings) ? fromSettings : [fromSettings] : getAllGeminiMdFilenames(); }, [settings.merged.context?.fileName]); // Initial prompt handling const initialPrompt = useMemo(() => config.getQuestion(), [config]); const initialPromptSubmitted = useRef(false); const geminiClient = config.getGeminiClient(); useEffect(() => { if (activePtyId) { ShellExecutionService.resizePty( activePtyId, Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), Math.max(Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), 1), ); } }, [terminalWidth, availableTerminalHeight, activePtyId]); useEffect(() => { if ( initialPrompt && isConfigInitialized && !initialPromptSubmitted.current && !isAuthenticating && !isAuthDialogOpen && !isThemeDialogOpen && !isEditorDialogOpen && !showPrivacyNotice && geminiClient?.isInitialized?.() ) { handleFinalSubmit(initialPrompt); initialPromptSubmitted.current = true; } }, [ initialPrompt, isConfigInitialized, handleFinalSubmit, isAuthenticating, isAuthDialogOpen, isThemeDialogOpen, isEditorDialogOpen, showPrivacyNotice, geminiClient, ]); const [idePromptAnswered, setIdePromptAnswered] = useState(false); const [currentIDE, setCurrentIDE] = useState(null); useEffect(() => { const getIde = async () => { const ideClient = await IdeClient.getInstance(); const currentIde = ideClient.getCurrentIde(); setCurrentIDE(currentIde || null); }; getIde(); }, []); const shouldShowIdePrompt = Boolean( currentIDE && !config.getIdeMode() && !settings.merged.ide?.hasSeenNudge && !idePromptAnswered, ); const [showErrorDetails, setShowErrorDetails] = useState(false); const [showFullTodos, setShowFullTodos] = useState(false); const [renderMarkdown, setRenderMarkdown] = useState(true); const [ctrlCPressCount, setCtrlCPressCount] = useState(0); const ctrlCTimerRef = useRef(null); const [ctrlDPressCount, setCtrlDPressCount] = useState(0); const ctrlDTimerRef = useRef(null); const [constrainHeight, setConstrainHeight] = useState(true); const [ideContextState, setIdeContextState] = useState< IdeContext | undefined >(); const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); const { needsRestart: ideNeedsRestart, restartReason: ideTrustRestartReason, } = useIdeTrustListener(); const isInitialMount = useRef(true); useEffect(() => { if (ideNeedsRestart) { // IDE trust changed, force a restart. setShowIdeRestartPrompt(true); } }, [ideNeedsRestart]); useEffect(() => { if (queueErrorMessage) { const timer = setTimeout(() => { setQueueErrorMessage(null); }, QUEUE_ERROR_DISPLAY_DURATION_MS); return () => clearTimeout(timer); } return undefined; }, [queueErrorMessage, setQueueErrorMessage]); useEffect(() => { if (isInitialMount.current) { isInitialMount.current = false; return; } const handler = setTimeout(() => { refreshStatic(); }, 300); return () => { clearTimeout(handler); }; }, [terminalWidth, refreshStatic]); useEffect(() => { const unsubscribe = ideContextStore.subscribe(setIdeContextState); setIdeContextState(ideContextStore.get()); return unsubscribe; }, []); useEffect(() => { const openDebugConsole = () => { setShowErrorDetails(true); setConstrainHeight(false); }; appEvents.on(AppEvent.OpenDebugConsole, openDebugConsole); const logErrorHandler = (errorMessage: unknown) => { handleNewMessage({ type: 'error', content: String(errorMessage), count: 1, }); }; appEvents.on(AppEvent.LogError, logErrorHandler); // Emit any policy errors that were stored during config loading // Only show these when message bus integration is enabled, as policies // are only active when the message bus is being used. if (config.getEnableMessageBusIntegration()) { const policyErrors = getPolicyErrorsForUI(); if (policyErrors.length > 0) { for (const error of policyErrors) { appEvents.emit(AppEvent.LogError, error); } } } return () => { appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole); appEvents.off(AppEvent.LogError, logErrorHandler); }; }, [handleNewMessage, config]); useEffect(() => { if (ctrlCTimerRef.current) { clearTimeout(ctrlCTimerRef.current); ctrlCTimerRef.current = null; } if (ctrlCPressCount > 2) { recordExitFail(config); } if (ctrlCPressCount > 1) { handleSlashCommand('/quit'); } else { ctrlCTimerRef.current = setTimeout(() => { setCtrlCPressCount(0); ctrlCTimerRef.current = null; }, CTRL_EXIT_PROMPT_DURATION_MS); } }, [ctrlCPressCount, config, setCtrlCPressCount, handleSlashCommand]); useEffect(() => { if (ctrlDTimerRef.current) { clearTimeout(ctrlDTimerRef.current); ctrlCTimerRef.current = null; } if (ctrlDPressCount > 2) { recordExitFail(config); } if (ctrlDPressCount > 1) { handleSlashCommand('/quit'); } else { ctrlDTimerRef.current = setTimeout(() => { setCtrlDPressCount(0); ctrlDTimerRef.current = null; }, CTRL_EXIT_PROMPT_DURATION_MS); } }, [ctrlDPressCount, config, setCtrlDPressCount, handleSlashCommand]); const handleEscapePromptChange = useCallback((showPrompt: boolean) => { setShowEscapePrompt(showPrompt); }, []); const handleIdePromptComplete = useCallback( (result: IdeIntegrationNudgeResult) => { if (result.userSelection === 'yes') { handleSlashCommand('/ide install'); settings.setValue( SettingScope.User, 'hasSeenIdeIntegrationNudge', true, ); } else if (result.userSelection === 'dismiss') { settings.setValue( SettingScope.User, 'hasSeenIdeIntegrationNudge', true, ); } setIdePromptAnswered(true); }, [handleSlashCommand, settings], ); const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator( streamingState, settings.merged.ui?.customWittyPhrases, ); const handleGlobalKeypress = useCallback( (key: Key) => { // Debug log keystrokes if enabled if (settings.merged.general?.debugKeystrokeLogging) { debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); } if (keyMatchers[Command.QUIT](key)) { // If the user presses Ctrl+C, we want to cancel any ongoing requests. // This should happen regardless of the count. cancelOngoingRequest?.(); setCtrlCPressCount((prev) => prev + 1); return; } else if (keyMatchers[Command.EXIT](key)) { if (buffer.text.length > 0) { return; } setCtrlDPressCount((prev) => prev + 1); return; } let enteringConstrainHeightMode = false; if (!constrainHeight) { enteringConstrainHeightMode = true; setConstrainHeight(true); } if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { setShowErrorDetails((prev) => !prev); } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { setShowFullTodos((prev) => !prev); } else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) { setRenderMarkdown((prev) => { const newValue = !prev; // Force re-render of static content refreshStatic(); return newValue; }); } else if ( keyMatchers[Command.TOGGLE_IDE_CONTEXT_DETAIL](key) && config.getIdeMode() && ideContextState ) { handleSlashCommand('/ide status'); } else if ( keyMatchers[Command.SHOW_MORE_LINES](key) && !enteringConstrainHeightMode ) { setConstrainHeight(false); } else if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) { if (activePtyId || embeddedShellFocused) { setEmbeddedShellFocused((prev) => !prev); } } }, [ constrainHeight, setConstrainHeight, setShowErrorDetails, config, ideContextState, setCtrlCPressCount, buffer.text.length, setCtrlDPressCount, handleSlashCommand, cancelOngoingRequest, activePtyId, embeddedShellFocused, settings.merged.general?.debugKeystrokeLogging, refreshStatic, ], ); useKeypress(handleGlobalKeypress, { isActive: true }); // Update terminal title with Gemini CLI status and thoughts useEffect(() => { // Respect both showStatusInTitle and hideWindowTitle settings if ( !settings.merged.ui?.showStatusInTitle || settings.merged.ui?.hideWindowTitle ) return; let title; if (streamingState === StreamingState.Idle) { title = originalTitleRef.current; } else { const statusText = thought?.subject ?.replace(/[\r\n]+/g, ' ') .substring(0, 80); title = statusText || originalTitleRef.current; } // Pad the title to a fixed width to prevent taskbar icon resizing. const paddedTitle = title.padEnd(80, ' '); // Only update the title if it's different from the last value we set if (lastTitleRef.current !== paddedTitle) { lastTitleRef.current = paddedTitle; stdout.write(`\x1b]2;${paddedTitle}\x07`); } // Note: We don't need to reset the window title on exit because Gemini CLI is already doing that elsewhere }, [ streamingState, thought, settings.merged.ui?.showStatusInTitle, settings.merged.ui?.hideWindowTitle, stdout, ]); useEffect(() => { const handleUserFeedback = (payload: UserFeedbackPayload) => { let type: MessageType; switch (payload.severity) { case 'error': type = MessageType.ERROR; break; case 'warning': type = MessageType.WARNING; break; case 'info': type = MessageType.INFO; break; default: throw new Error( `Unexpected severity for user feedback: ${payload.severity}`, ); } historyManager.addItem( { type, text: payload.message, }, Date.now(), ); // If there is an attached error object, log it to the debug drawer. if (payload.error) { debugLogger.warn( `[Feedback Details for "${payload.message}"]`, payload.error, ); } }; coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback); // Flush any messages that happened during startup before this component // mounted. coreEvents.drainFeedbackBacklog(); return () => { coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); }; }, [historyManager]); const filteredConsoleMessages = useMemo(() => { if (config.getDebugMode()) { return consoleMessages; } return consoleMessages.filter((msg) => msg.type !== 'debug'); }, [consoleMessages, config]); // Computed values const errorCount = useMemo( () => filteredConsoleMessages .filter((msg) => msg.type === 'error') .reduce((total, msg) => total + msg.count, 0), [filteredConsoleMessages], ); const nightly = props.version.includes('nightly'); const dialogsVisible = shouldShowIdePrompt || isFolderTrustDialogOpen || !!shellConfirmationRequest || !!confirmationRequest || confirmUpdateExtensionRequests.length > 0 || !!loopDetectionConfirmationRequest || isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || isPermissionsDialogOpen || isAuthenticating || isAuthDialogOpen || isEditorDialogOpen || showPrivacyNotice || showIdeRestartPrompt || !!proQuotaRequest; const pendingHistoryItems = useMemo( () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], ); const uiState: UIState = useMemo( () => ({ history: historyManager.history, historyManager, isThemeDialogOpen, themeError, isAuthenticating, isConfigInitialized, authError, isAuthDialogOpen, editorError, isEditorDialogOpen, showPrivacyNotice, corgiMode, debugMessage, quittingMessages, isSettingsDialogOpen, isModelDialogOpen, isPermissionsDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, shellConfirmationRequest, confirmationRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, geminiMdFileCount, streamingState, initError, pendingGeminiHistoryItems, thought, shellModeActive, userMessages, buffer, inputWidth, suggestionsWidth, isInputActive, shouldShowIdePrompt, isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, isTrustedFolder, constrainHeight, showErrorDetails, showFullTodos, filteredConsoleMessages, ideContextState, renderMarkdown, ctrlCPressedOnce: ctrlCPressCount >= 1, ctrlDPressedOnce: ctrlDPressCount >= 1, showEscapePrompt, isFocused, elapsedTime, currentLoadingPhrase, historyRemountKey, messageQueue, queueErrorMessage, showAutoAcceptIndicator, currentModel, userTier, proQuotaRequest, contextFileNames, errorCount, availableTerminalHeight, mainAreaWidth, staticAreaMaxItemHeight, staticExtraHeight, dialogsVisible, pendingHistoryItems, nightly, branchName, sessionStats, terminalWidth, terminalHeight, mainControlsRef, rootUiRef, currentIDE, updateInfo, showIdeRestartPrompt, ideTrustRestartReason, isRestarting, extensionsUpdateState, activePtyId, embeddedShellFocused, showDebugProfiler, }), [ isThemeDialogOpen, themeError, isAuthenticating, isConfigInitialized, authError, isAuthDialogOpen, editorError, isEditorDialogOpen, showPrivacyNotice, corgiMode, debugMessage, quittingMessages, isSettingsDialogOpen, isModelDialogOpen, isPermissionsDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, shellConfirmationRequest, confirmationRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, geminiMdFileCount, streamingState, initError, pendingGeminiHistoryItems, thought, shellModeActive, userMessages, buffer, inputWidth, suggestionsWidth, isInputActive, shouldShowIdePrompt, isFolderTrustDialogOpen, isTrustedFolder, constrainHeight, showErrorDetails, showFullTodos, filteredConsoleMessages, ideContextState, renderMarkdown, ctrlCPressCount, ctrlDPressCount, showEscapePrompt, isFocused, elapsedTime, currentLoadingPhrase, historyRemountKey, messageQueue, queueErrorMessage, showAutoAcceptIndicator, userTier, proQuotaRequest, contextFileNames, errorCount, availableTerminalHeight, mainAreaWidth, staticAreaMaxItemHeight, staticExtraHeight, dialogsVisible, pendingHistoryItems, nightly, branchName, sessionStats, terminalWidth, terminalHeight, mainControlsRef, rootUiRef, currentIDE, updateInfo, showIdeRestartPrompt, ideTrustRestartReason, isRestarting, currentModel, extensionsUpdateState, activePtyId, historyManager, embeddedShellFocused, showDebugProfiler, ], ); const exitPrivacyNotice = useCallback( () => setShowPrivacyNotice(false), [setShowPrivacyNotice], ); const uiActions: UIActions = useMemo( () => ({ handleThemeSelect, closeThemeDialog, handleThemeHighlight, handleAuthSelect, setAuthState, onAuthError, handleEditorSelect, exitEditorDialog, exitPrivacyNotice, closeSettingsDialog, closeModelDialog, closePermissionsDialog, setShellModeActive, vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, setConstrainHeight, onEscapePromptChange: handleEscapePromptChange, refreshStatic, handleFinalSubmit, handleClearScreen, handleProQuotaChoice, setQueueErrorMessage, popAllMessages, }), [ handleThemeSelect, closeThemeDialog, handleThemeHighlight, handleAuthSelect, setAuthState, onAuthError, handleEditorSelect, exitEditorDialog, exitPrivacyNotice, closeSettingsDialog, closeModelDialog, closePermissionsDialog, setShellModeActive, vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, setConstrainHeight, handleEscapePromptChange, refreshStatic, handleFinalSubmit, handleClearScreen, handleProQuotaChoice, setQueueErrorMessage, popAllMessages, ], ); return ( ); };