diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts
index 53d75bd436..a12fb68ce3 100644
--- a/packages/cli/src/config/settingsSchema.test.ts
+++ b/packages/cli/src/config/settingsSchema.test.ts
@@ -83,19 +83,6 @@ describe('SettingsSchema', () => {
).toBe('boolean');
});
- it('should have loadingPhrases enum property', () => {
- const definition = getSettingsSchema().ui?.properties?.loadingPhrases;
- expect(definition).toBeDefined();
- expect(definition?.type).toBe('enum');
- expect(definition?.default).toBe('tips');
- expect(definition?.options?.map((o) => o.value)).toEqual([
- 'tips',
- 'witty',
- 'all',
- 'off',
- ]);
- });
-
it('should have errorVerbosity enum property', () => {
const definition = getSettingsSchema().ui?.properties?.errorVerbosity;
expect(definition).toBeDefined();
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 0bfdeba120..a83654abf5 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -1395,7 +1395,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
!isResuming &&
!!slashCommands &&
(streamingState === StreamingState.Idle ||
- streamingState === StreamingState.Responding) &&
+ streamingState === StreamingState.Responding ||
+ streamingState === StreamingState.WaitingForConfirmation) &&
!proQuotaRequest;
const [controlsHeight, setControlsHeight] = useState(0);
@@ -1661,15 +1662,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
[handleSlashCommand, settings],
);
- const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({
- streamingState,
- shouldShowFocusHint,
- retryStatus,
- loadingPhrasesMode: settings.merged.ui.loadingPhrases,
- customWittyPhrases: settings.merged.ui.customWittyPhrases,
- errorVerbosity: settings.merged.ui.errorVerbosity,
- });
-
const handleGlobalKeypress = useCallback(
(key: Key): boolean => {
// Debug log keystrokes if enabled
@@ -2059,6 +2051,45 @@ Logging in with Google... Restarting Gemini CLI to continue.
!!emptyWalletRequest ||
!!customDialog;
+ const loadingPhrases = settings.merged.ui.loadingPhrases;
+
+ const showLoadingIndicator =
+ (!embeddedShellFocused || isBackgroundShellVisible) &&
+ streamingState === StreamingState.Responding &&
+ !hasPendingActionRequired;
+
+ let estimatedStatusLength = 0;
+ if (activeHooks.length > 0 && settings.merged.hooksConfig.notifications) {
+ const hookLabel =
+ activeHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
+ const hookNames = activeHooks
+ .map(
+ (h) =>
+ h.name +
+ (h.index && h.total && h.total > 1 ? ` (${h.index}/${h.total})` : ''),
+ )
+ .join(', ');
+ estimatedStatusLength = hookLabel.length + hookNames.length + 10;
+ } else if (showLoadingIndicator) {
+ const thoughtText = thought?.subject || 'Waiting for model...';
+ estimatedStatusLength = thoughtText.length + 25;
+ } else if (hasPendingActionRequired) {
+ estimatedStatusLength = 35;
+ }
+
+ const maxLength = terminalWidth - estimatedStatusLength - 5;
+
+ const { elapsedTime, currentLoadingPhrase, currentTip, currentWittyPhrase } =
+ useLoadingIndicator({
+ streamingState,
+ shouldShowFocusHint,
+ retryStatus,
+ loadingPhrasesMode: loadingPhrases,
+ customWittyPhrases: settings.merged.ui.customWittyPhrases,
+ errorVerbosity: settings.merged.ui.errorVerbosity,
+ maxLength,
+ });
+
const allowPlanMode =
config.isPlanEnabled() &&
streamingState === StreamingState.Idle &&
@@ -2245,6 +2276,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isFocused,
elapsedTime,
currentLoadingPhrase,
+ currentTip,
+ currentWittyPhrase,
historyRemountKey,
activeHooks,
messageQueue,
@@ -2373,6 +2406,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isFocused,
elapsedTime,
currentLoadingPhrase,
+ currentTip,
+ currentWittyPhrase,
historyRemountKey,
activeHooks,
messageQueue,
diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap
index 9e1d66df01..ec15357df2 100644
--- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap
+++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap
@@ -36,6 +36,7 @@ Tips for getting started:
+
Notifications
@@ -98,6 +99,7 @@ exports[`App > Snapshots > renders with dialogs visible 1`] = `
+
Notifications
@@ -131,6 +133,8 @@ HistoryItemDisplay
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
+│ Enter to select · ↑/↓ to navigate · Esc to cancel │
+│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -142,7 +146,6 @@ HistoryItemDisplay
-
Notifications
Composer
"
diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx
index 84f8d15a06..af53d39c50 100644
--- a/packages/cli/src/ui/components/Composer.test.tsx
+++ b/packages/cli/src/ui/components/Composer.test.tsx
@@ -17,13 +17,6 @@ import {
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
-// Mock VimModeContext hook
-vi.mock('../contexts/VimModeContext.js', () => ({
- useVimMode: vi.fn(() => ({
- vimEnabled: false,
- vimMode: 'INSERT',
- })),
-}));
import {
ApprovalMode,
tokenLimit,
@@ -36,6 +29,21 @@ import type { LoadedSettings } from '../../config/settings.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import type { TextBuffer } from './shared/text-buffer.js';
+// Mock VimModeContext hook
+vi.mock('../contexts/VimModeContext.js', () => ({
+ useVimMode: vi.fn(() => ({
+ vimEnabled: false,
+ vimMode: 'INSERT',
+ })),
+}));
+
+vi.mock('../hooks/useTerminalSize.js', () => ({
+ useTerminalSize: vi.fn(() => ({
+ columns: 100,
+ rows: 24,
+ })),
+}));
+
const composerTestControls = vi.hoisted(() => ({
suggestionsVisible: false,
isAlternateBuffer: false,
@@ -58,18 +66,9 @@ vi.mock('./LoadingIndicator.js', () => ({
}));
vi.mock('./StatusDisplay.js', () => ({
- StatusDisplay: () => StatusDisplay,
-}));
-
-vi.mock('./ToastDisplay.js', () => ({
- ToastDisplay: () => ToastDisplay,
- shouldShowToast: (uiState: UIState) =>
- uiState.ctrlCPressedOnce ||
- Boolean(uiState.transientMessage) ||
- uiState.ctrlDPressedOnce ||
- (uiState.showEscapePrompt &&
- (uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
- Boolean(uiState.queueErrorMessage),
+ StatusDisplay: ({ hideContextSummary }: { hideContextSummary: boolean }) => (
+ StatusDisplay{hideContextSummary ? ' (hidden summary)' : ''}
+ ),
}));
vi.mock('./ContextSummaryDisplay.js', () => ({
@@ -81,7 +80,9 @@ vi.mock('./HookStatusDisplay.js', () => ({
}));
vi.mock('./ApprovalModeIndicator.js', () => ({
- ApprovalModeIndicator: () => ApprovalModeIndicator,
+ ApprovalModeIndicator: ({ approvalMode }: { approvalMode: ApprovalMode }) => (
+ ApprovalModeIndicator: {approvalMode}
+ ),
}));
vi.mock('./ShellModeIndicator.js', () => ({
@@ -174,6 +175,8 @@ const createMockUIState = (overrides: Partial = {}): UIState =>
isFocused: true,
thought: '',
currentLoadingPhrase: '',
+ currentTip: '',
+ currentWittyPhrase: '',
elapsedTime: 0,
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
@@ -202,6 +205,7 @@ const createMockUIState = (overrides: Partial = {}): UIState =>
activeHooks: [],
isBackgroundShellVisible: false,
embeddedShellFocused: false,
+ showIsExpandableHint: false,
quota: {
userTier: undefined,
stats: undefined,
@@ -248,7 +252,7 @@ const createMockConfig = (overrides = {}): Config =>
const renderComposer = async (
uiState: UIState,
- settings = createMockSettings(),
+ settings = createMockSettings({ ui: {} }),
config = createMockConfig(),
uiActions = createMockUIActions(),
) => {
@@ -257,7 +261,7 @@ const renderComposer = async (
-
+
@@ -385,7 +389,9 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState, settings);
const output = lastFrame();
- expect(output).toContain('LoadingIndicator: Thinking...');
+ // In Refreshed UX, we don't force 'Thinking...' label in renderStatusNode
+ // It uses the subject directly
+ expect(output).toContain('LoadingIndicator: Thinking about code');
});
it('hides shortcuts hint while loading', async () => {
@@ -455,9 +461,8 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
- const output = lastFrame();
- expect(output).not.toContain('LoadingIndicator');
- expect(output).not.toContain('esc to cancel');
+ const output = lastFrame({ allowEmpty: true });
+ expect(output).toBe('');
});
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', async () => {
@@ -560,8 +565,10 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
- expect(output).toContain('ToastDisplay');
- expect(output).not.toContain('ApprovalModeIndicator');
+ expect(output).toContain('Press Ctrl+C again to exit.');
+ // In Refreshed UX, Row 1 shows toast, and Row 2 shows ApprovalModeIndicator/StatusDisplay
+ // They are no longer mutually exclusive.
+ expect(output).toContain('ApprovalModeIndicator');
expect(output).toContain('StatusDisplay');
});
@@ -576,8 +583,8 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
- expect(output).toContain('ToastDisplay');
- expect(output).not.toContain('ApprovalModeIndicator');
+ expect(output).toContain('Warning');
+ expect(output).toContain('ApprovalModeIndicator');
});
});
@@ -586,15 +593,16 @@ describe('Composer', () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
});
+ const settings = createMockSettings({
+ ui: { showShortcutsHint: false },
+ });
- const { lastFrame } = await renderComposer(uiState);
+ const { lastFrame } = await renderComposer(uiState, settings);
const output = lastFrame();
- expect(output).toContain('ShortcutsHint');
+ expect(output).not.toContain('ShortcutsHint');
expect(output).toContain('InputPrompt');
expect(output).not.toContain('Footer');
- expect(output).not.toContain('ApprovalModeIndicator');
- expect(output).not.toContain('ContextSummaryDisplay');
});
it('renders InputPrompt when input is active', async () => {
@@ -667,12 +675,15 @@ describe('Composer', () => {
});
it.each([
- [ApprovalMode.YOLO, 'YOLO'],
- [ApprovalMode.PLAN, 'plan'],
- [ApprovalMode.AUTO_EDIT, 'auto edit'],
+ { mode: ApprovalMode.YOLO, label: '● YOLO' },
+ { mode: ApprovalMode.PLAN, label: '● plan' },
+ {
+ mode: ApprovalMode.AUTO_EDIT,
+ label: '● auto edit',
+ },
])(
- 'shows minimal mode badge "%s" when clean UI details are hidden',
- async (mode, label) => {
+ 'shows minimal mode badge "$mode" when clean UI details are hidden',
+ async ({ mode, label }) => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showApprovalModeIndicator: mode,
@@ -710,9 +721,7 @@ describe('Composer', () => {
});
const { lastFrame } = await renderComposer(uiState);
- const output = lastFrame();
- expect(output).not.toContain('plan');
- expect(output).not.toContain('ShortcutsHint');
+ expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('shows Esc rewind prompt in minimal mode without showing full UI', async () => {
@@ -724,7 +733,7 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
- expect(output).toContain('ToastDisplay');
+ expect(output).toContain('Press Esc again to rewind.');
expect(output).not.toContain('ContextSummaryDisplay');
});
@@ -749,7 +758,13 @@ describe('Composer', () => {
});
const { lastFrame } = await renderComposer(uiState, settings);
- expect(lastFrame()).toContain('%');
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(250);
+ });
+
+ // In Refreshed UX, bleed-through is handled by StatusDisplay in Row 2
+ expect(lastFrame()).toContain('StatusDisplay');
});
});
@@ -821,12 +836,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');
});
@@ -865,9 +884,10 @@ describe('Composer', () => {
),
});
- const { lastFrame } = await renderComposer(uiState);
+ const { lastFrame, unmount } = await renderComposer(uiState);
- expect(lastFrame()).not.toContain('ShortcutsHint');
+ expect(lastFrame({ allowEmpty: true })).toBe('');
+ unmount();
});
it('keeps shortcuts hint visible when no action is required', async () => {
@@ -877,6 +897,10 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(250);
+ });
+
expect(lastFrame()).toContain('ShortcutsHint');
});
@@ -887,6 +911,11 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(250);
+ });
+
+ // In Refreshed UX, shortcuts hint is in the top multipurpose status row
expect(lastFrame()).toContain('ShortcutsHint');
});
@@ -898,6 +927,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');
});
@@ -910,6 +945,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');
});
@@ -963,6 +1004,11 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(250);
+ });
+
+ // In Refreshed UX, shortcuts hint is in the top status row and doesn't collide with suggestions below
expect(lastFrame()).toContain('ShortcutsHint');
});
});
@@ -991,24 +1037,22 @@ describe('Composer', () => {
expect(lastFrame()).not.toContain('ShortcutsHelp');
unmount();
});
-
it('hides shortcuts help when action is required', async () => {
const uiState = createMockUIState({
shortcutsHelpVisible: true,
customDialog: (
- Dialog content
+ Test Dialog
),
});
const { lastFrame, unmount } = await renderComposer(uiState);
- expect(lastFrame()).not.toContain('ShortcutsHelp');
+ expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
-
describe('Snapshots', () => {
it('matches snapshot in idle state', async () => {
const uiState = createMockUIState();
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index 0864b8f02b..2e25c94575 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -4,13 +4,26 @@
* 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 { 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 { 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 { StatusDisplay } from './StatusDisplay.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
@@ -24,38 +37,30 @@ import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
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';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
- const config = useConfig();
- const settings = useSettings();
- const isScreenReaderEnabled = useIsScreenReaderEnabled();
const uiState = useUIState();
const uiActions = useUIActions();
+ const settings = useSettings();
+ const config = useConfig();
const { vimEnabled, vimMode } = useVimMode();
- const inlineThinkingMode = getInlineThinkingMode(settings);
- const terminalWidth = uiState.terminalWidth;
+ const isScreenReaderEnabled = useIsScreenReaderEnabled();
+ const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
const isAlternateBuffer = useAlternateBuffer();
const { showApprovalModeIndicator } = uiState;
+ 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 =
@@ -84,6 +89,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
Boolean(uiState.quota.proQuotaRequest) ||
Boolean(uiState.quota.validationRequest) ||
Boolean(uiState.customDialog);
+
const isPassiveShortcutsHelpState =
uiState.isInputActive &&
uiState.streamingState === StreamingState.Idle &&
@@ -105,16 +111,53 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
uiState.shortcutsHelpVisible &&
uiState.streamingState === StreamingState.Idle &&
!hasPendingActionRequired;
+
+ 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:
@@ -137,10 +180,22 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const hideMinimalModeHintWhileBusy =
!showUiDetails && (showLoadingIndicator || hasPendingActionRequired);
- const minimalModeBleedThrough = hideMinimalModeHintWhileBusy
- ? null
- : modeBleedThrough;
- const hasMinimalStatusBleedThrough = shouldShowToast(uiState);
+
+ // Universal Content Objects
+ const modeContentObj = hideMinimalModeHintWhileBusy ? null : modeBleedThrough;
+
+ const USER_HOOK_SOURCES = ['user', 'project', 'runtime'];
+ const userHooks = uiState.activeHooks.filter(
+ (h) => !h.source || USER_HOOK_SOURCES.includes(h.source),
+ );
+ const hasUserHooks =
+ userHooks.length > 0 && settings.merged.hooksConfig.notifications;
+
+ const shouldReserveSpaceForShortcutsHint =
+ settings.merged.ui.showShortcutsHint && !hideUiDetailsForSuggestions;
+
+ const showShortcutsHint =
+ shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced;
const showMinimalContextBleedThrough =
!settings.merged.ui.footer.hideContextPercentage &&
@@ -150,44 +205,348 @@ 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;
+ /**
+ * Calculate the estimated length of the status message to avoid collisions
+ * with the tips area.
+ */
+ let estimatedStatusLength = 0;
+ if (hasUserHooks) {
+ const hookLabel =
+ userHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
+ const hookNames = userHooks
+ .map(
+ (h) =>
+ h.name +
+ (h.index && h.total && h.total > 1 ? ` (${h.index}/${h.total})` : ''),
+ )
+ .join(', ');
+ estimatedStatusLength = hookLabel.length + hookNames.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 ambientContentStr = (() => {
+ // Only show Tips on the right
+ if (showTips && uiState.currentTip) {
+ if (
+ estimatedStatusLength + uiState.currentTip.length + 10 <=
+ terminalWidth
+ ) {
+ return uiState.currentTip;
+ }
}
- const timeout = setTimeout(() => {
- setShowShortcutsHintDebounced(true);
- }, 200);
+ return undefined;
+ })();
- return () => clearTimeout(timeout);
- }, [canShowShortcutsHint]);
+ const estimatedAmbientLength = ambientContentStr?.length || 0;
+ const willCollideAmbient =
+ estimatedStatusLength + estimatedAmbientLength + 5 > terminalWidth;
+
+ const showAmbientLine =
+ uiState.streamingState !== StreamingState.Idle &&
+ !hasPendingActionRequired &&
+ (showTips || showWit) &&
+ ambientContentStr &&
+ !willCollideAmbient &&
+ !isNarrow;
+
+ // Mini Mode VIP Flags (Pure Content Triggers)
+ const miniMode_ShowApprovalMode =
+ Boolean(modeContentObj) && !hideUiDetailsForSuggestions;
+ const miniMode_ShowToast = hasToast;
+ const miniMode_ShowContext = showMinimalContextBleedThrough;
+ const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint;
+ const miniMode_ShowStatus = showLoadingIndicator || hasUserHooks;
+ const miniMode_ShowAmbient = showAmbientLine;
+
+ // Composite Mini Mode Triggers
+ const showRow1_MiniMode =
+ miniMode_ShowToast ||
+ miniMode_ShowStatus ||
+ miniMode_ShowShortcuts ||
+ miniMode_ShowAmbient;
+
+ const showRow2_MiniMode = miniMode_ShowApprovalMode || miniMode_ShowContext;
+
+ // Final Display Rules (Stable Footer Architecture)
+ const showRow1 = showUiDetails || showRow1_MiniMode;
+ const showRow2 = showUiDetails || showRow2_MiniMode;
- const shouldReserveSpaceForShortcutsHint =
- settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions;
- const showShortcutsHint =
- shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced;
- const showMinimalModeBleedThrough =
- !hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough);
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
- const showMinimalBleedThroughRow =
- !showUiDetails &&
- (showMinimalModeBleedThrough ||
- hasMinimalStatusBleedThrough ||
- showMinimalContextBleedThrough);
- const showMinimalMetaRow =
- !showUiDetails &&
- (showMinimalInlineLoading ||
- showMinimalBleedThroughRow ||
- shouldReserveSpaceForShortcutsHint);
+ const showMinimalBleedThroughRow = !showUiDetails && showRow2_MiniMode;
+
+ const isInteractiveShellWaiting = uiState.currentLoadingPhrase?.includes(
+ INTERACTIVE_SHELL_WAITING_PHRASE,
+ );
+
+ const renderAmbientNode = () => {
+ if (!ambientContentStr) return null;
+
+ return (
+
+
+ {ambientContentStr === uiState.currentTip
+ ? `Tip: ${ambientContentStr}`
+ : ambientContentStr}
+
+
+ );
+ };
+
+ const renderStatusNode = () => {
+ if (hasUserHooks) {
+ const activeHook = userHooks[0];
+ const hookIcon = activeHook?.eventName?.startsWith('After') ? '↩' : '↪';
+
+ return (
+
+
+
+
+
+
+
+ {showWit && uiState.currentWittyPhrase && (
+
+
+ {uiState.currentWittyPhrase} :)
+
+
+ )}
+
+ );
+ }
+
+ if (showLoadingIndicator) {
+ return (
+
+ );
+ }
+ return null;
+ };
+
+ const statusNode = renderStatusNode();
+
+ /**
+ * Renders the minimal metadata row content shown when UI details are hidden.
+ */
+ const renderMinimalMetaRowContent = () => (
+
+ {showMinimalInlineLoading && (
+
+ )}
+ {hasUserHooks && (
+
+
+
+
+
+
+
+
+ )}
+ {showMinimalBleedThroughRow && (
+
+ {miniMode_ShowApprovalMode && modeContentObj && (
+ ● {modeContentObj.text}
+ )}
+ {/* {zenMode_ShowToast && (
+
+
+
+ )} */}
+
+ )}
+
+ );
+
+ 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 && miniMode_ShowStatus ? (
+ renderMinimalMetaRowContent()
+ ) : isInteractiveShellWaiting ? (
+
+
+ ! Shell awaiting input (Tab to focus)
+
+
+ ) : (
+
+ {statusNode}
+
+ )}
+
+
+
+ {!isNarrow && (
+ <>
+ {showShortcutsHint && }
+ {!showShortcutsHint && showAmbientLine && renderAmbientNode()}
+ >
+ )}
+
+
+ )}
+
+ {/* 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) && (
+
+ )}
+
+
+ )}
+
+ );
+ };
return (
{
{showUiDetails && }
-
-
-
- {showUiDetails && showLoadingIndicator && (
-
- )}
-
-
- {showUiDetails && showShortcutsHint && }
-
-
- {showMinimalMetaRow && (
-
-
- {showMinimalInlineLoading && (
-
- )}
- {showMinimalModeBleedThrough && minimalModeBleedThrough && (
-
- ● {minimalModeBleedThrough.text}
-
- )}
- {hasMinimalStatusBleedThrough && (
-
-
-
- )}
-
- {(showMinimalContextBleedThrough ||
- shouldReserveSpaceForShortcutsHint) && (
-
- {showMinimalContextBleedThrough && (
-
- )}
-
- {showShortcutsHint && }
-
-
- )}
-
- )}
- {showShortcutsHelp && }
- {showUiDetails && }
- {showUiDetails && (
-
-
- {hasToast ? (
-
- ) : (
-
- {showApprovalIndicator && (
-
- )}
- {!showLoadingIndicator && (
- <>
- {uiState.shellModeActive && (
-
-
-
- )}
- {showRawMarkdownIndicator && (
-
-
-
- )}
- >
- )}
-
- )}
-
+ {showShortcutsHelp && }
-
- {!showLoadingIndicator && (
-
- )}
-
-
- )}
+ {(showUiDetails || miniMode_ShowToast) && (
+
+
+
+ )}
+
+
+ {renderStatusRow()}
{showUiDetails && uiState.showErrorDetails && (
diff --git a/packages/cli/src/ui/components/ConsentPrompt.tsx b/packages/cli/src/ui/components/ConsentPrompt.tsx
index 3f255d2606..859d29281d 100644
--- a/packages/cli/src/ui/components/ConsentPrompt.tsx
+++ b/packages/cli/src/ui/components/ConsentPrompt.tsx
@@ -9,6 +9,7 @@ import { type ReactNode } from 'react';
import { theme } from '../semantic-colors.js';
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import { DialogFooter } from './shared/DialogFooter.js';
type ConsentPromptProps = {
// If a simple string is given, it will render using markdown by default.
@@ -37,7 +38,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
) : (
prompt
)}
-
+
{
]}
onSelect={onConfirm}
/>
+
);
diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx
index f48cfb2a31..43b733da3d 100644
--- a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx
+++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx
@@ -78,32 +78,6 @@ describe('', () => {
unmount();
});
- it('should switch layout at the 80-column breakpoint', async () => {
- const props = {
- ...baseProps,
- geminiMdFileCount: 1,
- contextFileNames: ['GEMINI.md'],
- mcpServers: { 'test-server': { command: 'test' } },
- ideContext: {
- workspaceState: {
- openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
- },
- },
- };
-
- // At 80 columns, should be on one line
- const { lastFrame: wideFrame, unmount: unmountWide } =
- await renderWithWidth(80, props);
- expect(wideFrame().trim().includes('\n')).toBe(false);
- unmountWide();
-
- // At 79 columns, should be on multiple lines
- const { lastFrame: narrowFrame, unmount: unmountNarrow } =
- await renderWithWidth(79, props);
- expect(narrowFrame().trim().includes('\n')).toBe(true);
- expect(narrowFrame().trim().split('\n').length).toBe(4);
- unmountNarrow();
- });
it('should not render empty parts', async () => {
const props = {
...baseProps,
diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx
index c9f67e34b3..696793bc06 100644
--- a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx
+++ b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx
@@ -8,8 +8,6 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { type IdeContext, type MCPServerConfig } from '@google/gemini-cli-core';
-import { useTerminalSize } from '../hooks/useTerminalSize.js';
-import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface ContextSummaryDisplayProps {
geminiMdFileCount: number;
@@ -30,8 +28,6 @@ export const ContextSummaryDisplay: React.FC = ({
skillCount,
backgroundProcessCount = 0,
}) => {
- const { columns: terminalWidth } = useTerminalSize();
- const isNarrow = isNarrowWidth(terminalWidth);
const mcpServerCount = Object.keys(mcpServers || {}).length;
const blockedMcpServerCount = blockedMcpServers?.length || 0;
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
@@ -44,7 +40,7 @@ export const ContextSummaryDisplay: React.FC = ({
skillCount === 0 &&
backgroundProcessCount === 0
) {
- return ; // Render an empty space to reserve height
+ return null;
}
const openFilesText = (() => {
@@ -113,21 +109,14 @@ export const ContextSummaryDisplay: React.FC = ({
backgroundText,
].filter(Boolean);
- if (isNarrow) {
- return (
-
- {summaryParts.map((part, index) => (
-
- - {part}
-
- ))}
-
- );
- }
-
return (
-
- {summaryParts.join(' | ')}
+
+ {summaryParts.map((part, index) => (
+
+ {index > 0 && {' · '}}
+ {part}
+
+ ))}
);
};
diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx
index 2e6821355f..316438d737 100644
--- a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx
+++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx
@@ -23,14 +23,28 @@ interface GeminiRespondingSpinnerProps {
*/
nonRespondingDisplay?: string;
spinnerType?: SpinnerName;
+ /**
+ * If true, we prioritize showing the nonRespondingDisplay (hook icon)
+ * even if the state is Responding.
+ */
+ isHookActive?: boolean;
+ color?: string;
}
export const GeminiRespondingSpinner: React.FC<
GeminiRespondingSpinnerProps
-> = ({ nonRespondingDisplay, spinnerType = 'dots' }) => {
+> = ({
+ nonRespondingDisplay,
+ spinnerType = 'dots',
+ isHookActive = false,
+ color,
+}) => {
const streamingState = useStreamingContext();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
- if (streamingState === StreamingState.Responding) {
+
+ // If a hook is active, we want to show the hook icon (nonRespondingDisplay)
+ // to be consistent, instead of the rainbow spinner which means "Gemini is talking".
+ if (streamingState === StreamingState.Responding && !isHookActive) {
return (
{SCREEN_READER_LOADING}
) : (
- {nonRespondingDisplay}
+ {nonRespondingDisplay}
);
}
diff --git a/packages/cli/src/ui/components/HookStatusDisplay.test.tsx b/packages/cli/src/ui/components/HookStatusDisplay.test.tsx
index fbf9ccb555..e2f39c301c 100644
--- a/packages/cli/src/ui/components/HookStatusDisplay.test.tsx
+++ b/packages/cli/src/ui/components/HookStatusDisplay.test.tsx
@@ -64,4 +64,18 @@ describe('', () => {
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
+
+ it('should show generic message when only system/extension hooks are active', async () => {
+ const props = {
+ activeHooks: [
+ { name: 'ext-hook', eventName: 'BeforeAgent', source: 'extensions' },
+ ],
+ };
+ const { lastFrame, waitUntilReady, unmount } = render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toContain('Working...');
+ unmount();
+ });
});
diff --git a/packages/cli/src/ui/components/HookStatusDisplay.tsx b/packages/cli/src/ui/components/HookStatusDisplay.tsx
index 07b2ee3d4a..8a464b9149 100644
--- a/packages/cli/src/ui/components/HookStatusDisplay.tsx
+++ b/packages/cli/src/ui/components/HookStatusDisplay.tsx
@@ -6,8 +6,8 @@
import type React from 'react';
import { Text } from 'ink';
-import { theme } from '../semantic-colors.js';
import { type ActiveHook } from '../types.js';
+import { GENERIC_WORKING_LABEL } from '../textConstants.js';
interface HookStatusDisplayProps {
activeHooks: ActiveHook[];
@@ -20,20 +20,27 @@ export const HookStatusDisplay: React.FC = ({
return null;
}
- const label = activeHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
- const displayNames = activeHooks.map((hook) => {
- let name = hook.name;
- if (hook.index && hook.total && hook.total > 1) {
- name += ` (${hook.index}/${hook.total})`;
- }
- return name;
- });
+ // Define which hook sources are considered "user" hooks that should be shown explicitly.
+ const USER_HOOK_SOURCES = ['user', 'project', 'runtime'];
- const text = `${label}: ${displayNames.join(', ')}`;
-
- return (
-
- {text}
-
+ const userHooks = activeHooks.filter(
+ (h) => !h.source || USER_HOOK_SOURCES.includes(h.source),
);
+
+ if (userHooks.length > 0) {
+ const label = userHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
+ const displayNames = userHooks.map((hook) => {
+ let name = hook.name;
+ if (hook.index && hook.total && hook.total > 1) {
+ name += ` (${hook.index}/${hook.total})`;
+ }
+ return name;
+ });
+
+ const text = `${label}: ${displayNames.join(', ')}`;
+ return {text};
+ }
+
+ // If only system/extension hooks are running, show a generic message.
+ return {GENERIC_WORKING_LABEL};
};
diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx
index 4c4e3053ef..8f6e8d2966 100644
--- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx
+++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx
@@ -50,7 +50,7 @@ const renderWithContext = (
describe('', () => {
const defaultProps = {
- currentLoadingPhrase: 'Loading...',
+ currentLoadingPhrase: 'Working...',
elapsedTime: 5,
};
@@ -71,8 +71,8 @@ describe('', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
- expect(output).toContain('Loading...');
- expect(output).toContain('(esc to cancel, 5s)');
+ expect(output).toContain('Working...');
+ expect(output).toContain('esc to cancel, 5s');
});
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', async () => {
@@ -116,7 +116,7 @@ describe('', () => {
StreamingState.Responding,
);
await waitUntilReady();
- expect(lastFrame()).toContain('(esc to cancel, 1m)');
+ expect(lastFrame()).toContain('esc to cancel, 1m');
unmount();
});
@@ -130,7 +130,7 @@ describe('', () => {
StreamingState.Responding,
);
await waitUntilReady();
- expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
+ expect(lastFrame()).toContain('esc to cancel, 2m 5s');
unmount();
});
@@ -196,7 +196,7 @@ describe('', () => {
let output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Now Responding');
- expect(output).toContain('(esc to cancel, 2s)');
+ expect(output).toContain('esc to cancel, 2s');
// Transition to WaitingForConfirmation
await act(async () => {
@@ -229,7 +229,7 @@ describe('', () => {
it('should display fallback phrase if thought is empty', async () => {
const props = {
thought: null,
- currentLoadingPhrase: 'Loading...',
+ currentLoadingPhrase: 'Working...',
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
@@ -238,7 +238,7 @@ describe('', () => {
);
await waitUntilReady();
const output = lastFrame();
- expect(output).toContain('Loading...');
+ expect(output).toContain('Working...');
unmount();
});
@@ -349,8 +349,8 @@ describe('', () => {
const output = lastFrame();
// Check for single line output
expect(output?.trim().includes('\n')).toBe(false);
- expect(output).toContain('Loading...');
- expect(output).toContain('(esc to cancel, 5s)');
+ expect(output).toContain('Working...');
+ expect(output).toContain('esc to cancel, 5s');
expect(output).toContain('Right');
unmount();
});
@@ -373,9 +373,9 @@ describe('', () => {
// 3. Right Content
expect(lines).toHaveLength(3);
if (lines) {
- expect(lines[0]).toContain('Loading...');
- expect(lines[0]).not.toContain('(esc to cancel, 5s)');
- expect(lines[1]).toContain('(esc to cancel, 5s)');
+ expect(lines[0]).toContain('Working...');
+ expect(lines[0]).not.toContain('esc to cancel, 5s');
+ expect(lines[1]).toContain('esc to cancel, 5s');
expect(lines[2]).toContain('Right');
}
unmount();
diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx
index eba0a7d8a3..f5785dbe3b 100644
--- a/packages/cli/src/ui/components/LoadingIndicator.tsx
+++ b/packages/cli/src/ui/components/LoadingIndicator.tsx
@@ -15,30 +15,47 @@ import { formatDuration } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
+import { GENERIC_WORKING_LABEL } from '../textConstants.js';
+import type { LoadingPhrasesMode } from '../../config/settings.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
+ wittyPhrase?: string;
+ showWit?: boolean;
+ showTips?: boolean;
+ loadingPhrases?: LoadingPhrasesMode;
+ errorVerbosity?: 'low' | 'full';
elapsedTime: number;
inline?: boolean;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
thoughtLabel?: string;
showCancelAndTimer?: boolean;
+ forceRealStatusOnly?: boolean;
}
export const LoadingIndicator: React.FC = ({
currentLoadingPhrase,
+ wittyPhrase,
+ showWit: showWitProp,
+ showTips: _showTipsProp,
+ loadingPhrases = 'all',
+ errorVerbosity: _errorVerbosity = 'full',
elapsedTime,
inline = false,
rightContent,
thought,
thoughtLabel,
showCancelAndTimer = true,
+ forceRealStatusOnly = false,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
+ const showWit =
+ showWitProp ?? (loadingPhrases === 'witty' || loadingPhrases === 'all');
+
if (
streamingState === StreamingState.Idle &&
!currentLoadingPhrase &&
@@ -54,7 +71,10 @@ export const LoadingIndicator: React.FC = ({
? currentLoadingPhrase
: thought?.subject
? (thoughtLabel ?? thought.subject)
- : currentLoadingPhrase;
+ : currentLoadingPhrase ||
+ (streamingState === StreamingState.Responding
+ ? GENERIC_WORKING_LABEL
+ : undefined);
const hasThoughtIndicator =
currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
Boolean(thought?.subject?.trim());
@@ -67,9 +87,21 @@ export const LoadingIndicator: React.FC = ({
const cancelAndTimerContent =
showCancelAndTimer &&
streamingState !== StreamingState.WaitingForConfirmation
- ? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
+ ? `esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)}`
: null;
+ const wittyPhraseNode =
+ !forceRealStatusOnly &&
+ showWit &&
+ wittyPhrase &&
+ primaryText === GENERIC_WORKING_LABEL ? (
+
+
+ {wittyPhrase}
+
+
+ ) : null;
+
if (inline) {
return (
@@ -96,6 +128,7 @@ export const LoadingIndicator: React.FC = ({
)}
)}
+ {wittyPhraseNode}
{cancelAndTimerContent && (
<>
@@ -138,6 +171,7 @@ export const LoadingIndicator: React.FC = ({
)}
)}
+ {wittyPhraseNode}
{!isNarrow && cancelAndTimerContent && (
<>
diff --git a/packages/cli/src/ui/components/ShortcutsHint.tsx b/packages/cli/src/ui/components/ShortcutsHint.tsx
index 4ecb01e9d8..bfe83b5673 100644
--- a/packages/cli/src/ui/components/ShortcutsHint.tsx
+++ b/packages/cli/src/ui/components/ShortcutsHint.tsx
@@ -20,5 +20,5 @@ export const ShortcutsHint: React.FC = () => {
? theme.text.accent
: theme.text.secondary;
- return ? for shortcuts ;
+ return ? for shortcuts;
};
diff --git a/packages/cli/src/ui/components/StatusDisplay.tsx b/packages/cli/src/ui/components/StatusDisplay.tsx
index 223340c039..472e900b3b 100644
--- a/packages/cli/src/ui/components/StatusDisplay.tsx
+++ b/packages/cli/src/ui/components/StatusDisplay.tsx
@@ -11,9 +11,8 @@ 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 {
+export interface StatusDisplayProps {
hideContextSummary: boolean;
}
@@ -28,13 +27,6 @@ export const StatusDisplay: React.FC = ({
return |⌐■_■|;
}
- if (
- uiState.activeHooks.length > 0 &&
- settings.merged.hooksConfig.notifications
- ) {
- return ;
- }
-
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
return (
{
if (uiState.showIsExpandableHint) {
const action = uiState.constrainHeight ? 'show more' : 'collapse';
return (
-
+
Press Ctrl+O to {action} lines of the last response
);
diff --git a/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap
index 452663d719..fa12e847ae 100644
--- a/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap
@@ -1,33 +1,33 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Composer > Snapshots > matches snapshot in idle state 1`] = `
-" ShortcutsHint
-────────────────────────────────────────────────────────────────────────────────────────────────────
- ApprovalModeIndicator StatusDisplay
+"
+
+ ───────────────────────────────────────────────────────────────────────────────────────────────────
+ ApprovalModeIndicator: default StatusDisplay
InputPrompt: Type your message or @path/to/file
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
+ ────────────────────────────────────────
+ ApprovalModeIndicator: StatusDispl
+ default ay
InputPrompt: Type your message or
@path/to/file
Footer
@@ -35,9 +35,10 @@ Footer
`;
exports[`Composer > Snapshots > matches snapshot while streaming 1`] = `
-" LoadingIndicator: Thinking
-────────────────────────────────────────────────────────────────────────────────────────────────────
- ApprovalModeIndicator
+"
+ LoadingIndicator: Thinking
+ ───────────────────────────────────────────────────────────────────────────────────────────────────
+ ApprovalModeIndicator: default StatusDisplay
InputPrompt: Type your message or @path/to/file
Footer
"
diff --git a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap
index 28929deee5..a970ab1a73 100644
--- a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap
@@ -18,12 +18,6 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
-exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
-"
-Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
-"
-`;
-
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
diff --git a/packages/cli/src/ui/components/__snapshots__/ContextSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ContextSummaryDisplay.test.tsx.snap
index e28d884acf..876524bdb8 100644
--- a/packages/cli/src/ui/components/__snapshots__/ContextSummaryDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ContextSummaryDisplay.test.tsx.snap
@@ -1,19 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[` > should not render empty parts 1`] = `
-" - 1 open file (ctrl+g to view)
+" 1 open file (ctrl+g to view)
"
`;
exports[` > should render on a single line on a wide screen 1`] = `
-" 1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server | 1 skill
+" 1 open file (ctrl+g to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
"
`;
exports[` > should render on multiple lines on a narrow screen 1`] = `
-" - 1 open file (ctrl+g to view)
- - 1 GEMINI.md file
- - 1 MCP server
- - 1 skill
+" 1 open file (ctrl+g to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap
index 666525e720..ec6078d21c 100644
--- a/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[` > should truncate long primary text instead of wrapping 1`] = `
-"MockRespondin This is an extremely long loading phrase that shoul…(esc to
-gSpinner cancel, 5s)
+"MockRespondin This is an extremely long loading phrase that shoul… esc to
+gSpinner cancel, 5s
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
index c0043bf6f9..ccbca75f8d 100644
--- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
@@ -4,7 +4,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Focuse
"ScrollableList
AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊶ Shell Command Running a long command... │
+│ ⊷ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
@@ -25,7 +25,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocu
"ScrollableList
AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊶ Shell Command Running a long command... │
+│ ⊷ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
@@ -45,7 +45,7 @@ AppHeader(full)
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = `
"AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊶ Shell Command Running a long command... │
+│ ⊷ Shell Command Running a long command... │
│ │
│ ... first 11 lines hidden (Ctrl+O to show) ... │
│ Line 12 │
@@ -64,7 +64,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = `
"AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊶ Shell Command Running a long command... │
+│ ⊷ Shell Command Running a long command... │
│ │
│ Line 1 │
│ Line 2 │
diff --git a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap
index 2620531cc3..2e6b4b75ad 100644
--- a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap
@@ -11,7 +11,7 @@ exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `
`;
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `
-"Mock Hook Status Display
+"Mock Context Summary Display (Skills: 2, Shells: 0)
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap
index 6d9baba94f..f752c1da65 100644
--- a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap
@@ -15,6 +15,8 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
│ 3. Modify with external editor │
│ 4. No, suggest changes (esc) │
│ │
+│ Enter to select · ↑/↓ to navigate · Esc to cancel │
+│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Press Ctrl+O to show more lines
"
@@ -38,6 +40,8 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe
│ 3. Modify with external editor │
│ 4. No, suggest changes (esc) │
│ │
+│ Enter to select · ↑/↓ to navigate · Esc to cancel │
+│ │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -106,6 +110,8 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and
│ 3. Modify with external editor │
│ 4. No, suggest changes (esc) │
│ │
+│ Enter to select · ↑/↓ to navigate · Esc to cancel │
+│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Press Ctrl+O to show more lines
"
@@ -124,6 +130,8 @@ exports[`ToolConfirmationQueue > renders the confirming tool with progress indic
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
+│ Enter to select · ↑/↓ to navigate · Esc to cancel │
+│ │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
index ec623f69a4..f6b01fc66a 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
@@ -608,7 +608,7 @@ describe('ToolConfirmationMessage', () => {
const output = lastFrame();
expect(output).toContain('MCP Tool Details:');
- expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
+ expect(output).toContain('Ctrl+O to expand details');
expect(output).not.toContain('https://www.google.co.jp');
expect(output).not.toContain('Navigates browser to a URL.');
unmount();
@@ -640,7 +640,7 @@ describe('ToolConfirmationMessage', () => {
const output = lastFrame();
expect(output).toContain('MCP Tool Details:');
- expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
+ expect(output).toContain('Ctrl+O to expand details');
expect(output).not.toContain('Invocation Arguments:');
unmount();
});
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index 8bc329f3df..54f339faf1 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -37,6 +37,7 @@ import { AskUserDialog } from '../AskUserDialog.js';
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
import { WarningMessage } from './WarningMessage.js';
import { colorizeCode } from '../../utils/CodeColorizer.js';
+import { DialogFooter } from '../shared/DialogFooter.js';
import {
getDeceptiveUrlDetails,
toUnicodeUrl,
@@ -742,13 +743,24 @@ export const ToolConfirmationMessage: React.FC<
{question}
-
+
+
>
)}
diff --git a/packages/cli/src/ui/components/messages/ToolShared.tsx b/packages/cli/src/ui/components/messages/ToolShared.tsx
index 2aa5ed992a..553d64670a 100644
--- a/packages/cli/src/ui/components/messages/ToolShared.tsx
+++ b/packages/cli/src/ui/components/messages/ToolShared.tsx
@@ -7,7 +7,7 @@
import React, { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
-import { CliSpinner } from '../CliSpinner.js';
+import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import {
SHELL_COMMAND_NAME,
SHELL_NAME,
@@ -123,7 +123,7 @@ export const FocusHint: React.FC<{
return (
-
+
{isThisShellFocused
? `(${formatCommand(Command.UNFOCUS_SHELL_INPUT)} to unfocus)`
: `(${formatCommand(Command.FOCUS_SHELL_INPUT)} to focus)`}
@@ -150,7 +150,7 @@ export const ToolStatusIndicator: React.FC = ({
const statusColor = isFocused
? theme.ui.focus
: isShell
- ? theme.ui.active
+ ? theme.ui.symbol
: theme.status.warning;
return (
@@ -159,9 +159,11 @@ export const ToolStatusIndicator: React.FC = ({
{TOOL_STATUS.PENDING}
)}
{status === ToolCallStatus.Executing && (
-
-
-
+
)}
{status === ToolCallStatus.Success && (
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap
index f584e7f483..ab2f005c1a 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap
@@ -7,8 +7,10 @@ Note: Command contains redirection which can be undesirable.
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
Allow execution of: 'echo, redirection (>)'?
-● 1. Allow once
+● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
+
+Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap
index 1847b8ce67..437ba7154c 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap
@@ -2,7 +2,7 @@
exports[` > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊶ Shell Command A shell command │
+│ ⊷ Shell Command A shell command │
│ │
│ Line 89 │
│ Line 90 │
@@ -128,7 +128,7 @@ exports[` > Height Constraints > fully expands in alternate
exports[` > Height Constraints > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊶ Shell Command A shell command │
+│ ⊷ Shell Command A shell command │
│ │
│ Line 93 │
│ Line 94 │
@@ -162,7 +162,7 @@ exports[` > Height Constraints > stays constrained in altern
exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊶ Shell Command A shell command │
+│ ⊷ Shell Command A shell command │
│ │
│ Line 89 │
│ Line 90 │
@@ -181,7 +181,7 @@ exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
exports[` > Height Constraints > uses full availableTerminalHeight when focused in alternate buffer mode 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊶ Shell Command A shell command (Shift+Tab to unfocus) │
+│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Line 3 │
│ Line 4 │
@@ -286,7 +286,7 @@ exports[` > Height Constraints > uses full availableTerminal
exports[` > Snapshots > renders in Alternate Buffer mode while focused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊶ Shell Command A shell command (Shift+Tab to unfocus) │
+│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Test result │
"
@@ -294,7 +294,7 @@ exports[` > Snapshots > renders in Alternate Buffer mode whi
exports[` > Snapshots > renders in Alternate Buffer mode while unfocused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊶ Shell Command A shell command │
+│ ⊷ Shell Command A shell command │
│ │
│ Test result │
"
@@ -318,7 +318,7 @@ exports[` > Snapshots > renders in Error state 1`] = `
exports[` > Snapshots > renders in Executing state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊶ Shell Command A shell command │
+│ ⊷ Shell Command A shell command │
│ │
│ Test result │
"
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting-SVG-snapshot-.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting-SVG-snapshot-.snap.svg
index d1396e2335..18b7d6eda1 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting-SVG-snapshot-.snap.svg
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting-SVG-snapshot-.snap.svg
@@ -1,8 +1,8 @@
-