From fe92a43e3118aaa0b6cca0303e08f35ffc6c3427 Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Wed, 25 Mar 2026 12:15:08 -0700 Subject: [PATCH] fix(ui): cleanup estimated string length hacks in composer (#23694) --- packages/cli/src/ui/components/Composer.tsx | 461 +----------------- packages/cli/src/ui/components/StatusRow.tsx | 424 ++++++++++++++++ .../cli/src/ui/hooks/useComposerStatus.ts | 110 +++++ packages/cli/src/ui/hooks/usePhraseCycler.ts | 6 +- 4 files changed, 557 insertions(+), 444 deletions(-) create mode 100644 packages/cli/src/ui/components/StatusRow.tsx create mode 100644 packages/cli/src/ui/hooks/useComposerStatus.ts diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index af6d3b32da..5c9850bf92 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -4,14 +4,8 @@ * 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 { Box, useIsScreenReaderEnabled } from 'ink'; +import { useState, useEffect } from 'react'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; @@ -20,28 +14,18 @@ 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 { StatusRow } from './StatusRow.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'; +import { useComposerStatus } from '../hooks/useComposerStatus.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const uiState = useUIState(); @@ -56,43 +40,17 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { 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 { hasPendingActionRequired, shouldCollapseDuringApproval } = + useComposerStatus(); const isPassiveShortcutsHelpState = uiState.isInputActive && - uiState.streamingState === StreamingState.Idle && + uiState.streamingState === 'idle' && !hasPendingActionRequired; const { setShortcutsHelpVisible } = uiActions; @@ -109,407 +67,19 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const showShortcutsHelp = uiState.shortcutsHelpVisible && - uiState.streamingState === StreamingState.Idle && + uiState.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 && ( - - - - )} - - - )} - - ); - }; + const showMinimalToast = hasToast; return ( { {showShortcutsHelp && } - {(showUiDetails || miniMode_ShowToast) && ( + {(showUiDetails || showMinimalToast) && ( )} - {renderStatusRow()} + {showUiDetails && uiState.showErrorDetails && ( @@ -569,7 +146,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { commandContext={uiState.commandContext} shellModeActive={uiState.shellModeActive} setShellModeActive={uiActions.setShellModeActive} - approvalMode={showApprovalModeIndicator} + approvalMode={uiState.showApprovalModeIndicator} onEscapePromptChange={uiActions.onEscapePromptChange} focus={isFocused} vimHandleInput={uiActions.vimHandleInput} diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx new file mode 100644 index 0000000000..4585438bee --- /dev/null +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -0,0 +1,424 @@ +/** + * @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 && ( + + + + )} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/hooks/useComposerStatus.ts b/packages/cli/src/ui/hooks/useComposerStatus.ts new file mode 100644 index 0000000000..0f82e650aa --- /dev/null +++ b/packages/cli/src/ui/hooks/useComposerStatus.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useMemo } from 'react'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { CoreToolCallStatus, ApprovalMode } from '@google/gemini-cli-core'; +import { type HistoryItemToolGroup, StreamingState } from '../types.js'; +import { INTERACTIVE_SHELL_WAITING_PHRASE } from './usePhraseCycler.js'; +import { isContextUsageHigh } from '../utils/contextUsage.js'; +import { theme } from '../semantic-colors.js'; + +/** + * A hook that encapsulates complex status and action-required logic for the Composer. + */ +export const useComposerStatus = () => { + const uiState = useUIState(); + const settings = useSettings(); + + 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 isInteractiveShellWaiting = Boolean( + uiState.currentLoadingPhrase?.includes(INTERACTIVE_SHELL_WAITING_PHRASE), + ); + + const showLoadingIndicator = + (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && + uiState.streamingState === StreamingState.Responding && + !hasPendingActionRequired; + + const showApprovalModeIndicator = uiState.showApprovalModeIndicator; + + const modeContentObj = useMemo(() => { + const hideMinimalModeHintWhileBusy = + !uiState.cleanUiDetailsVisible && + (showLoadingIndicator || uiState.activeHooks.length > 0); + + if (hideMinimalModeHintWhileBusy) return null; + + switch (showApprovalModeIndicator) { + case ApprovalMode.YOLO: + return { text: 'YOLO', color: theme.status.error }; + case ApprovalMode.PLAN: + return { text: 'plan', color: theme.status.success }; + case ApprovalMode.AUTO_EDIT: + return { text: 'auto edit', color: theme.status.warning }; + case ApprovalMode.DEFAULT: + default: + return null; + } + }, [ + uiState.cleanUiDetailsVisible, + showLoadingIndicator, + uiState.activeHooks.length, + showApprovalModeIndicator, + ]); + + const showMinimalContext = isContextUsageHigh( + uiState.sessionStats.lastPromptTokenCount, + uiState.currentModel, + settings.merged.model?.compressionThreshold, + ); + + const loadingPhrases = settings.merged.ui.loadingPhrases; + const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all'; + const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all'; + + /** + * 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; + + return { + hasPendingActionRequired, + shouldCollapseDuringApproval, + isInteractiveShellWaiting, + showLoadingIndicator, + showTips, + showWit, + modeContentObj, + showMinimalContext, + }; +}; diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 1b82336afe..5bae72f172 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -66,11 +66,11 @@ export const usePhraseCycler = ( if (shouldShowFocusHint || isWaiting) { // These are handled by the return value directly for immediate feedback - return; + return clearTimers; } if (!isActive || (!showTips && !showWit)) { - return; + return clearTimers; } const wittyPhrasesList = @@ -101,6 +101,7 @@ export const usePhraseCycler = ( : INFORMATIVE_TIPS; if (filteredTips.length > 0) { + // codeql[js/insecure-randomness] false positive: used for non-sensitive UI flavor text (tips) const selected = filteredTips[Math.floor(Math.random() * filteredTips.length)]; setCurrentTipState(selected); @@ -132,6 +133,7 @@ export const usePhraseCycler = ( : wittyPhrasesList; if (filteredWitty.length > 0) { + // codeql[js/insecure-randomness] false positive: used for non-sensitive UI flavor text (witty phrases) const selected = filteredWitty[Math.floor(Math.random() * filteredWitty.length)]; setCurrentWittyPhraseState(selected);