/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { ApprovalMode, checkExhaustive, CoreToolCallStatus, isUserVisibleHook, } from '@google/gemini-cli-core'; import { Box, Text, useIsScreenReaderEnabled } from 'ink'; import { useState, useEffect, useMemo } from 'react'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { isContextUsageHigh } from '../utils/contextUsage.js'; import { theme } from '../semantic-colors.js'; import { GENERIC_WORKING_LABEL } from '../textConstants.js'; import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js'; import { StreamingState, type HistoryItemToolGroup } from '../types.js'; import { LoadingIndicator } from './LoadingIndicator.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { StatusDisplay } from './StatusDisplay.js'; import { HorizontalLine } from './shared/HorizontalLine.js'; import { ToastDisplay, shouldShowToast } from './ToastDisplay.js'; import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; import { ShortcutsHelp } from './ShortcutsHelp.js'; import { InputPrompt } from './InputPrompt.js'; import { Footer } from './Footer.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; import { ConfigInitDisplay } from './ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const uiState = useUIState(); const uiActions = useUIActions(); const settings = useSettings(); const config = useConfig(); const { vimEnabled, vimMode } = useVimMode(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); const { columns: terminalWidth } = useTerminalSize(); const isNarrow = isNarrowWidth(terminalWidth); const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5)); const [suggestionsVisible, setSuggestionsVisible] = useState(false); const isAlternateBuffer = useAlternateBuffer(); const showApprovalModeIndicator = uiState.showApprovalModeIndicator; const loadingPhrases = settings.merged.ui.loadingPhrases; const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all'; const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all'; const showUiDetails = uiState.cleanUiDetailsVisible; const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; const hideContextSummary = suggestionsVisible && suggestionsPosition === 'above'; const hasPendingToolConfirmation = useMemo( () => (uiState.pendingHistoryItems ?? []) .filter( (item): item is HistoryItemToolGroup => item.type === 'tool_group', ) .some((item) => item.tools.some( (tool) => tool.status === CoreToolCallStatus.AwaitingApproval, ), ), [uiState.pendingHistoryItems], ); const hasPendingActionRequired = hasPendingToolConfirmation || Boolean(uiState.commandConfirmationRequest) || Boolean(uiState.authConsentRequest) || (uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 || Boolean(uiState.loopDetectionConfirmationRequest) || Boolean(uiState.quota.proQuotaRequest) || Boolean(uiState.quota.validationRequest) || Boolean(uiState.customDialog); const isPassiveShortcutsHelpState = uiState.isInputActive && uiState.streamingState === StreamingState.Idle && !hasPendingActionRequired; const { setShortcutsHelpVisible } = uiActions; useEffect(() => { if (uiState.shortcutsHelpVisible && !isPassiveShortcutsHelpState) { setShortcutsHelpVisible(false); } }, [ uiState.shortcutsHelpVisible, isPassiveShortcutsHelpState, setShortcutsHelpVisible, ]); const showShortcutsHelp = uiState.shortcutsHelpVisible && uiState.streamingState === StreamingState.Idle && !hasPendingActionRequired; /** * Use the setting if provided, otherwise default to true for the new UX. * This allows tests to override the collapse behavior. */ const shouldCollapseDuringApproval = settings.merged.ui.collapseDrawerDuringApproval !== false; if (hasPendingActionRequired && shouldCollapseDuringApproval) { return null; } const hasToast = shouldShowToast(uiState); const showLoadingIndicator = (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && uiState.streamingState === StreamingState.Responding && !hasPendingActionRequired; const hideUiDetailsForSuggestions = suggestionsVisible && suggestionsPosition === 'above'; const showApprovalIndicator = !uiState.shellModeActive && !hideUiDetailsForSuggestions; const showRawMarkdownIndicator = !uiState.renderMarkdown; let modeBleedThrough: { text: string; color: string } | null = null; switch (showApprovalModeIndicator) { case ApprovalMode.YOLO: modeBleedThrough = { text: 'YOLO', color: theme.status.error }; break; case ApprovalMode.PLAN: modeBleedThrough = { text: 'plan', color: theme.status.success }; break; case ApprovalMode.AUTO_EDIT: modeBleedThrough = { text: 'auto edit', color: theme.status.warning }; break; case ApprovalMode.DEFAULT: modeBleedThrough = null; break; default: checkExhaustive(showApprovalModeIndicator); modeBleedThrough = null; break; } const hideMinimalModeHintWhileBusy = !showUiDetails && (showLoadingIndicator || hasPendingActionRequired); // Universal Content Objects const modeContentObj = hideMinimalModeHintWhileBusy ? null : modeBleedThrough; const allHooks = uiState.activeHooks; const hasAnyHooks = allHooks.length > 0; const userVisibleHooks = allHooks.filter((h) => isUserVisibleHook(h.source)); const hasUserVisibleHooks = userVisibleHooks.length > 0; const shouldReserveSpaceForShortcutsHint = settings.merged.ui.showShortcutsHint && !hideUiDetailsForSuggestions && !hasPendingActionRequired; const isInteractiveShellWaiting = uiState.currentLoadingPhrase?.includes( INTERACTIVE_SHELL_WAITING_PHRASE, ); /** * Calculate the estimated length of the status message to avoid collisions * with the tips area. */ let estimatedStatusLength = 0; if (hasAnyHooks) { if (hasUserVisibleHooks) { const hookLabel = userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook'; const hookNames = userVisibleHooks .map( (h) => h.name + (h.index && h.total && h.total > 1 ? ` (${h.index}/${h.total})` : ''), ) .join(', '); estimatedStatusLength = hookLabel.length + hookNames.length + 10; } else { estimatedStatusLength = GENERIC_WORKING_LABEL.length + 10; } } else if (showLoadingIndicator) { const thoughtText = uiState.thought?.subject || GENERIC_WORKING_LABEL; const inlineWittyLength = showWit && uiState.currentWittyPhrase ? uiState.currentWittyPhrase.length + 1 : 0; estimatedStatusLength = thoughtText.length + 25 + inlineWittyLength; } else if (hasPendingActionRequired) { estimatedStatusLength = 20; } else if (hasToast) { estimatedStatusLength = 40; } /** * Determine the ambient text (tip) to display. */ const tipContentStr = (() => { // 1. Proactive Tip (Priority) if ( showTips && uiState.currentTip && !( isInteractiveShellWaiting && uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE ) ) { if ( estimatedStatusLength + uiState.currentTip.length + 10 <= terminalWidth ) { return uiState.currentTip; } } // 2. Shortcut Hint (Fallback) if ( settings.merged.ui.showShortcutsHint && !hideUiDetailsForSuggestions && !hasPendingActionRequired && uiState.buffer.text.length === 0 ) { return showUiDetails ? '? for shortcuts' : 'press tab twice for more'; } return undefined; })(); const tipLength = tipContentStr?.length || 0; const willCollideTip = estimatedStatusLength + tipLength + 5 > terminalWidth; const showTipLine = !hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow; // Mini Mode VIP Flags (Pure Content Triggers) const miniMode_ShowApprovalMode = Boolean(modeContentObj) && !hideUiDetailsForSuggestions; const miniMode_ShowToast = hasToast; const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint; const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks; const miniMode_ShowTip = showTipLine; const miniMode_ShowContext = isContextUsageHigh( uiState.sessionStats.lastPromptTokenCount, uiState.currentModel, settings.merged.model?.compressionThreshold, ); // Composite Mini Mode Triggers const showRow1_MiniMode = miniMode_ShowToast || miniMode_ShowStatus || miniMode_ShowShortcuts || miniMode_ShowTip; const showRow2_MiniMode = miniMode_ShowApprovalMode || miniMode_ShowContext; // Final Display Rules (Stable Footer Architecture) const showRow1 = showUiDetails || showRow1_MiniMode; const showRow2 = showUiDetails || showRow2_MiniMode; const showMinimalBleedThroughRow = !showUiDetails && showRow2_MiniMode; const renderTipNode = () => { if (!tipContentStr) return null; const isShortcutHint = tipContentStr === '? for shortcuts' || tipContentStr === 'press tab twice for more'; const color = isShortcutHint && uiState.shortcutsHelpVisible ? theme.text.accent : theme.text.secondary; return ( {tipContentStr === uiState.currentTip ? `Tip: ${tipContentStr}` : tipContentStr} ); }; const renderStatusNode = () => { const allHooks = uiState.activeHooks; if (allHooks.length === 0 && !showLoadingIndicator) return null; if (allHooks.length > 0) { const userVisibleHooks = allHooks.filter((h) => isUserVisibleHook(h.source), ); let hookText = GENERIC_WORKING_LABEL; if (userVisibleHooks.length > 0) { const label = userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook'; const displayNames = userVisibleHooks.map((h) => { let name = h.name; if (h.index && h.total && h.total > 1) { name += ` (${h.index}/${h.total})`; } return name; }); hookText = `${label}: ${displayNames.join(', ')}`; } return ( ); } return ( ); }; const statusNode = renderStatusNode(); /** * Renders the minimal metadata row content shown when UI details are hidden. */ const renderMinimalMetaRowContent = () => ( {renderStatusNode()} {showMinimalBleedThroughRow && ( {miniMode_ShowApprovalMode && modeContentObj && ( ● {modeContentObj.text} )} )} ); const renderStatusRow = () => { // Mini Mode Height Reservation (The "Anti-Jitter" line) if (!showUiDetails && !showRow1_MiniMode && !showRow2_MiniMode) { return ; } return ( {/* Row 1: multipurpose status (thinking, hooks, wit, tips) */} {showRow1 && ( {!showUiDetails && showRow1_MiniMode ? ( renderMinimalMetaRowContent() ) : isInteractiveShellWaiting ? ( ! Shell awaiting input (Tab to focus) ) : ( {statusNode} )} {!isNarrow && showTipLine && renderTipNode()} )} {/* Internal Separator Line */} {showRow1 && showRow2 && (showUiDetails || (showRow1_MiniMode && showRow2_MiniMode)) && ( )} {/* Row 2: Mode and Context Summary */} {showRow2 && ( {showUiDetails ? ( <> {showApprovalIndicator && ( )} {uiState.shellModeActive && ( )} {showRawMarkdownIndicator && ( )} ) : ( miniMode_ShowApprovalMode && modeContentObj && ( ● {modeContentObj.text} ) )} {(showUiDetails || miniMode_ShowContext) && ( )} {miniMode_ShowContext && !showUiDetails && ( )} )} ); }; return ( {uiState.isResuming && ( )} {showUiDetails && ( )} {showUiDetails && } {showShortcutsHelp && } {(showUiDetails || miniMode_ShowToast) && ( )} {renderStatusRow()} {showUiDetails && uiState.showErrorDetails && ( )} {uiState.isInputActive && ( )} {showUiDetails && !settings.merged.ui.hideFooter && !isScreenReaderEnabled && (