/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useCallback, useRef, useState } from 'react'; import { Box, Text, ResizeObserver, type DOMElement } from 'ink'; import { isUserVisibleHook, type ThoughtSummary, } from '@google/gemini-cli-core'; import stripAnsi from 'strip-ansi'; import { type ActiveHook } from '../types.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { theme } from '../semantic-colors.js'; import { GENERIC_WORKING_LABEL } from '../textConstants.js'; import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js'; import { LoadingIndicator } from './LoadingIndicator.js'; import { StatusDisplay } from './StatusDisplay.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { HorizontalLine } from './shared/HorizontalLine.js'; import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; import { useComposerStatus } from '../hooks/useComposerStatus.js'; /** * Layout constants to prevent magic numbers. */ const LAYOUT = { STATUS_MIN_HEIGHT: 1, TIP_LEFT_MARGIN: 2, TIP_RIGHT_MARGIN_NARROW: 0, TIP_RIGHT_MARGIN_WIDE: 1, INDICATOR_LEFT_MARGIN: 1, CONTEXT_DISPLAY_TOP_MARGIN_NARROW: 1, CONTEXT_DISPLAY_LEFT_MARGIN_NARROW: 1, CONTEXT_DISPLAY_LEFT_MARGIN_WIDE: 0, COLLISION_GAP: 10, }; interface StatusRowProps { showUiDetails: boolean; isNarrow: boolean; terminalWidth: number; hideContextSummary: boolean; hideUiDetailsForSuggestions: boolean; hasPendingActionRequired: boolean; } /** * Renders the loading or hook execution status. */ export const StatusNode: React.FC<{ showTips: boolean; showWit: boolean; thought: ThoughtSummary | null; elapsedTime: number; currentWittyPhrase: string | undefined; activeHooks: ActiveHook[]; showLoadingIndicator: boolean; errorVerbosity: 'low' | 'full' | undefined; onResize?: (width: number) => void; }> = ({ showTips, showWit, thought, elapsedTime, currentWittyPhrase, activeHooks, showLoadingIndicator, errorVerbosity, onResize, }) => { const observerRef = useRef(null); const onRefChange = useCallback( (node: DOMElement | null) => { if (observerRef.current) { observerRef.current.disconnect(); observerRef.current = null; } if (node && onResize) { const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) { onResize(Math.round(entry.contentRect.width)); } }); observer.observe(node); observerRef.current = observer; } }, [onResize], ); if (activeHooks.length === 0 && !showLoadingIndicator) return null; let currentLoadingPhrase: string | undefined = undefined; let currentThought: ThoughtSummary | null = null; if (activeHooks.length > 0) { const userVisibleHooks = activeHooks.filter((h) => isUserVisibleHook(h.source), ); if (userVisibleHooks.length > 0) { const label = userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook'; const displayNames = userVisibleHooks.map((h) => { let name = stripAnsi(h.name); if (h.index && h.total && h.total > 1) { name += ` (${h.index}/${h.total})`; } return name; }); currentLoadingPhrase = `${label}: ${displayNames.join(', ')}`; } else { currentLoadingPhrase = GENERIC_WORKING_LABEL; } } else { // Sanitize thought subject to prevent terminal injection currentThought = thought ? { ...thought, subject: stripAnsi(thought.subject) } : null; } return ( ); }; export const StatusRow: React.FC = ({ showUiDetails, isNarrow, terminalWidth, hideContextSummary, hideUiDetailsForSuggestions, hasPendingActionRequired, }) => { const uiState = useUIState(); const settings = useSettings(); const { isInteractiveShellWaiting, showLoadingIndicator, showTips, showWit, modeContentObj, showMinimalContext, } = useComposerStatus(); const [statusWidth, setStatusWidth] = useState(0); const [tipWidth, setTipWidth] = useState(0); const tipObserverRef = useRef(null); const onTipRefChange = useCallback((node: DOMElement | null) => { if (tipObserverRef.current) { tipObserverRef.current.disconnect(); tipObserverRef.current = null; } if (node) { const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) { setTipWidth(Math.round(entry.contentRect.width)); } }); observer.observe(node); tipObserverRef.current = observer; } }, []); const tipContentStr = (() => { // 1. Proactive Tip (Priority) if ( showTips && uiState.currentTip && !( isInteractiveShellWaiting && uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE ) ) { 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; })(); // Collision detection using measured widths const willCollideTip = statusWidth + tipWidth + LAYOUT.COLLISION_GAP > terminalWidth; const showTipLine = Boolean( !hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow, ); const showRow1Minimal = showLoadingIndicator || uiState.activeHooks.length > 0 || showTipLine; const showRow2Minimal = (Boolean(modeContentObj) && !hideUiDetailsForSuggestions) || showMinimalContext; const showRow1 = showUiDetails || showRow1Minimal; const showRow2 = showUiDetails || showRow2Minimal; const statusNode = ( ); 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} ); }; if (!showUiDetails && !showRow1Minimal && !showRow2Minimal) { return ; } return ( {/* Row 1: Status & Tips */} {showRow1 && ( {!showUiDetails && showRow1Minimal ? ( {statusNode} {!showUiDetails && showRow2Minimal && modeContentObj && ( ● {modeContentObj.text} )} ) : isInteractiveShellWaiting ? ( ! Shell awaiting input (Tab to focus) ) : ( {statusNode} )} {/* We always render the tip node so it can be measured by ResizeObserver, but we control its visibility based on the collision detection. */} {!isNarrow && tipContentStr && renderTipNode()} )} {/* Internal Separator */} {showRow1 && showRow2 && (showUiDetails || (showRow1Minimal && showRow2Minimal)) && ( )} {/* Row 2: Modes & Context */} {showRow2 && ( {showUiDetails ? ( <> {!hideUiDetailsForSuggestions && !uiState.shellModeActive && ( )} {uiState.shellModeActive && ( )} {!uiState.renderMarkdown && ( )} ) : ( showRow2Minimal && modeContentObj && ( ● {modeContentObj.text} ) )} {(showUiDetails || showMinimalContext) && ( )} {showMinimalContext && !showUiDetails && ( )} )} ); };