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