fix(ui): cleanup estimated string length hacks in composer (#23694)

This commit is contained in:
Keith Guerin
2026-03-25 12:15:08 -07:00
committed by GitHub
parent 830f7dec61
commit fe92a43e31
4 changed files with 557 additions and 444 deletions

View File

@@ -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 (
<Box flexDirection="row" justifyContent="flex-end">
<Text
color={color}
wrap="truncate-end"
italic={
!isShortcutHint && tipContentStr === uiState.currentWittyPhrase
}
>
{tipContentStr === uiState.currentTip
? `Tip: ${tipContentStr}`
: tipContentStr}
</Text>
</Box>
);
};
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 (
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={settings.merged.ui.errorVerbosity}
currentLoadingPhrase={hookText}
elapsedTime={uiState.elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={uiState.currentWittyPhrase}
/>
);
}
return (
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={settings.merged.ui.errorVerbosity}
thought={uiState.thought}
elapsedTime={uiState.elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={uiState.currentWittyPhrase}
/>
);
};
const statusNode = renderStatusNode();
/**
* Renders the minimal metadata row content shown when UI details are hidden.
*/
const renderMinimalMetaRowContent = () => (
<Box flexDirection="row" columnGap={1}>
{renderStatusNode()}
{showMinimalBleedThroughRow && (
<Box>
{miniMode_ShowApprovalMode && modeContentObj && (
<Text color={modeContentObj.color}> {modeContentObj.text}</Text>
)}
</Box>
)}
</Box>
);
const renderStatusRow = () => {
// Mini Mode Height Reservation (The "Anti-Jitter" line)
if (!showUiDetails && !showRow1_MiniMode && !showRow2_MiniMode) {
return <Box height={1} />;
}
return (
<Box flexDirection="column" width="100%">
{/* Row 1: multipurpose status (thinking, hooks, wit, tips) */}
{showRow1 && (
<Box
width="100%"
flexDirection="row"
alignItems="center"
justifyContent="space-between"
minHeight={1}
>
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
{!showUiDetails && showRow1_MiniMode ? (
renderMinimalMetaRowContent()
) : isInteractiveShellWaiting ? (
<Box width="100%" marginLeft={1}>
<Text color={theme.status.warning}>
! Shell awaiting input (Tab to focus)
</Text>
</Box>
) : (
<Box
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
flexShrink={0}
marginLeft={1}
>
{statusNode}
</Box>
)}
</Box>
<Box flexShrink={0} marginLeft={2} marginRight={isNarrow ? 0 : 1}>
{!isNarrow && showTipLine && renderTipNode()}
</Box>
</Box>
)}
{/* Internal Separator Line */}
{showRow1 &&
showRow2 &&
(showUiDetails || (showRow1_MiniMode && showRow2_MiniMode)) && (
<Box width="100%">
<HorizontalLine dim />
</Box>
)}
{/* Row 2: Mode and Context Summary */}
{showRow2 && (
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent="space-between"
>
<Box flexDirection="row" alignItems="center" marginLeft={1}>
{showUiDetails ? (
<>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{uiState.shellModeActive && (
<Box
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
>
<ShellModeIndicator />
</Box>
)}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator || uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator || uiState.shellModeActive) &&
isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
</>
) : (
miniMode_ShowApprovalMode &&
modeContentObj && (
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
)
)}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="row"
alignItems="center"
marginLeft={isNarrow ? 1 : 0}
>
{(showUiDetails || miniMode_ShowContext) && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
{miniMode_ShowContext && !showUiDetails && (
<Box marginLeft={1}>
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={
typeof uiState.currentModel === 'string'
? uiState.currentModel
: undefined
}
terminalWidth={uiState.terminalWidth}
/>
</Box>
)}
</Box>
</Box>
)}
</Box>
);
};
const showMinimalToast = hasToast;
return (
<Box
@@ -530,14 +100,21 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
{showShortcutsHelp && <ShortcutsHelp />}
{(showUiDetails || miniMode_ShowToast) && (
{(showUiDetails || showMinimalToast) && (
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
<ToastDisplay />
</Box>
)}
<Box width="100%" flexDirection="column">
{renderStatusRow()}
<StatusRow
showUiDetails={showUiDetails}
isNarrow={isNarrow}
terminalWidth={terminalWidth}
hideContextSummary={hideContextSummary}
hideUiDetailsForSuggestions={hideUiDetailsForSuggestions}
hasPendingActionRequired={hasPendingActionRequired}
/>
</Box>
{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}

View File

@@ -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<ResizeObserver | null>(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 (
<Box ref={onRefChange}>
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={errorVerbosity}
thought={currentThought}
currentLoadingPhrase={currentLoadingPhrase}
elapsedTime={elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={currentWittyPhrase}
/>
</Box>
);
};
export const StatusRow: React.FC<StatusRowProps> = ({
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<ResizeObserver | null>(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 = (
<StatusNode
showTips={showTips}
showWit={showWit}
thought={uiState.thought}
elapsedTime={uiState.elapsedTime}
currentWittyPhrase={uiState.currentWittyPhrase}
activeHooks={uiState.activeHooks}
showLoadingIndicator={showLoadingIndicator}
errorVerbosity={
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
}
onResize={setStatusWidth}
/>
);
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 (
<Box flexDirection="row" justifyContent="flex-end" ref={onTipRefChange}>
<Text
color={color}
wrap="truncate-end"
italic={
!isShortcutHint && tipContentStr === uiState.currentWittyPhrase
}
>
{tipContentStr === uiState.currentTip
? `Tip: ${tipContentStr}`
: tipContentStr}
</Text>
</Box>
);
};
if (!showUiDetails && !showRow1Minimal && !showRow2Minimal) {
return <Box height={LAYOUT.STATUS_MIN_HEIGHT} />;
}
return (
<Box flexDirection="column" width="100%">
{/* Row 1: Status & Tips */}
{showRow1 && (
<Box
width="100%"
flexDirection="row"
alignItems="center"
justifyContent="space-between"
minHeight={LAYOUT.STATUS_MIN_HEIGHT}
>
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
{!showUiDetails && showRow1Minimal ? (
<Box flexDirection="row" columnGap={1}>
{statusNode}
{!showUiDetails && showRow2Minimal && modeContentObj && (
<Box>
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
</Box>
)}
</Box>
) : isInteractiveShellWaiting ? (
<Box width="100%" marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<Text color={theme.status.warning}>
! Shell awaiting input (Tab to focus)
</Text>
</Box>
) : (
<Box
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
flexShrink={0}
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
>
{statusNode}
</Box>
)}
</Box>
<Box
flexShrink={0}
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
marginRight={
isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
}
>
{/*
We always render the tip node so it can be measured by ResizeObserver,
but we control its visibility based on the collision detection.
*/}
<Box display={showTipLine ? 'flex' : 'none'}>
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
</Box>
</Box>
)}
{/* Internal Separator */}
{showRow1 &&
showRow2 &&
(showUiDetails || (showRow1Minimal && showRow2Minimal)) && (
<Box width="100%">
<HorizontalLine dim />
</Box>
)}
{/* Row 2: Modes & Context */}
{showRow2 && (
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent="space-between"
>
<Box
flexDirection="row"
alignItems="center"
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
>
{showUiDetails ? (
<>
{!hideUiDetailsForSuggestions && !uiState.shellModeActive && (
<ApprovalModeIndicator
approvalMode={uiState.showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{uiState.shellModeActive && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<ShellModeIndicator />
</Box>
)}
{!uiState.renderMarkdown && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<RawMarkdownIndicator />
</Box>
)}
</>
) : (
showRow2Minimal &&
modeContentObj && (
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
)
)}
</Box>
<Box
marginTop={isNarrow ? LAYOUT.CONTEXT_DISPLAY_TOP_MARGIN_NARROW : 0}
flexDirection="row"
alignItems="center"
marginLeft={
isNarrow
? LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_NARROW
: LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_WIDE
}
>
{(showUiDetails || showMinimalContext) && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
{showMinimalContext && !showUiDetails && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={
typeof uiState.currentModel === 'string'
? uiState.currentModel
: undefined
}
terminalWidth={terminalWidth}
/>
</Box>
)}
</Box>
</Box>
)}
</Box>
);
};

View File

@@ -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,
};
};

View File

@@ -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);