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}
/>
) : (
-
+
)}