From 6f3e28c2a2cd6995c8df280134bc8ef40b03cca7 Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Mon, 23 Mar 2026 21:58:38 -0700 Subject: [PATCH] fix(ui): cleanup estimated string length hacks in composer - Replace arbitrary string length offsets (+25, +10) with accurate `getCachedStringWidth` measurements. - Extract status row rendering logic into a dedicated `StatusRow` component. - Rename `miniMode_` variables to camelCase to adhere to naming conventions. - Ensure `clearTimers` is correctly returned from `usePhraseCycler` cleanup. --- packages/cli/src/ui/components/Composer.tsx | 370 +++--------------- packages/cli/src/ui/components/StatusRow.tsx | 388 +++++++++++++++++++ packages/cli/src/ui/hooks/usePhraseCycler.ts | 4 +- 3 files changed, 436 insertions(+), 326 deletions(-) create mode 100644 packages/cli/src/ui/components/StatusRow.tsx diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 042f50776d..ed6be90735 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -8,9 +8,8 @@ import { ApprovalMode, checkExhaustive, CoreToolCallStatus, - isUserVisibleHook, } from '@google/gemini-cli-core'; -import { Box, Text, useIsScreenReaderEnabled } from 'ink'; +import { Box, useIsScreenReaderEnabled } from 'ink'; import { useState, useEffect, useMemo } from 'react'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; @@ -20,23 +19,27 @@ 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 { getCachedStringWidth } from '../utils/textUtils.js'; + +/** + * Minimum gap between the status indicator and a tip. + */ +const STATUS_TIP_MIN_GAP = 10; + +/** + * Buffer to prevent tip collisions with terminal boundaries. + */ +const TIP_COLLISION_BUFFER = 5; + 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, estimateStatusWidth } from './StatusRow.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; @@ -131,9 +134,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const hideUiDetailsForSuggestions = suggestionsVisible && suggestionsPosition === 'above'; - const showApprovalIndicator = - !uiState.shellModeActive && !hideUiDetailsForSuggestions; - const showRawMarkdownIndicator = !uiState.renderMarkdown; let modeBleedThrough: { text: string; color: string } | null = null; switch (showApprovalModeIndicator) { @@ -161,54 +161,18 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { // 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; - } + const estimatedStatusLength = estimateStatusWidth( + uiState.activeHooks, + showLoadingIndicator, + uiState.thought, + uiState.currentWittyPhrase, + showWit, + Boolean(isInteractiveShellWaiting), + ); /** * Determine the ambient text (tip) to display. @@ -224,7 +188,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { ) ) { if ( - estimatedStatusLength + uiState.currentTip.length + 10 <= + estimatedStatusLength + + getCachedStringWidth(uiState.currentTip) + + STATUS_TIP_MIN_GAP <= terminalWidth ) { return uiState.currentTip; @@ -244,272 +210,16 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { return undefined; })(); - const tipLength = tipContentStr?.length || 0; - const willCollideTip = estimatedStatusLength + tipLength + 5 > terminalWidth; + const tipLength = tipContentStr ? getCachedStringWidth(tipContentStr) : 0; + const willCollideTip = + estimatedStatusLength + tipLength + TIP_COLLISION_BUFFER > terminalWidth; - const showTipLine = - !hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow; + const showTipLine = Boolean( + !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 && ( diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx new file mode 100644 index 0000000000..0da87043d6 --- /dev/null +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -0,0 +1,388 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { + isUserVisibleHook, + type ThoughtSummary, +} from '@google/gemini-cli-core'; +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 { isContextUsageHigh } from '../utils/contextUsage.js'; +import { getCachedStringWidth } from '../utils/textUtils.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'; + +/** + * Overhead for the status indicator (spinner, padding). + */ +const STATUS_INDICATOR_OVERHEAD = 5; + +export const estimateStatusWidth = ( + activeHooks: ActiveHook[], + showLoadingIndicator: boolean, + thought: ThoughtSummary | null, + currentWittyPhrase: string | undefined, + showWit: boolean, + isInteractiveShellWaiting: boolean, +): number => { + if (isInteractiveShellWaiting) { + return getCachedStringWidth(INTERACTIVE_SHELL_WAITING_PHRASE); + } + + // Estimate timer length: "(esc to cancel, 99s)" is ~20 chars + const timerEstimate = ' (esc to cancel, 99s)'; + + if (activeHooks.length > 0) { + const userVisibleHooks = activeHooks.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 ( + getCachedStringWidth(hookText) + + timerEstimate.length + + STATUS_INDICATOR_OVERHEAD + ); + } + + if (showLoadingIndicator) { + const thoughtText = thought?.subject || GENERIC_WORKING_LABEL; + const thinkingIndicator = + thought?.subject && !thoughtText.startsWith('Thinking') + ? 'Thinking... ' + : ''; + const wittyText = + showWit && currentWittyPhrase ? ` ${currentWittyPhrase}` : ''; + return ( + getCachedStringWidth(thinkingIndicator + thoughtText + wittyText) + + timerEstimate.length + + STATUS_INDICATOR_OVERHEAD + ); + } + + return 0; +}; + +interface StatusRowProps { + showUiDetails: boolean; + isNarrow: boolean; + terminalWidth: number; + showTips: boolean; + showWit: boolean; + tipContentStr: string | undefined; + showTipLine: boolean; + estimatedStatusLength: number; + hideContextSummary: boolean; + modeContentObj: { text: string; color: string } | null; + hideUiDetailsForSuggestions: boolean; +} + +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; +}> = ({ + showTips, + showWit, + thought, + elapsedTime, + currentWittyPhrase, + activeHooks, + showLoadingIndicator, + errorVerbosity, +}) => { + 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 = 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 { + currentThought = thought; + } + + return ( + + ); +}; + +export const StatusRow: React.FC = ({ + showUiDetails, + isNarrow, + terminalWidth, + showTips, + showWit, + tipContentStr, + showTipLine, + hideContextSummary, + modeContentObj, + hideUiDetailsForSuggestions, +}) => { + const uiState = useUIState(); + const settings = useSettings(); + + const isInteractiveShellWaiting = uiState.currentLoadingPhrase?.includes( + INTERACTIVE_SHELL_WAITING_PHRASE, + ); + + const showLoadingIndicator = + (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && + uiState.streamingState === 'responding' && + !( + uiState.pendingHistoryItems?.some( + (item) => + item.type === 'tool_group' && + item.tools.some((t) => t.status === 'awaiting_approval'), + ) || + uiState.commandConfirmationRequest || + uiState.authConsentRequest || + (uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 || + uiState.loopDetectionConfirmationRequest || + uiState.quota.proQuotaRequest || + uiState.quota.validationRequest || + uiState.customDialog + ); + + const hasAnyHooks = uiState.activeHooks.length > 0; + + const showMinimalStatus = showLoadingIndicator || hasAnyHooks; + + const showMinimalApprovalMode = + Boolean(modeContentObj) && !hideUiDetailsForSuggestions; + + const showMinimalContext = isContextUsageHigh( + uiState.sessionStats.lastPromptTokenCount, + uiState.currentModel, + settings.merged.model?.compressionThreshold, + ); + + const showRow1Minimal = showMinimalStatus || showTipLine; + const showRow2Minimal = showMinimalApprovalMode || 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 ( + + {showRow1 && ( + + + {!showUiDetails && showRow1Minimal ? ( + + {statusNode} + {!showUiDetails && showRow2Minimal && modeContentObj && ( + + + ● {modeContentObj.text} + + + )} + + ) : isInteractiveShellWaiting ? ( + + + ! Shell awaiting input (Tab to focus) + + + ) : ( + + {statusNode} + + )} + + + + {!isNarrow && showTipLine && renderTipNode()} + + + )} + + {showRow1 && + showRow2 && + (showUiDetails || (showRow1Minimal && showRow2Minimal)) && ( + + + + )} + + {showRow2 && ( + + + {showUiDetails ? ( + <> + {!hideUiDetailsForSuggestions && !uiState.shellModeActive && ( + + )} + {uiState.shellModeActive && ( + + + + )} + {!uiState.renderMarkdown && ( + + + + )} + + ) : ( + showMinimalApprovalMode && + modeContentObj && ( + + ● {modeContentObj.text} + + ) + )} + + + {(showUiDetails || showMinimalContext) && ( + + )} + {showMinimalContext && !showUiDetails && ( + + + + )} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 1b82336afe..f97e72b722 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 =