diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 797203d7cb..20cf770a54 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -259,7 +259,7 @@ const renderComposer = async ( - + @@ -822,12 +822,16 @@ describe('Composer', () => { describe('Shortcuts Hint', () => { it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => { - const { lastFrame } = await renderComposer( - createMockUIState({ - buffer: { text: '' } as unknown as TextBuffer, - cleanUiDetailsVisible: false, - }), - ); + const uiState = createMockUIState({ + buffer: { text: '' } as unknown as TextBuffer, + cleanUiDetailsVisible: false, + }); + + const { lastFrame } = await renderComposer(uiState); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint'); }); @@ -880,6 +884,10 @@ describe('Composer', () => { const { lastFrame } = await renderComposer(uiState); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + expect(lastFrame()).toContain('ShortcutsHint'); }); @@ -890,6 +898,10 @@ describe('Composer', () => { const { lastFrame } = await renderComposer(uiState); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + expect(lastFrame()).toContain('ShortcutsHint'); }); @@ -901,6 +913,12 @@ describe('Composer', () => { const { lastFrame } = await renderComposer(uiState); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + // In experimental layout, status row is visible during loading + expect(lastFrame()).toContain('LoadingIndicator'); expect(lastFrame()).not.toContain('ShortcutsHint'); }); @@ -911,6 +929,7 @@ describe('Composer', () => { const { lastFrame } = await renderComposer(uiState); + // In experimental layout, shortcuts hint is hidden when text is present expect(lastFrame()).not.toContain('ShortcutsHint'); }); @@ -923,6 +942,12 @@ describe('Composer', () => { const { lastFrame } = await renderComposer(uiState); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + // In experimental layout, status row is visible in clean mode while busy + expect(lastFrame()).toContain('LoadingIndicator'); expect(lastFrame()).not.toContain('ShortcutsHint'); }); @@ -976,6 +1001,10 @@ describe('Composer', () => { const { lastFrame } = await renderComposer(uiState); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + expect(lastFrame()).toContain('ShortcutsHint'); }); }); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 7a9e9d74e2..c487df0c4c 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -1,20 +1,33 @@ /** * @license - * Copyright 2026 Google LLC + * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useMemo } from 'react'; -import { Box, Text, useIsScreenReaderEnabled } from 'ink'; import { ApprovalMode, checkExhaustive, CoreToolCallStatus, } from '@google/gemini-cli-core'; +import { Box, Text, useIsScreenReaderEnabled } from 'ink'; +import type React from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; +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 { getInlineThinkingMode } from '../utils/inlineThinkingMode.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 { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; import { StatusDisplay } from './StatusDisplay.js'; -import { HookStatusDisplay } from './HookStatusDisplay.js'; import { ToastDisplay, shouldShowToast } from './ToastDisplay.js'; import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; @@ -29,31 +42,25 @@ import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { HorizontalLine } from './shared/HorizontalLine.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; -import { isNarrowWidth } from '../utils/isNarrowWidth.js'; -import { useUIState } from '../contexts/UIStateContext.js'; -import { useUIActions } from '../contexts/UIActionsContext.js'; -import { useVimMode } from '../contexts/VimModeContext.js'; -import { useConfig } from '../contexts/ConfigContext.js'; -import { useSettings } from '../contexts/SettingsContext.js'; -import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; -import { StreamingState, type HistoryItemToolGroup } from '../types.js'; -import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; +import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; +import { HookStatusDisplay } from './HookStatusDisplay.js'; +import { ConfigInitDisplay } from './ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; -import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; -import { isContextUsageHigh } from '../utils/contextUsage.js'; -import { theme } from '../semantic-colors.js'; -import { GENERIC_WORKING_LABEL } from '../textConstants.js'; -export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { - const config = useConfig(); - const settings = useSettings(); - const isScreenReaderEnabled = useIsScreenReaderEnabled(); +interface ComposerProps { + isFocused: boolean; +} + +export const Composer: React.FC = ({ isFocused }) => { const uiState = useUIState(); const uiActions = useUIActions(); + const settings = useSettings(); + const config = useConfig(); + const isScreenReaderEnabled = useIsScreenReaderEnabled(); + const { columns: terminalWidth } = useTerminalSize(); + const isNarrow = isNarrowWidth(terminalWidth); const { vimEnabled, vimMode } = useVimMode(); const inlineThinkingMode = getInlineThinkingMode(settings); - const terminalWidth = uiState.terminalWidth; - const isNarrow = isNarrowWidth(terminalWidth); const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5)); const [suggestionsVisible, setSuggestionsVisible] = useState(false); @@ -117,18 +124,51 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { uiState.shortcutsHelpVisible && uiState.streamingState === StreamingState.Idle && !hasPendingActionRequired; - const isInteractiveShellWaiting = - uiState.currentLoadingPhrase?.includes('Tab to focus'); - const hasToast = shouldShowToast(uiState) || isInteractiveShellWaiting; + + const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] = + useState(false); + const canShowShortcutsHint = + uiState.isInputActive && + uiState.streamingState === StreamingState.Idle && + !hasPendingActionRequired && + uiState.buffer.text.length === 0; + + useEffect(() => { + if (!canShowShortcutsHint) { + setShowShortcutsHintDebounced(false); + return; + } + + const timeout = setTimeout(() => { + setShowShortcutsHintDebounced(true); + }, 200); + + return () => clearTimeout(timeout); + }, [canShowShortcutsHint]); + + // 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 as Record)[ + '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: @@ -164,37 +204,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { ? uiState.currentModel : undefined, ); + const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions; - const isModelIdle = uiState.streamingState === StreamingState.Idle; - const isBufferEmpty = uiState.buffer.text.length === 0; - const canShowShortcutsHint = - isModelIdle && isBufferEmpty && !hasPendingActionRequired; - const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] = - useState(canShowShortcutsHint); - - useEffect(() => { - if (!canShowShortcutsHint) { - setShowShortcutsHintDebounced(false); - return; - } - - const timeout = setTimeout(() => { - setShowShortcutsHintDebounced(true); - }, 200); - - return () => clearTimeout(timeout); - }, [canShowShortcutsHint]); - - // 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 as Record)[ - 'collapseDrawerDuringApproval' - ] !== false; - - if (hasPendingActionRequired && shouldCollapseDuringApproval) { - return null; - } const showShortcutsHint = settings.merged.ui.showShortcutsHint && @@ -203,6 +214,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const showMinimalModeBleedThrough = !hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough); const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator; + const hasActiveHooks = + uiState.activeHooks.length > 0 && settings.merged.hooksConfig.notifications; const showMinimalBleedThroughRow = !showUiDetails && (showMinimalModeBleedThrough || @@ -212,7 +225,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { !showUiDetails && (showMinimalInlineLoading || showMinimalBleedThroughRow || - showShortcutsHint); + showShortcutsHint || + hasActiveHooks); let estimatedStatusLength = 0; if ( @@ -241,6 +255,10 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { estimatedStatusLength = 20; // "↑ Action required" } + const isInteractiveShellWaiting = uiState.currentLoadingPhrase?.includes( + INTERACTIVE_SHELL_WAITING_PHRASE, + ); + const ambientText = (() => { if (isInteractiveShellWaiting) return undefined; @@ -317,8 +335,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { }; const renderStatusNode = () => { - if (!showUiDetails) return null; - + // In experimental layout, hooks take priority if ( isExperimentalLayout && uiState.activeHooks.length > 0 && @@ -345,8 +362,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { {!hasUserHooks && showWit && uiState.currentWittyPhrase && ( - - {uiState.currentWittyPhrase} + + {uiState.currentWittyPhrase} :) )} @@ -397,6 +414,188 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const statusNode = renderStatusNode(); const hasStatusMessage = Boolean(statusNode) || hasToast; + const renderExperimentalStatusNode = () => { + if (!showUiDetails && !showMinimalMetaRow) return null; + + return ( + + {!showUiDetails && showMinimalMetaRow && ( + + + {showMinimalInlineLoading && ( + + )} + {hasActiveHooks && ( + + + + + + + + + )} + {showMinimalBleedThroughRow && ( + + {showMinimalModeBleedThrough && minimalModeBleedThrough && ( + + ● {minimalModeBleedThrough.text} + + )} + {hasMinimalStatusBleedThrough && ( + + + + )} + {showMinimalContextBleedThrough && ( + + + + )} + + )} + + {showShortcutsHint && ( + + + + )} + + )} + + {showUiDetails && ( + + + {hasToast ? ( + + {isInteractiveShellWaiting && !shouldShowToast(uiState) ? ( + + ! Shell awaiting input (Tab to focus) + + ) : ( + + )} + + ) : ( + + {statusNode} + + )} + + + {!hasToast && ( + + {renderAmbientNode()} + + )} + + )} + + {showUiDetails && ( + + + {showApprovalIndicator && ( + + )} + {uiState.shellModeActive && ( + + + + )} + {showRawMarkdownIndicator && ( + + + + )} + + + + + + )} + + ); + }; + return ( { {showUiDetails && hasStatusMessage && } - {!isExperimentalLayout ? ( + {isExperimentalLayout ? ( + renderExperimentalStatusNode() + ) : ( { {showUiDetails && showLoadingIndicator && ( )} @@ -481,39 +675,52 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { {showMinimalInlineLoading && ( )} - {showMinimalModeBleedThrough && minimalModeBleedThrough && ( - - ● {minimalModeBleedThrough.text} - + {hasActiveHooks && ( + + + + + + + + )} - {hasMinimalStatusBleedThrough && ( + {showMinimalBleedThroughRow && ( - + {showMinimalModeBleedThrough && + minimalModeBleedThrough && ( + + ● {minimalModeBleedThrough.text} + + )} + {hasMinimalStatusBleedThrough && ( + + + + )} )} @@ -572,7 +779,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { allowPlanMode={uiState.allowPlanMode} /> )} - {!showLoadingIndicator && ( + {!showLoadingIndicator && !hasActiveHooks && ( <> {uiState.shellModeActive && ( @@ -587,7 +794,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { )} - {!showLoadingIndicator && ( + {!showLoadingIndicator && !hasActiveHooks && ( <> · @@ -602,96 +809,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { )} - ) : ( - - {showUiDetails && ( - - {hasToast ? ( - - {isInteractiveShellWaiting && !shouldShowToast(uiState) ? ( - - ! Shell awaiting input (Tab to focus) - - ) : ( - - )} - - ) : ( - <> - - {statusNode} - - - {renderAmbientNode()} - - - )} - - )} - - {showUiDetails && ( - - - {showApprovalIndicator && ( - - )} - {uiState.shellModeActive && ( - - - - )} - {showRawMarkdownIndicator && ( - - - - )} - - - - - - )} - )} @@ -713,7 +830,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { {uiState.isInputActive && ( = ({ currentLoadingPhrase, wittyPhrase, - showWit = true, - showTips: _showTips = true, + showWit: showWitProp, + showTips: _showTipsProp, + loadingPhrases = 'all', + errorVerbosity: _errorVerbosity = 'full', elapsedTime, inline = false, rightContent, @@ -48,6 +52,9 @@ export const LoadingIndicator: React.FC = ({ const { columns: terminalWidth } = useTerminalSize(); const isNarrow = isNarrowWidth(terminalWidth); + const showWit = + showWitProp ?? (loadingPhrases === 'witty' || loadingPhrases === 'all'); + if ( streamingState === StreamingState.Idle && !currentLoadingPhrase && diff --git a/packages/cli/src/ui/components/StatusDisplay.tsx b/packages/cli/src/ui/components/StatusDisplay.tsx index ffbca0bc80..1c66e74a1c 100644 --- a/packages/cli/src/ui/components/StatusDisplay.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.tsx @@ -11,6 +11,7 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { ContextSummaryDisplay } from './ContextSummaryDisplay.js'; +import { HookStatusDisplay } from './HookStatusDisplay.js'; interface StatusDisplayProps { hideContextSummary: boolean; @@ -27,6 +28,20 @@ export const StatusDisplay: React.FC = ({ return |⌐■_■|; } + // In legacy layout, we show hooks here. + // In experimental layout, hooks are shown in the top row of the composer, + // but we still show them here if they are "system" hooks or if notifications are enabled. + const isLegacyLayout = + (settings.merged.ui as Record)['useLegacyLayout'] === true; + + if ( + isLegacyLayout && + uiState.activeHooks.length > 0 && + settings.merged.hooksConfig.notifications + ) { + return ; + } + if (!settings.merged.ui.hideContextSummary && !hideContextSummary) { return ( Snapshots > matches snapshot in idle state 1`] = ` -" ShortcutsHint +" ApprovalModeIndicator ·StatusDisplay InputPrompt: Type your message or @path/to/file Footer @@ -9,20 +9,20 @@ Footer `; exports[`Composer > Snapshots > matches snapshot in minimal UI mode 1`] = ` -" ShortcutsHint +" InputPrompt: Type your message or @path/to/file " `; exports[`Composer > Snapshots > matches snapshot in minimal UI mode while loading 1`] = ` -" LoadingIndicator +" + LoadingIndicator InputPrompt: Type your message or @path/to/file " `; exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = ` " -ShortcutsHint ApprovalModeIndicator ·StatusDisplay InputPrompt: Type your message or @path/to/file @@ -33,6 +33,7 @@ Footer exports[`Composer > Snapshots > matches snapshot while streaming 1`] = ` "──────────────────────────────────────────────────────────────────────────────────────────────────── LoadingIndicator: Thinking + ApprovalModeIndicator InputPrompt: Type your message or @path/to/file Footer diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index d88f3f1fb2..d8b82f1010 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -38,7 +38,7 @@ export const ScreenReaderAppLayout: React.FC = () => { addItem={uiState.historyManager.addItem} /> ) : ( - + )}