feat(ui): implement refreshed UX for Composer layout (#21212)

Co-authored-by: Keith Guerin <keithguerin@gmail.com>
This commit is contained in:
Jarrod Whelan
2026-03-23 19:30:48 -07:00
committed by GitHub
parent 1560131f94
commit 271908dc94
50 changed files with 1578 additions and 1362 deletions
@@ -8,6 +8,7 @@ import {
renderWithProviders,
persistentStateMock,
} from '../../test-utils/render.js';
import type { LoadedSettings } from '../../config/settings.js';
import { AppHeader } from './AppHeader.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
@@ -264,4 +265,23 @@ describe('<AppHeader />', () => {
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should NOT render Tips when ui.hideTips is true', async () => {
const mockConfig = makeFakeConfig();
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
settings: {
merged: {
ui: { hideTips: true },
},
} as unknown as LoadedSettings,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Tips');
unmount();
});
});
+124 -74
View File
@@ -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: () => <Text>StatusDisplay</Text>,
}));
vi.mock('./ToastDisplay.js', () => ({
ToastDisplay: () => <Text>ToastDisplay</Text>,
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 }) => (
<Text>StatusDisplay{hideContextSummary ? ' (hidden summary)' : ''}</Text>
),
}));
vi.mock('./ContextSummaryDisplay.js', () => ({
@@ -81,17 +80,15 @@ vi.mock('./HookStatusDisplay.js', () => ({
}));
vi.mock('./ApprovalModeIndicator.js', () => ({
ApprovalModeIndicator: () => <Text>ApprovalModeIndicator</Text>,
ApprovalModeIndicator: ({ approvalMode }: { approvalMode: ApprovalMode }) => (
<Text>ApprovalModeIndicator: {approvalMode}</Text>
),
}));
vi.mock('./ShellModeIndicator.js', () => ({
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
}));
vi.mock('./ShortcutsHint.js', () => ({
ShortcutsHint: () => <Text>ShortcutsHint</Text>,
}));
vi.mock('./ShortcutsHelp.js', () => ({
ShortcutsHelp: () => <Text>ShortcutsHelp</Text>,
}));
@@ -174,6 +171,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
isFocused: true,
thought: '',
currentLoadingPhrase: '',
currentTip: '',
currentWittyPhrase: '',
elapsedTime: 0,
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
@@ -201,6 +200,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
activeHooks: [],
isBackgroundShellVisible: false,
embeddedShellFocused: false,
showIsExpandableHint: false,
quota: {
userTier: undefined,
stats: undefined,
@@ -247,7 +247,7 @@ const createMockConfig = (overrides = {}): Config =>
const renderComposer = async (
uiState: UIState,
settings = createMockSettings(),
settings = createMockSettings({ ui: {} }),
config = createMockConfig(),
uiActions = createMockUIActions(),
) => {
@@ -256,7 +256,7 @@ const renderComposer = async (
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer />
<Composer isFocused={true} />
</UIActionsContext.Provider>
</UIStateContext.Provider>
</SettingsContext.Provider>
@@ -383,10 +383,12 @@ 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 () => {
it('shows shortcuts hint while loading', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
elapsedTime: 1,
@@ -397,7 +399,8 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('ShortcutsHint');
expect(output).toContain('press tab twice for more');
expect(output).not.toContain('? for shortcuts');
});
it('renders LoadingIndicator with thought when loadingPhrases is off', async () => {
@@ -453,9 +456,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 () => {
@@ -558,8 +560,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');
});
@@ -574,8 +578,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');
});
});
@@ -584,15 +588,17 @@ 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('press tab twice for more');
expect(output).not.toContain('? for shortcuts');
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 () => {
@@ -665,12 +671,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,
@@ -693,7 +702,8 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('plan');
expect(output).not.toContain('ShortcutsHint');
expect(output).toContain('press tab twice for more');
expect(output).not.toContain('? for shortcuts');
});
it('hides minimal mode badge while action-required state is active', async () => {
@@ -708,9 +718,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 () => {
@@ -722,7 +730,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');
});
@@ -747,7 +755,14 @@ describe('Composer', () => {
});
const { lastFrame } = await renderComposer(uiState, settings);
expect(lastFrame()).toContain('%');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// StatusDisplay (which contains ContextUsageDisplay) should bleed through in minimal mode
expect(lastFrame()).toContain('StatusDisplay');
expect(lastFrame()).toContain('70% used');
});
});
@@ -812,14 +827,20 @@ 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,
});
expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint');
const { lastFrame } = await renderComposer(uiState);
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(lastFrame({ allowEmpty: true })).toContain(
'press tab twice for more',
);
});
it('hides shortcuts hint when text is typed in buffer', async () => {
@@ -830,7 +851,8 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame()).not.toContain('press tab twice for more');
expect(lastFrame()).not.toContain('? for shortcuts');
});
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
@@ -843,7 +865,7 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState, settings);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame()).not.toContain('? for shortcuts');
});
it('hides shortcuts hint when a action is required (e.g. dialog is open)', async () => {
@@ -856,9 +878,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 () => {
@@ -868,7 +891,11 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(lastFrame()).toContain('press tab twice for more');
});
it('shows shortcuts hint when full UI details are visible', async () => {
@@ -878,10 +905,15 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// In Refreshed UX, shortcuts hint is in the top multipurpose status row
expect(lastFrame()).toContain('? for shortcuts');
});
it('hides shortcuts hint while loading when full UI details are visible', async () => {
it('shows shortcuts hint while loading when full UI details are visible', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: true,
streamingState: StreamingState.Responding,
@@ -889,10 +921,17 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// In experimental layout, status row is visible during loading
expect(lastFrame()).toContain('LoadingIndicator');
expect(lastFrame()).toContain('? for shortcuts');
expect(lastFrame()).not.toContain('press tab twice for more');
});
it('hides shortcuts hint while loading in minimal mode', async () => {
it('shows shortcuts hint while loading in minimal mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
streamingState: StreamingState.Responding,
@@ -901,7 +940,14 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
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()).toContain('press tab twice for more');
expect(lastFrame()).not.toContain('? for shortcuts');
});
it('shows shortcuts help in minimal mode when toggled on', async () => {
@@ -926,7 +972,8 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame()).not.toContain('press tab twice for more');
expect(lastFrame()).not.toContain('? for shortcuts');
expect(lastFrame()).not.toContain('plan');
});
@@ -954,7 +1001,12 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
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('press tab twice for more');
});
});
@@ -982,24 +1034,22 @@ describe('Composer', () => {
expect(lastFrame()).not.toContain('ShortcutsHelp');
unmount();
});
it('hides shortcuts help when action is required', async () => {
const uiState = createMockUIState({
shortcutsHelpVisible: true,
customDialog: (
<Box>
<Text>Dialog content</Text>
<Text>Test Dialog</Text>
</Box>
),
});
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();
+399 -275
View File
@@ -4,58 +4,63 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import {
ApprovalMode,
checkExhaustive,
CoreToolCallStatus,
isUserVisibleHook,
} 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 { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { StatusDisplay } from './StatusDisplay.js';
import { HorizontalLine } from './shared/HorizontalLine.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { ShortcutsHint } from './ShortcutsHint.js';
import { ShortcutsHelp } from './ShortcutsHelp.js';
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 { 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 showApprovalModeIndicator = uiState.showApprovalModeIndicator;
const loadingPhrases = settings.merged.ui.loadingPhrases;
const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
const showUiDetails = uiState.cleanUiDetailsVisible;
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
const hideContextSummary =
@@ -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,30 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
uiState.shortcutsHelpVisible &&
uiState.streamingState === StreamingState.Idle &&
!hasPendingActionRequired;
/**
* Use the setting if provided, otherwise default to true for the new UX.
* This allows tests to override the collapse behavior.
*/
const shouldCollapseDuringApproval =
settings.merged.ui.collapseDrawerDuringApproval !== false;
if (hasPendingActionRequired && shouldCollapseDuringApproval) {
return null;
}
const hasToast = shouldShowToast(uiState);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
uiState.streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
const hideUiDetailsForSuggestions =
suggestionsVisible && suggestionsPosition === 'above';
const showApprovalIndicator =
!uiState.shellModeActive && !hideUiDetailsForSuggestions;
const showRawMarkdownIndicator = !uiState.renderMarkdown;
let modeBleedThrough: { text: string; color: string } | null = null;
switch (showApprovalModeIndicator) {
case ApprovalMode.YOLO:
@@ -137,59 +157,359 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const hideMinimalModeHintWhileBusy =
!showUiDetails && (showLoadingIndicator || hasPendingActionRequired);
const minimalModeBleedThrough = hideMinimalModeHintWhileBusy
? null
: modeBleedThrough;
const hasMinimalStatusBleedThrough = shouldShowToast(uiState);
const showMinimalContextBleedThrough =
!settings.merged.ui.footer.hideContextPercentage &&
isContextUsageHigh(
uiState.sessionStats.lastPromptTokenCount,
typeof uiState.currentModel === 'string'
? 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);
// Universal Content Objects
const modeContentObj = hideMinimalModeHintWhileBusy ? null : modeBleedThrough;
useEffect(() => {
if (!canShowShortcutsHint) {
setShowShortcutsHintDebounced(false);
return;
}
const timeout = setTimeout(() => {
setShowShortcutsHintDebounced(true);
}, 200);
return () => clearTimeout(timeout);
}, [canShowShortcutsHint]);
const allHooks = uiState.activeHooks;
const hasAnyHooks = allHooks.length > 0;
const userVisibleHooks = allHooks.filter((h) => isUserVisibleHook(h.source));
const hasUserVisibleHooks = userVisibleHooks.length > 0;
const shouldReserveSpaceForShortcutsHint =
settings.merged.ui.showShortcutsHint &&
!hideShortcutsHintForSuggestions &&
!hideUiDetailsForSuggestions &&
!hasPendingActionRequired;
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 isInteractiveShellWaiting = uiState.currentLoadingPhrase?.includes(
INTERACTIVE_SHELL_WAITING_PHRASE,
);
/**
* Calculate the estimated length of the status message to avoid collisions
* with the tips area.
*/
let estimatedStatusLength = 0;
if (hasAnyHooks) {
if (hasUserVisibleHooks) {
const hookLabel =
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const hookNames = userVisibleHooks
.map(
(h) =>
h.name +
(h.index && h.total && h.total > 1
? ` (${h.index}/${h.total})`
: ''),
)
.join(', ');
estimatedStatusLength = hookLabel.length + hookNames.length + 10;
} else {
estimatedStatusLength = GENERIC_WORKING_LABEL.length + 10;
}
} else if (showLoadingIndicator) {
const thoughtText = uiState.thought?.subject || GENERIC_WORKING_LABEL;
const inlineWittyLength =
showWit && uiState.currentWittyPhrase
? uiState.currentWittyPhrase.length + 1
: 0;
estimatedStatusLength = thoughtText.length + 25 + inlineWittyLength;
} else if (hasPendingActionRequired) {
estimatedStatusLength = 20;
} else if (hasToast) {
estimatedStatusLength = 40;
}
/**
* Determine the ambient text (tip) to display.
*/
const tipContentStr = (() => {
// 1. Proactive Tip (Priority)
if (
showTips &&
uiState.currentTip &&
!(
isInteractiveShellWaiting &&
uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE
)
) {
if (
estimatedStatusLength + uiState.currentTip.length + 10 <=
terminalWidth
) {
return uiState.currentTip;
}
}
// 2. Shortcut Hint (Fallback)
if (
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
!hasPendingActionRequired &&
uiState.buffer.text.length === 0
) {
return showUiDetails ? '? for shortcuts' : 'press tab twice for more';
}
return undefined;
})();
const tipLength = tipContentStr?.length || 0;
const willCollideTip = estimatedStatusLength + tipLength + 5 > terminalWidth;
const showTipLine =
!hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow;
// Mini Mode VIP Flags (Pure Content Triggers)
const miniMode_ShowApprovalMode =
Boolean(modeContentObj) && !hideUiDetailsForSuggestions;
const miniMode_ShowToast = hasToast;
const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint;
const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks;
const miniMode_ShowTip = showTipLine;
const miniMode_ShowContext = isContextUsageHigh(
uiState.sessionStats.lastPromptTokenCount,
uiState.currentModel,
settings.merged.model?.compressionThreshold,
);
// Composite Mini Mode Triggers
const showRow1_MiniMode =
miniMode_ShowToast ||
miniMode_ShowStatus ||
miniMode_ShowShortcuts ||
miniMode_ShowTip;
const showRow2_MiniMode = miniMode_ShowApprovalMode || miniMode_ShowContext;
// Final Display Rules (Stable Footer Architecture)
const showRow1 = showUiDetails || showRow1_MiniMode;
const showRow2 = showUiDetails || showRow2_MiniMode;
const showMinimalBleedThroughRow = !showUiDetails && showRow2_MiniMode;
const renderTipNode = () => {
if (!tipContentStr) return null;
const isShortcutHint =
tipContentStr === '? for shortcuts' ||
tipContentStr === 'press tab twice for more';
const color =
isShortcutHint && uiState.shortcutsHelpVisible
? theme.text.accent
: theme.text.secondary;
return (
<Box flexDirection="row" justifyContent="flex-end">
<Text
color={color}
wrap="truncate-end"
italic={
!isShortcutHint && tipContentStr === uiState.currentWittyPhrase
}
>
{tipContentStr === uiState.currentTip
? `Tip: ${tipContentStr}`
: tipContentStr}
</Text>
</Box>
);
};
const renderStatusNode = () => {
const allHooks = uiState.activeHooks;
if (allHooks.length === 0 && !showLoadingIndicator) return null;
if (allHooks.length > 0) {
const userVisibleHooks = allHooks.filter((h) =>
isUserVisibleHook(h.source),
);
let hookText = GENERIC_WORKING_LABEL;
if (userVisibleHooks.length > 0) {
const label =
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const displayNames = userVisibleHooks.map((h) => {
let name = h.name;
if (h.index && h.total && h.total > 1) {
name += ` (${h.index}/${h.total})`;
}
return name;
});
hookText = `${label}: ${displayNames.join(', ')}`;
}
return (
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={settings.merged.ui.errorVerbosity}
currentLoadingPhrase={hookText}
elapsedTime={uiState.elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={uiState.currentWittyPhrase}
/>
);
}
return (
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={settings.merged.ui.errorVerbosity}
thought={uiState.thought}
elapsedTime={uiState.elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={uiState.currentWittyPhrase}
/>
);
};
const statusNode = renderStatusNode();
/**
* Renders the minimal metadata row content shown when UI details are hidden.
*/
const renderMinimalMetaRowContent = () => (
<Box flexDirection="row" columnGap={1}>
{renderStatusNode()}
{showMinimalBleedThroughRow && (
<Box>
{miniMode_ShowApprovalMode && modeContentObj && (
<Text color={modeContentObj.color}> {modeContentObj.text}</Text>
)}
</Box>
)}
</Box>
);
const renderStatusRow = () => {
// Mini Mode Height Reservation (The "Anti-Jitter" line)
if (!showUiDetails && !showRow1_MiniMode && !showRow2_MiniMode) {
return <Box height={1} />;
}
return (
<Box flexDirection="column" width="100%">
{/* Row 1: multipurpose status (thinking, hooks, wit, tips) */}
{showRow1 && (
<Box
width="100%"
flexDirection="row"
alignItems="center"
justifyContent="space-between"
minHeight={1}
>
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
{!showUiDetails && showRow1_MiniMode ? (
renderMinimalMetaRowContent()
) : isInteractiveShellWaiting ? (
<Box width="100%" marginLeft={1}>
<Text color={theme.status.warning}>
! Shell awaiting input (Tab to focus)
</Text>
</Box>
) : (
<Box
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
flexShrink={0}
marginLeft={1}
>
{statusNode}
</Box>
)}
</Box>
<Box flexShrink={0} marginLeft={2} marginRight={isNarrow ? 0 : 1}>
{!isNarrow && showTipLine && renderTipNode()}
</Box>
</Box>
)}
{/* Internal Separator Line */}
{showRow1 &&
showRow2 &&
(showUiDetails || (showRow1_MiniMode && showRow2_MiniMode)) && (
<Box width="100%">
<HorizontalLine dim />
</Box>
)}
{/* Row 2: Mode and Context Summary */}
{showRow2 && (
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent="space-between"
>
<Box flexDirection="row" alignItems="center" marginLeft={1}>
{showUiDetails ? (
<>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{uiState.shellModeActive && (
<Box
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
>
<ShellModeIndicator />
</Box>
)}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator || uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator || uiState.shellModeActive) &&
isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
</>
) : (
miniMode_ShowApprovalMode &&
modeContentObj && (
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
)
)}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="row"
alignItems="center"
marginLeft={isNarrow ? 1 : 0}
>
{(showUiDetails || miniMode_ShowContext) && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
{miniMode_ShowContext && !showUiDetails && (
<Box marginLeft={1}>
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={
typeof uiState.currentModel === 'string'
? uiState.currentModel
: undefined
}
terminalWidth={uiState.terminalWidth}
/>
</Box>
)}
</Box>
</Box>
)}
</Box>
);
};
return (
<Box
@@ -212,212 +532,16 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
{showUiDetails && <TodoTray />}
<Box width="100%" flexDirection="column">
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
>
{showUiDetails && showLoadingIndicator && (
<LoadingIndicator
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation
? undefined
: uiState.thought
}
currentLoadingPhrase={
settings.merged.ui.loadingPhrases === 'off'
? undefined
: uiState.currentLoadingPhrase
}
thoughtLabel={
inlineThinkingMode === 'full' ? 'Thinking...' : undefined
}
elapsedTime={uiState.elapsedTime}
/>
)}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={
showUiDetails && shouldReserveSpaceForShortcutsHint ? 1 : 0
}
>
{showUiDetails && showShortcutsHint && <ShortcutsHint />}
</Box>
</Box>
{showMinimalMetaRow && (
<Box
justifyContent="space-between"
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
>
{showMinimalInlineLoading && (
<LoadingIndicator
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation
? undefined
: uiState.thought
}
currentLoadingPhrase={
settings.merged.ui.loadingPhrases === 'off'
? undefined
: uiState.currentLoadingPhrase
}
thoughtLabel={
inlineThinkingMode === 'full' ? 'Thinking...' : undefined
}
elapsedTime={uiState.elapsedTime}
/>
)}
{showMinimalModeBleedThrough && minimalModeBleedThrough && (
<Text color={minimalModeBleedThrough.color}>
{minimalModeBleedThrough.text}
</Text>
)}
{hasMinimalStatusBleedThrough && (
<Box
marginLeft={
showMinimalInlineLoading || showMinimalModeBleedThrough
? 1
: 0
}
>
<ToastDisplay />
</Box>
)}
</Box>
{(showMinimalContextBleedThrough ||
shouldReserveSpaceForShortcutsHint) && (
<Box
marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0}
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={1}
>
{showMinimalContextBleedThrough && (
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={uiState.currentModel}
terminalWidth={uiState.terminalWidth}
/>
)}
<Box
marginLeft={
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
}
marginTop={showMinimalContextBleedThrough && isNarrow ? 1 : 0}
>
{showShortcutsHint && <ShortcutsHint />}
</Box>
</Box>
)}
</Box>
)}
{showShortcutsHelp && <ShortcutsHelp />}
{showUiDetails && <HorizontalLine />}
{showUiDetails && (
<Box
justifyContent={
settings.merged.ui.hideContextSummary
? 'flex-start'
: 'space-between'
}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems="center"
flexGrow={1}
>
{hasToast ? (
<ToastDisplay />
) : (
<Box
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{!showLoadingIndicator && (
<>
{uiState.shellModeActive && (
<Box
marginLeft={
showApprovalIndicator && !isNarrow ? 1 : 0
}
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
>
<ShellModeIndicator />
</Box>
)}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator ||
uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator ||
uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
</>
)}
</Box>
)}
</Box>
{showShortcutsHelp && <ShortcutsHelp />}
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
{!showLoadingIndicator && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
</Box>
</Box>
)}
{(showUiDetails || miniMode_ShowToast) && (
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
<ToastDisplay />
</Box>
)}
<Box width="100%" flexDirection="column">
{renderStatusRow()}
</Box>
{showUiDetails && uiState.showErrorDetails && (
@@ -16,7 +16,7 @@ import { GeminiSpinner } from './GeminiSpinner.js';
import { theme } from '../semantic-colors.js';
export const ConfigInitDisplay = ({
message: initialMessage = 'Initializing...',
message: initialMessage = 'Working...',
}: {
message?: string;
}) => {
@@ -45,14 +45,14 @@ export const ConfigInitDisplay = ({
const suffix = remaining > 0 ? `, +${remaining} more` : '';
const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size}) - Waiting for: ${displayedServers}${suffix}`;
setMessage(
initialMessage && initialMessage !== 'Initializing...'
initialMessage && initialMessage !== 'Working...'
? `${initialMessage} (${mcpMessage})`
: mcpMessage,
);
} else {
const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size})`;
setMessage(
initialMessage && initialMessage !== 'Initializing...'
initialMessage && initialMessage !== 'Working...'
? `${initialMessage} (${mcpMessage})`
: mcpMessage,
);
@@ -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
)}
<Box marginTop={1}>
<Box marginTop={1} flexDirection="column">
<RadioButtonSelect
items={[
{ label: 'Yes', value: true, key: 'Yes' },
@@ -45,6 +46,10 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
]}
onSelect={onConfirm}
/>
<DialogFooter
primaryAction="Enter to select"
navigationActions="↑/↓ to navigate"
/>
</Box>
</Box>
);
@@ -77,32 +77,6 @@ describe('<ContextSummaryDisplay />', () => {
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,
@@ -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<ContextSummaryDisplayProps> = ({
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<ContextSummaryDisplayProps> = ({
skillCount === 0 &&
backgroundProcessCount === 0
) {
return <Text> </Text>; // Render an empty space to reserve height
return null;
}
const openFilesText = (() => {
@@ -113,21 +109,14 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
backgroundText,
].filter(Boolean);
if (isNarrow) {
return (
<Box flexDirection="column" paddingX={1}>
{summaryParts.map((part, index) => (
<Text key={index} color={theme.text.secondary}>
- {part}
</Text>
))}
</Box>
);
}
return (
<Box paddingX={1}>
<Text color={theme.text.secondary}>{summaryParts.join(' | ')}</Text>
<Box paddingX={1} flexDirection="row" flexWrap="wrap">
{summaryParts.map((part, index) => (
<Box key={index} flexDirection="row">
{index > 0 && <Text color={theme.text.secondary}>{' · '}</Text>}
<Text color={theme.text.secondary}>{part}</Text>
</Box>
))}
</Box>
);
};
@@ -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 (
<GeminiSpinner
spinnerType={spinnerType}
@@ -43,7 +57,7 @@ export const GeminiRespondingSpinner: React.FC<
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_LOADING}</Text>
) : (
<Text color={theme.text.primary}>{nonRespondingDisplay}</Text>
<Text color={color ?? theme.text.primary}>{nonRespondingDisplay}</Text>
);
}
@@ -18,9 +18,10 @@ describe('<HookStatusDisplay />', () => {
const props = {
activeHooks: [{ name: 'test-hook', eventName: 'BeforeAgent' }],
};
const { lastFrame, unmount } = await render(
const { lastFrame, unmount, waitUntilReady } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -32,9 +33,10 @@ describe('<HookStatusDisplay />', () => {
{ name: 'h2', eventName: 'BeforeAgent' },
],
};
const { lastFrame, unmount } = await render(
const { lastFrame, unmount, waitUntilReady } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -45,19 +47,47 @@ describe('<HookStatusDisplay />', () => {
{ name: 'step', eventName: 'BeforeAgent', index: 1, total: 3 },
],
};
const { lastFrame, unmount } = await render(
const { lastFrame, unmount, waitUntilReady } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should return empty string if no active hooks', async () => {
const props = { activeHooks: [] };
const { lastFrame, unmount } = await render(
const { lastFrame, unmount, waitUntilReady } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('should show generic message when only system hooks are active', async () => {
const props = {
activeHooks: [
{ name: 'sys-hook', eventName: 'BeforeAgent', source: 'system' },
],
};
const { lastFrame, unmount, waitUntilReady } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Working...');
unmount();
});
it('matches SVG snapshot for single hook', async () => {
const props = {
activeHooks: [
{ name: 'test-hook', eventName: 'BeforeAgent', source: 'user' },
],
};
const result = await render(<HookStatusDisplay {...props} />);
await result.waitUntilReady();
await expect(result).toMatchSvgSnapshot();
result.unmount();
});
});
@@ -6,8 +6,10 @@
import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { type ActiveHook } from '../types.js';
import { isUserVisibleHook } from '@google/gemini-cli-core';
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
import { theme } from '../semantic-colors.js';
interface HookStatusDisplayProps {
activeHooks: ActiveHook[];
@@ -20,20 +22,30 @@ export const HookStatusDisplay: React.FC<HookStatusDisplayProps> = ({
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;
});
const userHooks = activeHooks.filter((h) => isUserVisibleHook(h.source));
const text = `${label}: ${displayNames.join(', ')}`;
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 color={theme.text.secondary} italic={true}>
{text}
</Text>
);
}
// If only system/extension hooks are running, show a generic message.
return (
<Text color={theme.status.warning} wrap="truncate">
{text}
<Text color={theme.text.secondary} italic={true}>
{GENERIC_WORKING_LABEL}
</Text>
);
};
@@ -10,7 +10,7 @@ import { Text } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { vi } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
// Mock GeminiRespondingSpinner
@@ -50,26 +50,28 @@ const renderWithContext = async (
describe('<LoadingIndicator />', () => {
const defaultProps = {
currentLoadingPhrase: 'Loading...',
currentLoadingPhrase: 'Thinking...',
elapsedTime: 5,
};
it('should render blank when streamingState is Idle and no loading phrase or thought', async () => {
const { lastFrame } = await renderWithContext(
const { lastFrame, waitUntilReady } = await renderWithContext(
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('');
});
it('should render spinner, phrase, and time when streamingState is Responding', async () => {
const { lastFrame } = await renderWithContext(
const { lastFrame, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Loading...');
expect(output).toContain('Thinking...');
expect(output).toContain('(esc to cancel, 5s)');
});
@@ -78,10 +80,11 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'Confirm action',
elapsedTime: 10,
};
const { lastFrame } = await renderWithContext(
const { lastFrame, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.WaitingForConfirmation,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
@@ -94,46 +97,50 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'Processing data...',
elapsedTime: 3,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('Processing data...');
unmount();
});
it('should display the elapsedTime correctly when Responding', async () => {
const props = {
currentLoadingPhrase: 'Working...',
currentLoadingPhrase: 'Thinking...',
elapsedTime: 60,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('(esc to cancel, 1m)');
unmount();
});
it('should display the elapsedTime correctly in human-readable format', async () => {
const props = {
currentLoadingPhrase: 'Working...',
currentLoadingPhrase: 'Thinking...',
elapsedTime: 125,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
unmount();
});
it('should render rightContent when provided', async () => {
const rightContent = <Text>Extra Info</Text>;
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} rightContent={rightContent} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('Extra Info');
unmount();
});
@@ -174,6 +181,7 @@ describe('<LoadingIndicator />', () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<TestWrapper />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Initial: Idle (no loading phrase)
// Transition to Responding
@@ -221,15 +229,16 @@ describe('<LoadingIndicator />', () => {
it('should display fallback phrase if thought is empty', async () => {
const props = {
thought: null,
currentLoadingPhrase: 'Loading...',
currentLoadingPhrase: 'Thinking...',
elapsedTime: 5,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Loading...');
expect(output).toContain('Thinking...');
unmount();
});
@@ -241,10 +250,11 @@ describe('<LoadingIndicator />', () => {
},
elapsedTime: 5,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
if (output) {
@@ -256,7 +266,7 @@ describe('<LoadingIndicator />', () => {
unmount();
});
it('should prepend "Thinking... " if the subject does not start with "Thinking"', async () => {
it('should NOT prepend "Thinking... " even if the subject does not start with "Thinking"', async () => {
const props = {
thought: {
subject: 'Planning the response...',
@@ -264,12 +274,14 @@ describe('<LoadingIndicator />', () => {
},
elapsedTime: 5,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking... Planning the response...');
expect(output).toContain('Planning the response...');
expect(output).not.toContain('Thinking... ');
unmount();
});
@@ -282,31 +294,32 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'This should not be displayed',
elapsedTime: 5,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking... ');
expect(output).toContain('This should be displayed');
expect(output).not.toContain('This should not be displayed');
unmount();
});
it('should not display thought indicator for non-thought loading phrases', async () => {
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
currentLoadingPhrase="some random tip..."
elapsedTime={3}
/>,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Thinking... ');
unmount();
});
it('should truncate long primary text instead of wrapping', async () => {
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
currentLoadingPhrase={
@@ -316,14 +329,14 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
80,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
describe('responsive layout', () => {
it('should render on a single line on a wide terminal', async () => {
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
@@ -331,17 +344,18 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
120,
);
await waitUntilReady();
const output = lastFrame();
// Check for single line output
expect(output?.trim().includes('\n')).toBe(false);
expect(output).toContain('Loading...');
expect(output).toContain('Thinking...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('Right');
unmount();
});
it('should render on multiple lines on a narrow terminal', async () => {
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
@@ -349,6 +363,7 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
79,
);
await waitUntilReady();
const output = lastFrame();
const lines = output?.trim().split('\n');
// Expecting 3 lines:
@@ -357,7 +372,7 @@ describe('<LoadingIndicator />', () => {
// 3. Right Content
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Loading...');
expect(lines[0]).toContain('Thinking...');
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[2]).toContain('Right');
@@ -366,23 +381,87 @@ describe('<LoadingIndicator />', () => {
});
it('should use wide layout at 80 columns', async () => {
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
80,
);
await waitUntilReady();
expect(lastFrame()?.trim().includes('\n')).toBe(false);
unmount();
});
it('should use narrow layout at 79 columns', async () => {
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
79,
);
await waitUntilReady();
expect(lastFrame()?.includes('\n')).toBe(true);
unmount();
});
it('should render witty phrase after cancel and timer hint in wide layout', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
elapsedTime={5}
wittyPhrase="I am witty"
showWit={true}
currentLoadingPhrase="Thinking..."
/>,
StreamingState.Responding,
120,
);
await waitUntilReady();
const output = lastFrame();
// Sequence should be: Primary Text -> Cancel/Timer -> Witty Phrase
expect(output).toContain('Thinking... (esc to cancel, 5s) I am witty');
unmount();
});
it('should render witty phrase after cancel and timer hint in narrow layout', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
elapsedTime={5}
wittyPhrase="I am witty"
showWit={true}
currentLoadingPhrase="Thinking..."
/>,
StreamingState.Responding,
79,
);
await waitUntilReady();
const output = lastFrame();
const lines = output?.trim().split('\n');
// Expecting 3 lines:
// 1. Spinner + Primary Text
// 2. Cancel + Timer
// 3. Witty Phrase
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Thinking...');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[2]).toContain('I am witty');
}
unmount();
});
});
it('should use spinnerIcon when provided', async () => {
const props = {
currentLoadingPhrase: 'Confirm action',
elapsedTime: 10,
spinnerIcon: '?',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.WaitingForConfirmation,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('?');
expect(output).not.toContain('⠏');
unmount();
});
});
@@ -18,22 +18,34 @@ import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
wittyPhrase?: string;
showWit?: boolean;
showTips?: boolean;
errorVerbosity?: 'low' | 'full';
elapsedTime: number;
inline?: boolean;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
thoughtLabel?: string;
showCancelAndTimer?: boolean;
forceRealStatusOnly?: boolean;
spinnerIcon?: string;
isHookActive?: boolean;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
currentLoadingPhrase,
wittyPhrase,
showWit = false,
elapsedTime,
inline = false,
rightContent,
thought,
thoughtLabel,
showCancelAndTimer = true,
forceRealStatusOnly = false,
spinnerIcon,
isHookActive = false,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
@@ -54,15 +66,10 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
? currentLoadingPhrase
: thought?.subject
? (thoughtLabel ?? thought.subject)
: currentLoadingPhrase;
const hasThoughtIndicator =
currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
Boolean(thought?.subject?.trim());
// Avoid "Thinking... Thinking..." duplication if primaryText already starts with "Thinking"
const thinkingIndicator =
hasThoughtIndicator && !primaryText?.startsWith('Thinking')
? 'Thinking... '
: '';
: currentLoadingPhrase ||
(streamingState === StreamingState.Responding
? 'Thinking...'
: undefined);
const cancelAndTimerContent =
showCancelAndTimer &&
@@ -70,22 +77,35 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null;
const wittyPhraseNode =
!forceRealStatusOnly &&
showWit &&
wittyPhrase &&
primaryText === 'Thinking...' ? (
<Box marginLeft={1}>
<Text color={theme.text.secondary} dimColor italic>
{wittyPhrase}
</Text>
</Box>
) : null;
if (inline) {
return (
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
: '')
}
isHookActive={isHookActive}
/>
</Box>
{primaryText && (
<Box flexShrink={1}>
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
@@ -102,6 +122,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</>
)}
{wittyPhraseNode}
</Box>
);
}
@@ -118,16 +139,17 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
: '')
}
isHookActive={isHookActive}
/>
</Box>
{primaryText && (
<Box flexShrink={1}>
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
@@ -144,6 +166,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</>
)}
{!isNarrow && wittyPhraseNode}
</Box>
{!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
{!isNarrow && rightContent && <Box>{rightContent}</Box>}
@@ -153,6 +176,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</Box>
)}
{isNarrow && wittyPhraseNode}
{isNarrow && rightContent && <Box>{rightContent}</Box>}
</Box>
);
@@ -1,24 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
export const ShortcutsHint: React.FC = () => {
const { cleanUiDetailsVisible, shortcutsHelpVisible } = useUIState();
if (!cleanUiDetailsVisible) {
return <Text color={theme.text.secondary}> press tab twice for more </Text>;
}
const highlightColor = shortcutsHelpVisible
? theme.text.accent
: theme.text.secondary;
return <Text color={highlightColor}> ? for shortcuts </Text>;
};
@@ -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<StatusDisplayProps> = ({
return <Text color={theme.status.error}>|_|</Text>;
}
if (
uiState.activeHooks.length > 0 &&
settings.merged.hooksConfig.notifications
) {
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
}
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
return (
<ContextSummaryDisplay
@@ -77,7 +77,7 @@ export const ToastDisplay: React.FC = () => {
if (uiState.showIsExpandableHint) {
const action = uiState.constrainHeight ? 'show more' : 'collapse';
return (
<Text color={theme.text.accent}>
<Text color={theme.text.secondary}>
Press Ctrl+O to {action} lines of the last response
</Text>
);
@@ -11,17 +11,6 @@ Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 2`] = `
"Select your preferred language:
1. TypeScript
2. JavaScript
● 3. Enter a custom value
Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = `
"Select your preferred language:
@@ -33,17 +22,6 @@ Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 2`] = `
"Select your preferred language:
1. TypeScript
2. JavaScript
● 3. Type another language...
Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
"Choose an option
@@ -60,20 +38,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 2`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
"Choose an option
@@ -90,45 +54,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 2`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
3. Option 3
Description 3
4. Option 4
Description 4
5. Option 5
Description 5
6. Option 6
Description 6
7. Option 7
Description 7
8. Option 8
Description 8
9. Option 9
Description 9
10. Option 10
Description 10
11. Option 11
Description 11
12. Option 12
Description 12
13. Option 13
Description 13
14. Option 14
Description 14
15. Option 15
Description 15
16. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
"What should we name this component?
@@ -271,19 +196,3 @@ exports[`AskUserDialog > verifies "All of the above" visual state with snapshot
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > verifies "All of the above" visual state with snapshot 2`] = `
"Which features?
(Select all that apply)
1. [x] TypeScript
2. [x] ESLint
● 3. [x] All of the above
Select all options
4. [ ] Enter a custom value
Done
Finish selection
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -1,33 +1,33 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Composer > Snapshots > matches snapshot in idle state 1`] = `
" ShortcutsHint
"
? for shortcuts
────────────────────────────────────────────────────────────────────────────────────────────────────
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
" press tab twice for more
InputPrompt: Type your message or @path/to/file
"
`;
exports[`Composer > Snapshots > matches snapshot in minimal UI mode while loading 1`] = `
" LoadingIndicator
"LoadingIndicator press tab twice for more
InputPrompt: Type your message or @path/to/file
"
`;
exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = `
"
ShortcutsHint
? for shortcuts
────────────────────────────────────────
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
"
LoadingIndicator: Thinking ? for shortcuts
────────────────────────────────────────────────────────────────────────────────────────────────────
ApprovalModeIndicator
ApprovalModeIndicator: default StatusDisplay
InputPrompt: Type your message or @path/to/file
Footer
"
@@ -2,24 +2,24 @@
exports[`ConfigInitDisplay > handles empty clients map 1`] = `
"
Spinner Initializing...
Spinner Working...
"
`;
exports[`ConfigInitDisplay > renders initial state 1`] = `
"
Spinner Initializing...
Spinner Working...
"
`;
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 1`] = `
"
Spinner Initializing...
Spinner Working...
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
"
Spinner Initializing...
Spinner Working...
"
`;
@@ -1,19 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ContextSummaryDisplay /> > should not render empty parts 1`] = `
" - 1 open file (ctrl+g to view)
" 1 open file (ctrl+g to view)
"
`;
exports[`<ContextSummaryDisplay /> > 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[`<ContextSummaryDisplay /> > 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
"
`;
@@ -27,33 +27,6 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Type your feedback...
Enter to submit · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = `
"Overview
@@ -81,33 +54,6 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Add tests
Enter to submit · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `
" Error reading plan: File not found
"
@@ -194,33 +140,6 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Type your feedback...
Enter to submit · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = `
"Overview
@@ -248,33 +167,6 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Add tests
Enter to submit · Ctrl+X to edit plan · Esc to cancel
"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `
" Error reading plan: File not found
"
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="37" viewBox="0 0 920 37">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="37" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#afafaf" textLength="225" lengthAdjust="spacingAndGlyphs" font-style="italic">Executing Hook: test-hook</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 479 B

@@ -1,5 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<HookStatusDisplay /> > matches SVG snapshot for single hook 1`] = `"Executing Hook: test-hook"`;
exports[`<HookStatusDisplay /> > should render a single executing hook 1`] = `
"Executing Hook: test-hook
"
@@ -78,27 +78,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
@@ -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)
"
`;
@@ -10,10 +10,12 @@ import { theme } from '../../semantic-colors.js';
interface HorizontalLineProps {
color?: string;
dim?: boolean;
}
export const HorizontalLine: React.FC<HorizontalLineProps> = ({
color = theme.border.default,
dim = false,
}) => (
<Box
width="100%"
@@ -23,5 +25,6 @@ export const HorizontalLine: React.FC<HorizontalLineProps> = ({
borderLeft={false}
borderRight={false}
borderColor={color}
borderDimColor={dim}
/>
);