mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-26 22:11:57 -07:00
fix(ui): cleanup estimated string length hacks in composer (#23694)
This commit is contained in:
@@ -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}
|
||||
|
||||
424
packages/cli/src/ui/components/StatusRow.tsx
Normal file
424
packages/cli/src/ui/components/StatusRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
110
packages/cli/src/ui/hooks/useComposerStatus.ts
Normal file
110
packages/cli/src/ui/hooks/useComposerStatus.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user