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 =