feat(ui): composer UX refresh

- Implement refreshed multi-row status area with flattened visibility logic.
- Stabilize Composer row heights to prevent layout jumping during debounce and typing.
- Refactor renderStatusRow to use a direct flow for Mini(ized) mode, Shell Waiting, and status states.
- Relocate ToastDisplay to top Composer row
- Migrate Composer tests to use real ToastDisplay component and content-based assertions.
- Regenerate all CLI UI snapshots to match the final architecture.
This commit is contained in:
Jarrod Whelan
2026-03-04 16:26:38 -08:00
committed by Jarrod Whelan
parent cd7dced951
commit c210b57ab9
52 changed files with 1326 additions and 1082 deletions

View File

@@ -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();

View File

@@ -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,

View File

@@ -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
"

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,7 +80,9 @@ vi.mock('./HookStatusDisplay.js', () => ({
}));
vi.mock('./ApprovalModeIndicator.js', () => ({
ApprovalModeIndicator: () => <Text>ApprovalModeIndicator</Text>,
ApprovalModeIndicator: ({ approvalMode }: { approvalMode: ApprovalMode }) => (
<Text>ApprovalModeIndicator: {approvalMode}</Text>
),
}));
vi.mock('./ShellModeIndicator.js', () => ({
@@ -174,6 +175,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
isFocused: true,
thought: '',
currentLoadingPhrase: '',
currentTip: '',
currentWittyPhrase: '',
elapsedTime: 0,
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
@@ -202,6 +205,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): 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 (
<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>
@@ -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: (
<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();

View File

@@ -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<string, unknown>)[
'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 (
<Box
flexDirection="row"
justifyContent="flex-end"
marginLeft={1}
marginRight={1}
>
<Text
color={theme.text.secondary}
wrap="truncate-end"
italic={ambientContentStr === uiState.currentWittyPhrase}
>
{ambientContentStr === uiState.currentTip
? `Tip: ${ambientContentStr}`
: ambientContentStr}
</Text>
</Box>
);
};
const renderStatusNode = () => {
if (hasUserHooks) {
const activeHook = userHooks[0];
const hookIcon = activeHook?.eventName?.startsWith('After') ? '↩' : '↪';
return (
<Box flexDirection="row" alignItems="center">
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={hookIcon}
isHookActive={true}
/>
</Box>
<Text color={theme.text.primary} italic wrap="truncate-end">
<HookStatusDisplay activeHooks={userHooks} />
</Text>
{showWit && uiState.currentWittyPhrase && (
<Box marginLeft={1}>
<Text color={theme.text.secondary} dimColor italic>
{uiState.currentWittyPhrase} :)
</Text>
</Box>
)}
</Box>
);
}
if (showLoadingIndicator) {
return (
<LoadingIndicator
inline
loadingPhrases={loadingPhrases}
errorVerbosity={settings.merged.ui.errorVerbosity}
thought={uiState.thought}
elapsedTime={uiState.elapsedTime}
forceRealStatusOnly={false}
showCancelAndTimer={false}
wittyPhrase={uiState.currentWittyPhrase}
/>
);
}
return null;
};
const statusNode = renderStatusNode();
/**
* Renders the minimal metadata row content shown when UI details are hidden.
*/
const renderMinimalMetaRowContent = () => (
<Box flexDirection="row">
{showMinimalInlineLoading && (
<LoadingIndicator
inline
loadingPhrases={loadingPhrases}
errorVerbosity={settings.merged.ui.errorVerbosity}
elapsedTime={uiState.elapsedTime}
forceRealStatusOnly={true}
showCancelAndTimer={false}
/>
)}
{hasUserHooks && (
<Box marginLeft={showMinimalInlineLoading ? 1 : 0}>
<Box marginRight={1}>
<GeminiRespondingSpinner isHookActive={true} />
</Box>
<Text color={theme.text.primary} italic>
<HookStatusDisplay activeHooks={userHooks} />
</Text>
</Box>
)}
{showMinimalBleedThroughRow && (
<Box marginLeft={showMinimalInlineLoading || hasUserHooks ? 1 : 0}>
{miniMode_ShowApprovalMode && modeContentObj && (
<Text color={modeContentObj.color}> {modeContentObj.text}</Text>
)}
{/* {zenMode_ShowToast && (
<Box
marginLeft={
showMinimalInlineLoading ||
zenMode_ShowApprovalMode ||
hasUserHooks
? 1
: 0
}
>
<ToastDisplay />
</Box>
)} */}
</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 && miniMode_ShowStatus ? (
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 && (
<>
{showShortcutsHint && <ShortcutsHint />}
{!showShortcutsHint && showAmbientLine && renderAmbientNode()}
</>
)}
</Box>
</Box>
)}
{/* Internal Separator Line */}
{showRow1 &&
showRow2 &&
(showUiDetails || (showRow1_MiniMode && showRow2_MiniMode)) && (
<Box width="100%" marginLeft={1} marginRight={1}>
<Box
borderStyle="single"
borderTop
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor={theme.border.default}
borderDimColor={true}
width="100%"
height={1}
/>
</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} />
)}
</Box>
</Box>
)}
</Box>
);
};
return (
<Box
@@ -210,212 +569,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 && (

View File

@@ -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>
);

View File

@@ -78,32 +78,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,

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -64,4 +64,18 @@ describe('<HookStatusDisplay />', () => {
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(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Working...');
unmount();
});
});

View File

@@ -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<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;
});
// 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 color={theme.status.warning} wrap="truncate">
{text}
</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 color="inherit">{text}</Text>;
}
// If only system/extension hooks are running, show a generic message.
return <Text color="inherit">{GENERIC_WORKING_LABEL}</Text>;
};

View File

@@ -50,7 +50,7 @@ const renderWithContext = (
describe('<LoadingIndicator />', () => {
const defaultProps = {
currentLoadingPhrase: 'Loading...',
currentLoadingPhrase: 'Working...',
elapsedTime: 5,
};
@@ -71,8 +71,8 @@ describe('<LoadingIndicator />', () => {
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('<LoadingIndicator />', () => {
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('(esc to cancel, 1m)');
expect(lastFrame()).toContain('esc to cancel, 1m');
unmount();
});
@@ -130,7 +130,7 @@ describe('<LoadingIndicator />', () => {
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('<LoadingIndicator />', () => {
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('<LoadingIndicator />', () => {
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('<LoadingIndicator />', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Loading...');
expect(output).toContain('Working...');
unmount();
});
@@ -349,8 +349,8 @@ describe('<LoadingIndicator />', () => {
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('<LoadingIndicator />', () => {
// 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();

View File

@@ -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<LoadingIndicatorProps> = ({
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<LoadingIndicatorProps> = ({
? 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<LoadingIndicatorProps> = ({
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 ? (
<Box marginLeft={1}>
<Text color={theme.text.secondary} dimColor italic>
{wittyPhrase}
</Text>
</Box>
) : null;
if (inline) {
return (
<Box>
@@ -96,6 +128,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
)}
</Box>
)}
{wittyPhraseNode}
{cancelAndTimerContent && (
<>
<Box flexShrink={0} width={1} />
@@ -138,6 +171,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
)}
</Box>
)}
{wittyPhraseNode}
{!isNarrow && cancelAndTimerContent && (
<>
<Box flexShrink={0} width={1} />

View File

@@ -20,5 +20,5 @@ export const ShortcutsHint: React.FC = () => {
? theme.text.accent
: theme.text.secondary;
return <Text color={highlightColor}> ? for shortcuts </Text>;
return <Text color={highlightColor}>? for shortcuts</Text>;
};

View File

@@ -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

View File

@@ -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>
);

View File

@@ -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
"

View File

@@ -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

View File

@@ -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
"
`;

View File

@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<LoadingIndicator /> > 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
"
`;

View File

@@ -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 │

View 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)
"
`;

View File

@@ -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 │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;

View File

@@ -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();
});

View File

@@ -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<
<Text color={theme.text.primary}>{question}</Text>
</Box>
<Box flexShrink={0}>
<Box flexShrink={0} flexDirection="column">
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={isFocused}
initialIndex={initialIndex}
/>
<DialogFooter
primaryAction="Enter to select"
navigationActions="↑/↓ to navigate"
extraParts={
hasMcpToolDetails
? [
`${expandDetailsHintKey} to ${isMcpToolDetailsExpanded ? 'collapse' : 'expand'} details`,
]
: []
}
/>
</Box>
</>
)}

View File

@@ -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 (
<Box marginLeft={1} flexShrink={0}>
<Text color={isThisShellFocused ? theme.ui.focus : theme.ui.active}>
<Text color={theme.status.warning}>
{isThisShellFocused
? `(${formatCommand(Command.UNFOCUS_SHELL_INPUT)} to unfocus)`
: `(${formatCommand(Command.FOCUS_SHELL_INPUT)} to focus)`}
@@ -150,7 +150,7 @@ export const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
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<ToolStatusIndicatorProps> = ({
<Text color={theme.status.success}>{TOOL_STATUS.PENDING}</Text>
)}
{status === ToolCallStatus.Executing && (
<Text color={statusColor}>
<CliSpinner type="toggle" />
</Text>
<GeminiRespondingSpinner
spinnerType="toggle"
nonRespondingDisplay={TOOL_STATUS.EXECUTING}
color={isFocused ? theme.ui.focus : undefined}
/>
)}
{status === ToolCallStatus.Success && (
<Text color={theme.status.success} aria-label={'Success:'}>

View File

@@ -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
"
`;

View File

@@ -2,7 +2,7 @@
exports[`<ShellToolMessage /> > 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[`<ShellToolMessage /> > Height Constraints > fully expands in alternate
exports[`<ShellToolMessage /> > 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[`<ShellToolMessage /> > Height Constraints > stays constrained in altern
exports[`<ShellToolMessage /> > 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[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
exports[`<ShellToolMessage /> > 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[`<ShellToolMessage /> > Height Constraints > uses full availableTerminal
exports[`<ShellToolMessage /> > 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[`<ShellToolMessage /> > Snapshots > renders in Alternate Buffer mode whi
exports[`<ShellToolMessage /> > 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[`<ShellToolMessage /> > Snapshots > renders in Error state 1`] = `
exports[`<ShellToolMessage /> > Snapshots > renders in Executing state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
Shell Command A shell command │
Shell Command A shell command │
│ │
│ Test result │
"

View File

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="173" viewBox="0 0 920 173">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="173" fill="#000000" />
<rect width="920" height="207" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">echo</text>
<text x="45" y="2" fill="#cdcd00" textLength="63" lengthAdjust="spacingAndGlyphs">&quot;hello&quot;</text>
@@ -23,10 +23,11 @@
<rect x="36" y="102" width="9" height="17" fill="#001a00" />
<rect x="45" y="102" width="90" height="17" fill="#001a00" />
<text x="45" y="104" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="135" y="102" width="135" height="17" fill="#001a00" />
<rect x="135" y="102" width="765" height="17" fill="#001a00" />
<text x="18" y="121" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">2.</text>
<text x="45" y="121" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">Allow for this session</text>
<text x="18" y="138" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">3.</text>
<text x="45" y="138" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">No, suggest changes (esc)</text>
<text x="0" y="172" fill="#afafaf" textLength="441" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · Esc to cancel</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -8,11 +8,13 @@ exports[`ToolConfirmationMessage > enablePermanentToolApproval setting > should
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. Allow for this file in all future sessions
4. Modify with external editor
5. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -24,9 +26,11 @@ ls -la
whoami
Allow execution of 3 commands?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -37,9 +41,11 @@ URLs to fetch:
- https://raw.githubusercontent.com/google/gemini-react/main/README.md
Do you want to proceed?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -47,9 +53,11 @@ exports[`ToolConfirmationMessage > should not display urls if prompt and url are
"https://example.com
Do you want to proceed?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -60,9 +68,11 @@ for i in 1 2 3; do
done
Allow execution of: 'echo'?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -71,10 +81,12 @@ exports[`ToolConfirmationMessage > should strip BiDi characters from MCP tool an
Tool: testtool
Allow execution of MCP tool "testtool" from server "testserver"?
● 1. Allow once
● 1. Allow once
2. Allow tool for this session
3. Allow all server tools for this session
4. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -86,9 +98,11 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
● 1. Allow once
2. Modify with external editor
3. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -100,10 +114,12 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. Modify with external editor
4. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -111,8 +127,10 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
"echo "hello"
Allow execution of: 'echo'?
● 1. Allow once
● 1. Allow once
2. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -120,9 +138,11 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
"echo "hello"
Allow execution of: 'echo'?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -130,8 +150,10 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
"https://example.com
Do you want to proceed?
● 1. Allow once
● 1. Allow once
2. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -139,9 +161,11 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
"https://example.com
Do you want to proceed?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -150,8 +174,10 @@ exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' >
Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
● 1. Allow once
● 1. Allow once
2. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
@@ -160,9 +186,11 @@ exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' >
Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
● 1. Allow once
● 1. Allow once
2. Allow tool for this session
3. Allow all server tools for this session
4. No, suggest changes (esc)
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;

View File

@@ -71,7 +71,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls incl
│ │
│ Test result │
│ │
run_shell_command Run command │
run_shell_command Run command │
│ │
│ Test result │
│ │

View File

@@ -29,7 +29,7 @@ exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows - for Canceled
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows MockRespondingSpinner for Executing status when streamingState is Responding 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
test-tool A tool for testing
MockRespondingSpinnertest-tool A tool for testing │
│ │
│ Test result │
"
@@ -45,7 +45,7 @@ exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows o for Pending s
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is Idle 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
test-tool A tool for testing
MockRespondingSpinnertest-tool A tool for testing │
│ │
│ Test result │
"
@@ -53,7 +53,7 @@ exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is WaitingForConfirmation 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
test-tool A tool for testing
MockRespondingSpinnertest-tool A tool for testing │
│ │
│ Test result │
"
@@ -94,7 +94,7 @@ exports[`<ToolMessage /> > renders DiffRenderer for diff results 1`] = `
exports[`<ToolMessage /> > renders McpProgressIndicator with percentage and message for executing tools 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
test-tool A tool for testing
MockRespondingSpinnertest-tool A tool for testing │
│ │
│ ████████░░░░░░░░░░░░ 42% │
│ Working on it... │
@@ -128,7 +128,7 @@ exports[`<ToolMessage /> > renders emphasis correctly 2`] = `
exports[`<ToolMessage /> > renders indeterminate progress when total is missing 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
test-tool A tool for testing
MockRespondingSpinnertest-tool A tool for testing │
│ │
│ ███████░░░░░░░░░░░░░ 7 │
│ Test result │
@@ -137,7 +137,7 @@ exports[`<ToolMessage /> > renders indeterminate progress when total is missing
exports[`<ToolMessage /> > renders only percentage when progressMessage is missing 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
test-tool A tool for testing
MockRespondingSpinnertest-tool A tool for testing │
│ │
│ ███████████████░░░░░ 75% │
│ Test result │

View File

@@ -2,63 +2,63 @@
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
Shell Command A tool for testing (Tab to focus) │
Shell Command A tool for testing (Tab to focus) │
│ │
"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > initial-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
Shell Command A tool for testing │
Shell Command A tool for testing │
│ │
"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
Shell Command A tool for testing (Tab to focus) │
Shell Command A tool for testing (Tab to focus) │
│ │
"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > initial-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
Shell Command A tool for testing │
Shell Command A tool for testing │
│ │
"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
Shell Command A tool for testing (Tab to focus) │
Shell Command A tool for testing (Tab to focus) │
│ │
"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > initial-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
Shell Command A tool for testing │
Shell Command A tool for testing │
│ │
"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
Shell Command A tool for testing (Tab to focus) │
Shell Command A tool for testing (Tab to focus) │
│ │
"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > initial-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
Shell Command A tool for testing │
Shell Command A tool for testing │
│ │
"
`;
exports[`Focus Hint > handles long descriptions by shrinking them to show the focus hint > long-description 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (Tab to focus) │
Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (Tab to focus) │
│ │
"
`;

View File

@@ -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}
/>
);

View File

@@ -6,160 +6,160 @@
export const INFORMATIVE_TIPS = [
//Settings tips start here
'Set your preferred editor for opening files (/settings)',
'Toggle Vim mode for a modal editing experience (/settings)',
'Disable automatic updates if you prefer manual control (/settings)',
'Turn off nagging update notifications (settings.json)',
'Enable checkpointing to recover your session after a crash (settings.json)',
'Change CLI output format to JSON for scripting (/settings)',
'Personalize your CLI with a new color theme (/settings)',
'Create and use your own custom themes (settings.json)',
'Hide window title for a more minimal UI (/settings)',
"Don't like these tips? You can hide them (/settings)",
'Hide the startup banner for a cleaner launch (/settings)',
'Hide the context summary above the input (/settings)',
'Reclaim vertical space by hiding the footer (/settings)',
'Hide individual footer elements like CWD or sandbox status (/settings)',
'Hide the context window percentage in the footer (/settings)',
'Show memory usage for performance monitoring (/settings)',
'Show line numbers in the chat for easier reference (/settings)',
'Show citations to see where the model gets information (/settings)',
'Customize loading phrases: tips, witty, all, or off (/settings)',
'Add custom witty phrases to the loading screen (settings.json)',
'Use alternate screen buffer to preserve shell history (/settings)',
'Choose a specific Gemini model for conversations (/settings)',
'Limit the number of turns in your session history (/settings)',
'Automatically summarize large tool outputs to save tokens (settings.json)',
'Control when chat history gets compressed based on context compression threshold (settings.json)',
'Define custom context file names, like CONTEXT.md (settings.json)',
'Set max directories to scan for context files (/settings)',
'Expand your workspace with additional directories (/directory)',
'Control how /memory reload loads context files (/settings)',
'Toggle respect for .gitignore files in context (/settings)',
'Toggle respect for .geminiignore files in context (/settings)',
'Enable recursive file search for @-file completions (/settings)',
'Disable fuzzy search when searching for files (/settings)',
'Run tools in a secure sandbox environment (settings.json)',
'Use an interactive terminal for shell commands (/settings)',
'Show color in shell command output (/settings)',
'Automatically accept safe read-only tool calls (/settings)',
'Restrict available built-in tools (settings.json)',
'Exclude specific tools from being used (settings.json)',
'Bypass confirmation for trusted tools (settings.json)',
'Use a custom command for tool discovery (settings.json)',
'Define a custom command for calling discovered tools (settings.json)',
'Define and manage connections to MCP servers (settings.json)',
'Enable folder trust to enhance security (/settings)',
'Disable YOLO mode to enforce confirmations (settings.json)',
'Block Git extensions for enhanced security (settings.json)',
'Change your authentication method (/settings)',
'Enforce auth type for enterprise use (settings.json)',
'Let Node.js auto-configure memory (settings.json)',
'Retry on fetch failed errors automatically (settings.json)',
'Customize the DNS resolution order (settings.json)',
'Exclude env vars from the context (settings.json)',
'Configure a custom command for filing bug reports (settings.json)',
'Enable or disable telemetry collection (/settings)',
'Send telemetry data to a local file or GCP (settings.json)',
'Configure the OTLP endpoint for telemetry (settings.json)',
'Choose whether to log prompt content (settings.json)',
'Enable AI-powered prompt completion while typing (/settings)',
'Enable debug logging of keystrokes to the console (/settings)',
'Enable automatic session cleanup of old conversations (/settings)',
'Show Gemini CLI status in the terminal window title (/settings)',
'Use the entire width of the terminal for output (/settings)',
'Enable screen reader mode for better accessibility (/settings)',
'Skip the next speaker check for faster responses (/settings)',
'Use ripgrep for faster file content search (/settings)',
'Enable truncation of large tool outputs to save tokens (/settings)',
'Set the character threshold for truncating tool outputs (/settings)',
'Set the number of lines to keep when truncating outputs (/settings)',
'Enable policy-based tool confirmation via message bus (/settings)',
'Enable write_todos_list tool to generate task lists (/settings)',
'Enable experimental subagents for task delegation (/settings)',
'Enable extension management features (settings.json)',
'Enable extension reloading within the CLI session (settings.json)',
'Set your preferred editor for opening files (/settings)',
'Toggle Vim mode for a modal editing experience (/settings)',
'Disable automatic updates if you prefer manual control (/settings)',
'Turn off nagging update notifications (settings.json)',
'Enable checkpointing to recover your session after a crash (settings.json)',
'Change CLI output format to JSON for scripting (/settings)',
'Personalize your CLI with a new color theme (/settings)',
'Create and use your own custom themes (settings.json)',
'Hide window title for a more minimal UI (/settings)',
"Don't like these tips? You can hide them (/settings)",
'Hide the startup banner for a cleaner launch (/settings)',
'Hide the context summary above the input (/settings)',
'Reclaim vertical space by hiding the footer (/settings)',
'Hide individual footer elements like CWD or sandbox status (/settings)',
'Hide the context window percentage in the footer (/settings)',
'Show memory usage for performance monitoring (/settings)',
'Show line numbers in the chat for easier reference (/settings)',
'Show citations to see where the model gets information (/settings)',
'Customize loading phrases: tips, witty, all, or off (/settings)',
'Add custom witty phrases to the loading screen (settings.json)',
'Use alternate screen buffer to preserve shell history (/settings)',
'Choose a specific Gemini model for conversations (/settings)',
'Limit the number of turns in your session history (/settings)',
'Automatically summarize large tool outputs to save tokens (settings.json)',
'Control when chat history gets compressed based on token usage (settings.json)',
'Define custom context file names, like CONTEXT.md (settings.json)',
'Set max directories to scan for context files (/settings)',
'Expand your workspace with additional directories (/directory)',
'Control how /memory reload loads context files (/settings)',
'Toggle respect for .gitignore files in context (/settings)',
'Toggle respect for .geminiignore files in context (/settings)',
'Enable recursive file search for @-file completions (/settings)',
'Disable fuzzy search when searching for files (/settings)',
'Run tools in a secure sandbox environment (settings.json)',
'Use an interactive terminal for shell commands (/settings)',
'Show color in shell command output (/settings)',
'Automatically accept safe read-only tool calls (/settings)',
'Restrict available built-in tools (settings.json)',
'Exclude specific tools from being used (settings.json)',
'Bypass confirmation for trusted tools (settings.json)',
'Use a custom command for tool discovery (settings.json)',
'Define a custom command for calling discovered tools (settings.json)',
'Define and manage connections to MCP servers (settings.json)',
'Enable folder trust to enhance security (/settings)',
'Disable YOLO mode to enforce confirmations (settings.json)',
'Block Git extensions for enhanced security (settings.json)',
'Change your authentication method (/settings)',
'Enforce auth type for enterprise use (settings.json)',
'Let Node.js auto-configure memory (settings.json)',
'Retry on fetch failed errors automatically (settings.json)',
'Customize the DNS resolution order (settings.json)',
'Exclude env vars from the context (settings.json)',
'Configure a custom command for filing bug reports (settings.json)',
'Enable or disable telemetry collection (/settings)',
'Send telemetry data to a local file or GCP (settings.json)',
'Configure the OTLP endpoint for telemetry (settings.json)',
'Choose whether to log prompt content (settings.json)',
'Enable AI-powered prompt completion while typing (/settings)',
'Enable debug logging of keystrokes to the console (/settings)',
'Enable automatic session cleanup of old conversations (/settings)',
'Show Gemini CLI status in the terminal window title (/settings)',
'Use the entire width of the terminal for output (/settings)',
'Enable screen reader mode for better accessibility (/settings)',
'Skip the next speaker check for faster responses (/settings)',
'Use ripgrep for faster file content search (/settings)',
'Enable truncation of large tool outputs to save tokens (/settings)',
'Set the character threshold for truncating tool outputs (/settings)',
'Set the number of lines to keep when truncating outputs (/settings)',
'Enable policy-based tool confirmation via message bus (/settings)',
'Enable write_todos_list tool to generate task lists (/settings)',
'Enable experimental subagents for task delegation (/settings)',
'Enable extension management features (settings.json)',
'Enable extension reloading within the CLI session (settings.json)',
//Settings tips end here
// Keyboard shortcut tips start here
'Close dialogs and suggestions with Esc',
'Cancel a request with Ctrl+C, or press twice to exit',
'Exit the app with Ctrl+D on an empty line',
'Clear your screen at any time with Ctrl+L',
'Toggle the debug console display with F12',
'Toggle the todo list display with Ctrl+T',
'See full, untruncated responses with Ctrl+O',
'Toggle auto-approval (YOLO mode) for all tools with Ctrl+Y',
'Cycle through approval modes (Default, Auto-Edit, Plan) with Shift+Tab',
'Toggle Markdown rendering (raw markdown mode) with Alt+M',
'Toggle shell mode by typing ! in an empty prompt',
'Insert a newline with a backslash (\\) followed by Enter',
'Navigate your prompt history with the Up and Down arrows',
'You can also use Ctrl+P (up) and Ctrl+N (down) for history',
'Search through command history with Ctrl+R',
'Accept an autocomplete suggestion with Tab or Enter',
'Move to the start of the line with Ctrl+A or Home',
'Move to the end of the line with Ctrl+E or End',
'Move one character left or right with Ctrl+B/F or the arrow keys',
'Move one word left or right with Ctrl+Left/Right Arrow',
'Delete the character to the left with Ctrl+H or Backspace',
'Delete the character to the right with Ctrl+D or Delete',
'Delete the word to the left of the cursor with Ctrl+W',
'Delete the word to the right of the cursor with Ctrl+Delete',
'Delete from the cursor to the start of the line with Ctrl+U',
'Delete from the cursor to the end of the line with Ctrl+K',
'Clear the entire input prompt with a double-press of Esc',
'Paste from your clipboard with Ctrl+V',
'Undo text edits in the input with Alt+Z or Cmd+Z',
'Redo undone text edits with Shift+Alt+Z or Shift+Cmd+Z',
'Open the current prompt in an external editor with Ctrl+X',
'In menus, move up/down with k/j or the arrow keys',
'In menus, select an item by typing its number',
"If you're using an IDE, see the context with Ctrl+G",
'Toggle background shells with Ctrl+B or /shells...',
'Toggle the background shell process list with Ctrl+L...',
'Close dialogs and suggestions with Esc',
'Cancel a request with Ctrl+C, or press twice to exit',
'Exit the app with Ctrl+D on an empty line',
'Clear your screen at any time with Ctrl+L',
'Toggle the debug console display with F12',
'Toggle the todo list display with Ctrl+T',
'See full, untruncated responses with Ctrl+O',
'Toggle auto-approval (YOLO mode) for all tools with Ctrl+Y',
'Cycle through approval modes (Default, Auto-Edit, Plan) with Shift+Tab',
'Toggle Markdown rendering (raw markdown mode) with Alt+M',
'Toggle shell mode by typing ! in an empty prompt',
'Insert a newline with a backslash (\\) followed by Enter',
'Navigate your prompt history with the Up and Down arrows',
'You can also use Ctrl+P (up) and Ctrl+N (down) for history',
'Search through command history with Ctrl+R',
'Accept an autocomplete suggestion with Tab or Enter',
'Move to the start of the line with Ctrl+A or Home',
'Move to the end of the line with Ctrl+E or End',
'Move one character left or right with Ctrl+B/F or the arrow keys',
'Move one word left or right with Ctrl+Left/Right Arrow',
'Delete the character to the left with Ctrl+H or Backspace',
'Delete the character to the right with Ctrl+D or Delete',
'Delete the word to the left of the cursor with Ctrl+W',
'Delete the word to the right of the cursor with Ctrl+Delete',
'Delete from the cursor to the start of the line with Ctrl+U',
'Delete from the cursor to the end of the line with Ctrl+K',
'Clear the entire input prompt with a double-press of Esc',
'Paste from your clipboard with Ctrl+V',
'Undo text edits in the input with Alt+Z or Cmd+Z',
'Redo undone text edits with Shift+Alt+Z or Shift+Cmd+Z',
'Open the current prompt in an external editor with Ctrl+X',
'In menus, move up/down with k/j or the arrow keys',
'In menus, select an item by typing its number',
"If you're using an IDE, see the context with Ctrl+G",
'Toggle background shells with Ctrl+B or /shells',
'Toggle the background shell process list with Ctrl+L',
// Keyboard shortcut tips end here
// Command tips start here
'Show version info with /about',
'Change your authentication method with /auth',
'File a bug report directly with /bug',
'List your saved chat checkpoints with /resume list',
'Save your current conversation with /resume save <tag>',
'Resume a saved conversation with /resume resume <tag>',
'Delete a conversation checkpoint with /resume delete <tag>',
'Share your conversation to a file with /resume share <file>',
'Clear the screen and history with /clear',
'Save tokens by summarizing the context with /compress',
'Copy the last response to your clipboard with /copy',
'Open the full documentation in your browser with /docs',
'Add directories to your workspace with /directory add <path>',
'Show all directories in your workspace with /directory show',
'Use /dir as a shortcut for /directory',
'Set your preferred external editor with /editor',
'List all active extensions with /extensions list',
'Update all or specific extensions with /extensions update',
'Get help on commands with /help',
'Manage IDE integration with /ide',
'Create a project-specific GEMINI.md file with /init',
'List configured MCP servers and tools with /mcp list',
'Authenticate with an OAuth-enabled MCP server with /mcp auth',
'Reload MCP servers with /mcp reload',
'See the current instructional context with /memory show',
'Add content to the instructional memory with /memory add',
'Reload instructional context from GEMINI.md files with /memory reload',
'List the paths of the GEMINI.md files in use with /memory list',
'Choose your Gemini model with /model',
'Display the privacy notice with /privacy',
'Restore project files to a previous state with /restore',
'Exit the CLI with /quit or /exit',
'Check model-specific usage stats with /stats model',
'Check tool-specific usage stats with /stats tools',
"Change the CLI's color theme with /theme",
'List all available tools with /tools',
'View and edit settings with the /settings editor',
'Toggle Vim keybindings on and off with /vim',
'Set up GitHub Actions with /setup-github',
'Configure terminal keybindings for multiline input with /terminal-setup',
'Find relevant documentation with /find-docs',
'Execute any shell command with !<command>',
'Show version info with /about',
'Change your authentication method with /auth',
'File a bug report directly with /bug',
'List your saved chat checkpoints with /resume list',
'Save your current conversation with /resume save <tag>',
'Resume a saved conversation with /resume resume <tag>',
'Delete a conversation checkpoint with /resume delete <tag>',
'Share your conversation to a file with /resume share <file>',
'Clear the screen and history with /clear',
'Save tokens by summarizing the context with /compress',
'Copy the last response to your clipboard with /copy',
'Open the full documentation in your browser with /docs',
'Add directories to your workspace with /directory add <path>',
'Show all directories in your workspace with /directory show',
'Use /dir as a shortcut for /directory',
'Set your preferred external editor with /editor',
'List all active extensions with /extensions list',
'Update all or specific extensions with /extensions update',
'Get help on commands with /help',
'Manage IDE integration with /ide',
'Create a project-specific GEMINI.md file with /init',
'List configured MCP servers and tools with /mcp list',
'Authenticate with an OAuth-enabled MCP server with /mcp auth',
'Reload MCP servers with /mcp reload',
'See the current instructional context with /memory show',
'Add content to the instructional memory with /memory add',
'Reload instructional context from GEMINI.md files with /memory reload',
'List the paths of the GEMINI.md files in use with /memory list',
'Choose your Gemini model with /model',
'Display the privacy notice with /privacy',
'Restore project files to a previous state with /restore',
'Exit the CLI with /quit or /exit',
'Check model-specific usage stats with /stats model',
'Check tool-specific usage stats with /stats tools',
"Change the CLI's color theme with /theme",
'List all available tools with /tools',
'View and edit settings with the /settings editor',
'Toggle Vim keybindings on and off with /vim',
'Set up GitHub Actions with /setup-github',
'Configure terminal keybindings for multiline input with /terminal-setup',
'Find relevant documentation with /find-docs',
'Execute any shell command with !<command>',
// Command tips end here
];

View File

@@ -6,113 +6,113 @@
export const WITTY_LOADING_PHRASES = [
"I'm Feeling Lucky",
'Shipping awesomeness',
'Painting the serifs back on',
'Navigating the slime mold',
'Consulting the digital spirits',
'Reticulating splines',
'Warming up the AI hamsters',
'Asking the magic conch shell',
'Generating witty retort',
'Polishing the algorithms',
"Don't rush perfection (or my code)",
'Brewing fresh bytes',
'Counting electrons',
'Engaging cognitive processors',
'Checking for syntax errors in the universe',
'One moment, optimizing humor',
'Shuffling punchlines',
'Untangling neural nets',
'Compiling brilliance',
'Loading wit.exe',
'Summoning the cloud of wisdom',
'Preparing a witty response',
"Just a sec, I'm debugging reality",
'Confuzzling the options',
'Tuning the cosmic frequencies',
'Crafting a response worthy of your patience',
'Compiling the 1s and 0s',
'Resolving dependencies… and existential crises',
'Defragmenting memories… both RAM and personal',
'Rebooting the humor module',
'Caching the essentials (mostly cat memes)',
'Shipping awesomeness',
'Painting the serifs back on',
'Navigating the slime mold',
'Consulting the digital spirits',
'Reticulating splines',
'Warming up the AI hamsters',
'Asking the magic conch shell',
'Generating witty retort',
'Polishing the algorithms',
"Don't rush perfection (or my code)",
'Brewing fresh bytes',
'Counting electrons',
'Engaging cognitive processors',
'Checking for syntax errors in the universe',
'One moment, optimizing humor',
'Shuffling punchlines',
'Untangling neural nets',
'Compiling brilliance',
'Loading wit.exe',
'Summoning the cloud of wisdom',
'Preparing a witty response',
"Just a sec, I'm debugging reality",
'Confuzzling the options',
'Tuning the cosmic frequencies',
'Crafting a response worthy of your patience',
'Compiling the 1s and 0s',
'Resolving dependencies… and existential crises',
'Defragmenting memories… both RAM and personal',
'Rebooting the humor module',
'Caching the essentials (mostly cat memes)',
'Optimizing for ludicrous speed',
"Swapping bits… don't tell the bytes",
'Garbage collecting… be right back',
'Assembling the interwebs',
'Converting coffee into code',
'Updating the syntax for reality',
'Rewiring the synapses',
'Looking for a misplaced semicolon',
"Greasin' the cogs of the machine",
'Pre-heating the servers',
'Calibrating the flux capacitor',
'Engaging the improbability drive',
'Channeling the Force',
'Aligning the stars for optimal response',
'So say we all',
'Loading the next great idea',
"Just a moment, I'm in the zone",
'Preparing to dazzle you with brilliance',
"Just a tick, I'm polishing my wit",
"Hold tight, I'm crafting a masterpiece",
"Just a jiffy, I'm debugging the universe",
"Just a moment, I'm aligning the pixels",
"Just a sec, I'm optimizing the humor",
"Just a moment, I'm tuning the algorithms",
'Warp speed engaged',
'Mining for more Dilithium crystals',
"Don't panic",
'Following the white rabbit',
'The truth is in here… somewhere',
'Blowing on the cartridge',
"Swapping bits… don't tell the bytes",
'Garbage collecting… be right back',
'Assembling the interwebs',
'Converting coffee into code',
'Updating the syntax for reality',
'Rewiring the synapses',
'Looking for a misplaced semicolon',
"Greasin' the cogs of the machine",
'Pre-heating the servers',
'Calibrating the flux capacitor',
'Engaging the improbability drive',
'Channeling the Force',
'Aligning the stars for optimal response',
'So say we all',
'Loading the next great idea',
"Just a moment, I'm in the zone",
'Preparing to dazzle you with brilliance',
"Just a tick, I'm polishing my wit",
"Hold tight, I'm crafting a masterpiece",
"Just a jiffy, I'm debugging the universe",
"Just a moment, I'm aligning the pixels",
"Just a sec, I'm optimizing the humor",
"Just a moment, I'm tuning the algorithms",
'Warp speed engaged',
'Mining for more Dilithium crystals',
"Don't panic",
'Following the white rabbit',
'The truth is in here… somewhere',
'Blowing on the cartridge',
'Loading… Do a barrel roll!',
'Waiting for the respawn',
'Finishing the Kessel Run in less than 12 parsecs',
"The cake is not a lie, it's just still loading",
'Fiddling with the character creation screen',
"Just a moment, I'm finding the right meme",
"Pressing 'A' to continue",
'Herding digital cats',
'Polishing the pixels',
'Finding a suitable loading screen pun',
'Distracting you with this witty phrase',
'Almost there… probably',
'Our hamsters are working as fast as they can',
'Giving Cloudy a pat on the head',
'Petting the cat',
'Rickrolling my boss',
'Slapping the bass',
'Tasting the snozberries',
"I'm going the distance, I'm going for speed",
'Is this the real life? Is this just fantasy?',
"I've got a good feeling about this",
'Poking the bear',
'Doing research on the latest memes',
'Figuring out how to make this more witty',
'Hmmm… let me think',
'What do you call a fish with no eyes? A fsh',
'Why did the computer go to therapy? It had too many bytes',
"Why don't programmers like nature? It has too many bugs",
'Why do programmers prefer dark mode? Because light attracts bugs',
'Why did the developer go broke? Because they used up all their cache',
"What can you do with a broken pencil? Nothing, it's pointless",
'Applying percussive maintenance',
'Searching for the correct USB orientation',
'Ensuring the magic smoke stays inside the wires',
'Rewriting in Rust for no particular reason',
'Trying to exit Vim',
'Spinning up the hamster wheel',
"That's not a bug, it's an undocumented feature",
'Waiting for the respawn',
'Finishing the Kessel Run in less than 12 parsecs',
"The cake is not a lie, it's just still loading",
'Fiddling with the character creation screen',
"Just a moment, I'm finding the right meme",
"Pressing 'A' to continue",
'Herding digital cats',
'Polishing the pixels',
'Finding a suitable loading screen pun',
'Distracting you with this witty phrase',
'Almost there… probably',
'Our hamsters are working as fast as they can',
'Giving Cloudy a pat on the head',
'Petting the cat',
'Rickrolling my boss',
'Slapping the bass',
'Tasting the snozberries',
"I'm going the distance, I'm going for speed",
'Is this the real life? Is this just fantasy?',
"I've got a good feeling about this",
'Poking the bear',
'Doing research on the latest memes',
'Figuring out how to make this more witty',
'Hmmm… let me think',
'What do you call a fish with no eyes? A fsh',
'Why did the computer go to therapy? It had too many bytes',
"Why don't programmers like nature? It has too many bugs",
'Why do programmers prefer dark mode? Because light attracts bugs',
'Why did the developer go broke? Because they used up all their cache',
"What can you do with a broken pencil? Nothing, it's pointless",
'Applying percussive maintenance',
'Searching for the correct USB orientation',
'Ensuring the magic smoke stays inside the wires',
'Rewriting in Rust for no particular reason',
'Trying to exit Vim',
'Spinning up the hamster wheel',
"That's not a bug, it's an undocumented feature",
'Engage.',
"I'll be back… with an answer.",
'My other process is a TARDIS',
'Communing with the machine spirit',
'Letting the thoughts marinate',
'Just remembered where I put my keys',
'Pondering the orb',
'My other process is a TARDIS',
'Communing with the machine spirit',
'Letting the thoughts marinate',
'Just remembered where I put my keys',
'Pondering the orb',
"I've seen things you people wouldn't believe… like a user who reads loading messages.",
'Initiating thoughtful gaze',
'Initiating thoughtful gaze',
"What's a computer's favorite snack? Microchips.",
"Why do Java developers wear glasses? Because they don't C#.",
'Charging the laser… pew pew!',
@@ -120,18 +120,18 @@ export const WITTY_LOADING_PHRASES = [
'Looking for an adult superviso… I mean, processing.',
'Making it go beep boop.',
'Buffering… because even AIs need a moment.',
'Entangling quantum particles for a faster response',
'Entangling quantum particles for a faster response',
'Polishing the chrome… on the algorithms.',
'Are you not entertained? (Working on it!)',
'Summoning the code gremlins… to help, of course.',
'Just waiting for the dial-up tone to finish',
'Just waiting for the dial-up tone to finish',
'Recalibrating the humor-o-meter.',
'My other loading screen is even funnier.',
"Pretty sure there's a cat walking on the keyboard somewhere",
"Pretty sure there's a cat walking on the keyboard somewhere",
'Enhancing… Enhancing… Still loading.',
"It's not a bug, it's a feature… of this loading screen.",
'Have you tried turning it off and on again? (The loading screen, not me.)',
'Constructing additional pylons',
'Constructing additional pylons',
'New line? Thats Ctrl+J.',
'Releasing the HypnoDrones',
'Releasing the HypnoDrones',
];

View File

@@ -168,6 +168,8 @@ export interface UIState {
cleanUiDetailsVisible: boolean;
elapsedTime: number;
currentLoadingPhrase: string | undefined;
currentTip: string | undefined;
currentWittyPhrase: string | undefined;
historyRemountKey: number;
activeHooks: ActiveHook[];
messageQueue: string[];

View File

@@ -2,10 +2,8 @@
exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 1`] = `"Waiting for user confirmation..."`;
exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 2`] = `"Interactive shell awaiting input... press tab to focus shell"`;
exports[`usePhraseCycler > should reset phrase when transitioning from waiting to active 1`] = `"Waiting for user confirmation..."`;
exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 2`] = `"! Shell awaiting input (Tab to focus)"`;
exports[`usePhraseCycler > should show "Waiting for user confirmation..." when isWaiting is true 1`] = `"Waiting for user confirmation..."`;
exports[`usePhraseCycler > should show interactive shell waiting message immediately when isInteractiveShellWaiting is true 1`] = `"Interactive shell awaiting input... press tab to focus shell"`;
exports[`usePhraseCycler > should show interactive shell waiting message immediately when shouldShowFocusHint is true 1`] = `"! Shell awaiting input (Tab to focus)"`;

View File

@@ -43,6 +43,7 @@ export const useHookDisplayState = () => {
{
name: payload.hookName,
eventName: payload.eventName,
source: payload.source,
index: payload.hookIndex,
total: payload.totalHooks,
},

View File

@@ -34,7 +34,7 @@ describe('useLoadingIndicator', () => {
initialStreamingState: StreamingState,
initialShouldShowFocusHint: boolean = false,
initialRetryStatus: RetryAttemptPayload | null = null,
loadingPhrasesMode: LoadingPhrasesMode = 'all',
loadingPhrases: LoadingPhrasesMode = 'all',
initialErrorVerbosity: 'low' | 'full' = 'full',
) => {
let hookResult: ReturnType<typeof useLoadingIndicator>;
@@ -42,20 +42,20 @@ describe('useLoadingIndicator', () => {
streamingState,
shouldShowFocusHint,
retryStatus,
mode,
loadingPhrases,
errorVerbosity,
}: {
streamingState: StreamingState;
shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null;
mode?: LoadingPhrasesMode;
errorVerbosity: 'low' | 'full';
loadingPhrases?: LoadingPhrasesMode;
errorVerbosity?: 'low' | 'full';
}) {
hookResult = useLoadingIndicator({
streamingState,
shouldShowFocusHint: !!shouldShowFocusHint,
retryStatus: retryStatus || null,
loadingPhrasesMode: mode,
loadingPhrasesMode: loadingPhrases,
errorVerbosity,
});
return null;
@@ -65,7 +65,7 @@ describe('useLoadingIndicator', () => {
streamingState={initialStreamingState}
shouldShowFocusHint={initialShouldShowFocusHint}
retryStatus={initialRetryStatus}
mode={loadingPhrasesMode}
loadingPhrases={loadingPhrases}
errorVerbosity={initialErrorVerbosity}
/>,
);
@@ -79,12 +79,12 @@ describe('useLoadingIndicator', () => {
streamingState: StreamingState;
shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null;
mode?: LoadingPhrasesMode;
loadingPhrases?: LoadingPhrasesMode;
errorVerbosity?: 'low' | 'full';
}) =>
rerender(
<TestComponent
mode={loadingPhrasesMode}
loadingPhrases={loadingPhrases}
errorVerbosity={initialErrorVerbosity}
{...newProps}
/>,
@@ -93,24 +93,19 @@ describe('useLoadingIndicator', () => {
};
it('should initialize with default values when Idle', () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { result } = renderLoadingIndicatorHook(StreamingState.Idle);
expect(result.current.elapsedTime).toBe(0);
expect(result.current.currentLoadingPhrase).toBeUndefined();
});
it('should show interactive shell waiting phrase when shouldShowFocusHint is true', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { result, rerender } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
);
// Initially should be witty phrase or tip
expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain(
result.current.currentLoadingPhrase,
);
await act(async () => {
rerender({
streamingState: StreamingState.Responding,
@@ -124,19 +119,17 @@ describe('useLoadingIndicator', () => {
});
it('should reflect values when Responding', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { result } = renderLoadingIndicatorHook(StreamingState.Responding);
// Initial phrase on first activation will be a tip, not necessarily from witty phrases
expect(result.current.elapsedTime).toBe(0);
// On first activation, it may show a tip, so we can't guarantee it's in WITTY_LOADING_PHRASES
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 1);
});
// Phrase should cycle if PHRASE_CHANGE_INTERVAL_MS has passed, now it should be witty since first activation already happened
expect(WITTY_LOADING_PHRASES).toContain(
// Both tip and witty phrase are available in the currentLoadingPhrase because it defaults to tip if present
expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain(
result.current.currentLoadingPhrase,
);
});
@@ -167,8 +160,8 @@ describe('useLoadingIndicator', () => {
expect(result.current.elapsedTime).toBe(60);
});
it('should reset elapsedTime and use a witty phrase when transitioning from WaitingForConfirmation to Responding', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
it('should reset elapsedTime and cycle phrases when transitioning from WaitingForConfirmation to Responding', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { result, rerender } = renderLoadingIndicatorHook(
StreamingState.Responding,
);
@@ -190,7 +183,7 @@ describe('useLoadingIndicator', () => {
rerender({ streamingState: StreamingState.Responding });
});
expect(result.current.elapsedTime).toBe(0); // Should reset
expect(WITTY_LOADING_PHRASES).toContain(
expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain(
result.current.currentLoadingPhrase,
);
@@ -201,7 +194,7 @@ describe('useLoadingIndicator', () => {
});
it('should reset timer and phrase when streamingState changes from Responding to Idle', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { result, rerender } = renderLoadingIndicatorHook(
StreamingState.Responding,
);
@@ -217,79 +210,5 @@ describe('useLoadingIndicator', () => {
expect(result.current.elapsedTime).toBe(0);
expect(result.current.currentLoadingPhrase).toBeUndefined();
// Timer should not advance
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(result.current.elapsedTime).toBe(0);
});
it('should reflect retry status in currentLoadingPhrase when provided', () => {
const retryStatus = {
model: 'gemini-pro',
attempt: 2,
maxAttempts: 3,
delayMs: 1000,
};
const { result } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
retryStatus,
);
expect(result.current.currentLoadingPhrase).toContain('Trying to reach');
expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3');
});
it('should hide low-verbosity retry status for early retry attempts', () => {
const retryStatus = {
model: 'gemini-pro',
attempt: 1,
maxAttempts: 5,
delayMs: 1000,
};
const { result } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
retryStatus,
'all',
'low',
);
expect(result.current.currentLoadingPhrase).not.toBe(
"This is taking a bit longer, we're still on it.",
);
});
it('should show a generic retry phrase in low error verbosity mode for later retries', () => {
const retryStatus = {
model: 'gemini-pro',
attempt: 2,
maxAttempts: 5,
delayMs: 1000,
};
const { result } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
retryStatus,
'all',
'low',
);
expect(result.current.currentLoadingPhrase).toBe(
"This is taking a bit longer, we're still on it.",
);
});
it('should show no phrases when loadingPhrasesMode is "off"', () => {
const { result } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
null,
'off',
);
expect(result.current.currentLoadingPhrase).toBeUndefined();
});
});

View File

@@ -22,16 +22,18 @@ export interface UseLoadingIndicatorProps {
retryStatus: RetryAttemptPayload | null;
loadingPhrasesMode?: LoadingPhrasesMode;
customWittyPhrases?: string[];
errorVerbosity: 'low' | 'full';
errorVerbosity?: 'low' | 'full';
maxLength?: number;
}
export const useLoadingIndicator = ({
streamingState,
shouldShowFocusHint,
retryStatus,
loadingPhrasesMode,
loadingPhrasesMode = 'tips',
customWittyPhrases,
errorVerbosity,
errorVerbosity = 'full',
maxLength,
}: UseLoadingIndicatorProps) => {
const [timerResetKey, setTimerResetKey] = useState(0);
const isTimerActive = streamingState === StreamingState.Responding;
@@ -40,12 +42,20 @@ export const useLoadingIndicator = ({
const isPhraseCyclingActive = streamingState === StreamingState.Responding;
const isWaiting = streamingState === StreamingState.WaitingForConfirmation;
const currentLoadingPhrase = usePhraseCycler(
const showTips =
loadingPhrasesMode === 'tips' || loadingPhrasesMode === 'all';
const showWit =
loadingPhrasesMode === 'witty' || loadingPhrasesMode === 'all';
const { currentTip, currentWittyPhrase } = usePhraseCycler(
isPhraseCyclingActive,
isWaiting,
shouldShowFocusHint,
loadingPhrasesMode,
showTips,
showWit,
customWittyPhrases,
maxLength,
);
const [retainedElapsedTime, setRetainedElapsedTime] = useState(0);
@@ -86,6 +96,8 @@ export const useLoadingIndicator = ({
streamingState === StreamingState.WaitingForConfirmation
? retainedElapsedTime
: elapsedTimeFromTimer,
currentLoadingPhrase: retryPhrase || currentLoadingPhrase,
currentLoadingPhrase: retryPhrase || currentTip || currentWittyPhrase,
currentTip,
currentWittyPhrase,
};
};

View File

@@ -14,30 +14,35 @@ import {
} from './usePhraseCycler.js';
import { INFORMATIVE_TIPS } from '../constants/tips.js';
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
import type { LoadingPhrasesMode } from '../../config/settings.js';
// Test component to consume the hook
const TestComponent = ({
isActive,
isWaiting,
isInteractiveShellWaiting = false,
loadingPhrasesMode = 'all',
shouldShowFocusHint = false,
showTips = true,
showWit = true,
customPhrases,
}: {
isActive: boolean;
isWaiting: boolean;
isInteractiveShellWaiting?: boolean;
loadingPhrasesMode?: LoadingPhrasesMode;
shouldShowFocusHint?: boolean;
showTips?: boolean;
showWit?: boolean;
customPhrases?: string[];
}) => {
const phrase = usePhraseCycler(
const { currentTip, currentWittyPhrase } = usePhraseCycler(
isActive,
isWaiting,
isInteractiveShellWaiting,
loadingPhrasesMode,
shouldShowFocusHint,
showTips,
showWit,
customPhrases,
);
return <Text>{phrase}</Text>;
// For tests, we'll combine them to verify existence
return (
<Text>{[currentTip, currentWittyPhrase].filter(Boolean).join(' | ')}</Text>
);
};
describe('usePhraseCycler', () => {
@@ -75,7 +80,7 @@ describe('usePhraseCycler', () => {
unmount();
});
it('should show interactive shell waiting message immediately when isInteractiveShellWaiting is true', async () => {
it('should show interactive shell waiting message immediately when shouldShowFocusHint is true', async () => {
const { lastFrame, rerender, waitUntilReady, unmount } = render(
<TestComponent isActive={true} isWaiting={false} />,
);
@@ -86,7 +91,7 @@ describe('usePhraseCycler', () => {
<TestComponent
isActive={true}
isWaiting={false}
isInteractiveShellWaiting={true}
shouldShowFocusHint={true}
/>,
);
});
@@ -108,7 +113,7 @@ describe('usePhraseCycler', () => {
<TestComponent
isActive={true}
isWaiting={true}
isInteractiveShellWaiting={true}
shouldShowFocusHint={true}
/>,
);
});
@@ -133,55 +138,56 @@ describe('usePhraseCycler', () => {
unmount();
});
it('should show a tip on first activation, then a witty phrase', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.99); // Subsequent phrases are witty
it('should show both a tip and a witty phrase when both are enabled', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { lastFrame, waitUntilReady, unmount } = render(
<TestComponent isActive={true} isWaiting={false} />,
<TestComponent
isActive={true}
isWaiting={false}
showTips={true}
showWit={true}
/>,
);
await waitUntilReady();
// Initial phrase on first activation should be a tip
expect(INFORMATIVE_TIPS).toContain(lastFrame().trim());
// After the first interval, it should be a witty phrase
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
// In the new logic, both are selected independently if enabled.
const frame = lastFrame().trim();
const parts = frame.split(' | ');
expect(parts).toHaveLength(2);
expect(INFORMATIVE_TIPS).toContain(parts[0]);
expect(WITTY_LOADING_PHRASES).toContain(parts[1]);
unmount();
});
it('should cycle through phrases when isActive is true and not waiting', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { lastFrame, waitUntilReady, unmount } = render(
<TestComponent isActive={true} isWaiting={false} />,
<TestComponent
isActive={true}
isWaiting={false}
showTips={true}
showWit={true}
/>,
);
await waitUntilReady();
// Initial phrase on first activation will be a tip
// After the first interval, it should follow the random pattern (witty phrases due to mock)
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
const frame = lastFrame().trim();
const parts = frame.split(' | ');
expect(parts).toHaveLength(2);
expect(INFORMATIVE_TIPS).toContain(parts[0]);
expect(WITTY_LOADING_PHRASES).toContain(parts[1]);
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
unmount();
});
it('should reset to a phrase when isActive becomes true after being false', async () => {
it('should reset to phrases when isActive becomes true after being false', async () => {
const customPhrases = ['Phrase A', 'Phrase B'];
let callCount = 0;
vi.spyOn(Math, 'random').mockImplementation(() => {
// For custom phrases, only 1 Math.random call is made per update.
// 0 -> index 0 ('Phrase A')
// 0.99 -> index 1 ('Phrase B')
const val = callCount % 2 === 0 ? 0 : 0.99;
callCount++;
return val;
@@ -192,34 +198,31 @@ describe('usePhraseCycler', () => {
isActive={false}
isWaiting={false}
customPhrases={customPhrases}
showWit={true}
showTips={false}
/>,
);
await waitUntilReady();
// Activate -> On first activation will show tip on initial call, then first interval will use first mock value for 'Phrase A'
// Activate
await act(async () => {
rerender(
<TestComponent
isActive={true}
isWaiting={false}
customPhrases={customPhrases}
showWit={true}
showTips={false}
/>,
);
});
await waitUntilReady();
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // First interval after initial state -> callCount 0 -> 'Phrase A'
await vi.advanceTimersByTimeAsync(0);
});
await waitUntilReady();
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
// Second interval -> callCount 1 -> returns 0.99 -> 'Phrase B'
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
});
await waitUntilReady();
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
expect(customPhrases).toContain(lastFrame().trim());
// Deactivate -> resets to undefined (empty string in output)
await act(async () => {
@@ -228,6 +231,8 @@ describe('usePhraseCycler', () => {
isActive={false}
isWaiting={false}
customPhrases={customPhrases}
showWit={true}
showTips={false}
/>,
);
});
@@ -235,24 +240,6 @@ describe('usePhraseCycler', () => {
// The phrase should be empty after reset
expect(lastFrame({ allowEmpty: true }).trim()).toBe('');
// Activate again -> this will show a tip on first activation, then cycle from where mock is
await act(async () => {
rerender(
<TestComponent
isActive={true}
isWaiting={false}
customPhrases={customPhrases}
/>,
);
});
await waitUntilReady();
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // First interval after re-activation -> should contain phrase
});
await waitUntilReady();
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
unmount();
});
@@ -264,7 +251,7 @@ describe('usePhraseCycler', () => {
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
unmount();
expect(clearIntervalSpy).toHaveBeenCalledOnce();
expect(clearIntervalSpy).toHaveBeenCalled();
});
it('should use custom phrases when provided', async () => {
@@ -293,7 +280,8 @@ describe('usePhraseCycler', () => {
<TestComponent
isActive={config.isActive}
isWaiting={false}
loadingPhrasesMode="witty"
showTips={false}
showWit={true}
customPhrases={config.customPhrases}
/>
);
@@ -304,7 +292,7 @@ describe('usePhraseCycler', () => {
// After first interval, it should use custom phrases
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
await vi.advanceTimersByTimeAsync(0);
});
await waitUntilReady();
@@ -323,78 +311,24 @@ describe('usePhraseCycler', () => {
await waitUntilReady();
expect(customPhrases).toContain(lastFrame({ allowEmpty: true }).trim());
randomMock.mockReturnValue(0.99);
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
});
await waitUntilReady();
expect(customPhrases).toContain(lastFrame({ allowEmpty: true }).trim());
// Test fallback to default phrases.
randomMock.mockRestore();
vi.spyOn(Math, 'random').mockReturnValue(0.5); // Always witty
await act(async () => {
setStateExternally?.({
isActive: true,
customPhrases: [] as string[],
});
});
await waitUntilReady();
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Wait for first cycle
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
unmount();
});
it('should fall back to witty phrases if custom phrases are an empty array', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
const { lastFrame, waitUntilReady, unmount } = render(
<TestComponent isActive={true} isWaiting={false} customPhrases={[]} />,
<TestComponent
isActive={true}
isWaiting={false}
showTips={false}
showWit={true}
customPhrases={[]}
/>,
);
await waitUntilReady();
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Next phrase after tip
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
unmount();
});
it('should reset phrase when transitioning from waiting to active', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
const { lastFrame, rerender, waitUntilReady, unmount } = render(
<TestComponent isActive={true} isWaiting={false} />,
);
await waitUntilReady();
// Cycle to a different phrase (should be witty due to mock)
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
// Go to waiting state
await act(async () => {
rerender(<TestComponent isActive={false} isWaiting={true} />);
});
await waitUntilReady();
expect(lastFrame().trim()).toMatchSnapshot();
// Go back to active cycling - should pick a phrase based on the logic (witty due to mock)
await act(async () => {
rerender(<TestComponent isActive={true} isWaiting={false} />);
});
await waitUntilReady();
await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Skip the tip and get next phrase
await vi.advanceTimersByTimeAsync(0);
});
await waitUntilReady();
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());

View File

@@ -7,112 +7,177 @@
import { useState, useEffect, useRef } from 'react';
import { INFORMATIVE_TIPS } from '../constants/tips.js';
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
import type { LoadingPhrasesMode } from '../../config/settings.js';
export const PHRASE_CHANGE_INTERVAL_MS = 15000;
export const PHRASE_CHANGE_INTERVAL_MS = 10000;
export const WITTY_PHRASE_CHANGE_INTERVAL_MS = 5000;
export const INTERACTIVE_SHELL_WAITING_PHRASE =
'Interactive shell awaiting input... press tab to focus shell';
'! Shell awaiting input (Tab to focus)';
/**
* Custom hook to manage cycling through loading phrases.
* @param isActive Whether the phrase cycling should be active.
* @param isWaiting Whether to show a specific waiting phrase.
* @param shouldShowFocusHint Whether to show the shell focus hint.
* @param loadingPhrasesMode Which phrases to show: tips, witty, all, or off.
* @param showTips Whether to show informative tips.
* @param showWit Whether to show witty phrases.
* @param customPhrases Optional list of custom phrases to use instead of built-in witty phrases.
* @param maxLength Optional maximum length for the selected phrase.
* @returns The current loading phrase.
*/
export const usePhraseCycler = (
isActive: boolean,
isWaiting: boolean,
shouldShowFocusHint: boolean,
loadingPhrasesMode: LoadingPhrasesMode = 'tips',
showTips: boolean = true,
showWit: boolean = true,
customPhrases?: string[],
maxLength?: number,
) => {
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState<
const [currentTipState, setCurrentTipState] = useState<string | undefined>(
undefined,
);
const [currentWittyPhraseState, setCurrentWittyPhraseState] = useState<
string | undefined
>(undefined);
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
const hasShownFirstRequestTipRef = useRef(false);
const tipIntervalRef = useRef<NodeJS.Timeout | null>(null);
const wittyIntervalRef = useRef<NodeJS.Timeout | null>(null);
const lastTipChangeTimeRef = useRef<number>(0);
const lastWittyChangeTimeRef = useRef<number>(0);
const lastSelectedTipRef = useRef<string | undefined>(undefined);
const lastSelectedWittyPhraseRef = useRef<string | undefined>(undefined);
const MIN_TIP_DISPLAY_TIME_MS = 10000;
const MIN_WIT_DISPLAY_TIME_MS = 5000;
useEffect(() => {
// Always clear on re-run
if (phraseIntervalRef.current) {
clearInterval(phraseIntervalRef.current);
phraseIntervalRef.current = null;
}
const clearTimers = () => {
if (tipIntervalRef.current) {
clearInterval(tipIntervalRef.current);
tipIntervalRef.current = null;
}
if (wittyIntervalRef.current) {
clearInterval(wittyIntervalRef.current);
wittyIntervalRef.current = null;
}
};
if (shouldShowFocusHint) {
setCurrentLoadingPhrase(INTERACTIVE_SHELL_WAITING_PHRASE);
clearTimers();
if (shouldShowFocusHint || isWaiting) {
// These are handled by the return value directly for immediate feedback
return;
}
if (isWaiting) {
setCurrentLoadingPhrase('Waiting for user confirmation...');
if (!isActive || (!showTips && !showWit)) {
return;
}
if (!isActive || loadingPhrasesMode === 'off') {
setCurrentLoadingPhrase(undefined);
return;
}
const wittyPhrases =
const wittyPhrasesList =
customPhrases && customPhrases.length > 0
? customPhrases
: WITTY_LOADING_PHRASES;
const setRandomPhrase = () => {
let phraseList: readonly string[];
switch (loadingPhrasesMode) {
case 'tips':
phraseList = INFORMATIVE_TIPS;
break;
case 'witty':
phraseList = wittyPhrases;
break;
case 'all':
// Show a tip on the first request after startup, then continue with 1/6 chance
if (!hasShownFirstRequestTipRef.current) {
phraseList = INFORMATIVE_TIPS;
hasShownFirstRequestTipRef.current = true;
} else {
const showTip = Math.random() < 1 / 6;
phraseList = showTip ? INFORMATIVE_TIPS : wittyPhrases;
}
break;
default:
phraseList = INFORMATIVE_TIPS;
break;
const setRandomTip = (force: boolean = false) => {
if (!showTips) {
setCurrentTipState(undefined);
lastSelectedTipRef.current = undefined;
return;
}
const randomIndex = Math.floor(Math.random() * phraseList.length);
setCurrentLoadingPhrase(phraseList[randomIndex]);
};
const now = Date.now();
if (
!force &&
now - lastTipChangeTimeRef.current < MIN_TIP_DISPLAY_TIME_MS &&
lastSelectedTipRef.current
) {
setCurrentTipState(lastSelectedTipRef.current);
return;
}
// Select an initial random phrase
setRandomPhrase();
const filteredTips =
maxLength !== undefined
? INFORMATIVE_TIPS.filter((p) => p.length <= maxLength)
: INFORMATIVE_TIPS;
phraseIntervalRef.current = setInterval(() => {
// Select a new random phrase
setRandomPhrase();
}, PHRASE_CHANGE_INTERVAL_MS);
return () => {
if (phraseIntervalRef.current) {
clearInterval(phraseIntervalRef.current);
phraseIntervalRef.current = null;
if (filteredTips.length > 0) {
const selected =
filteredTips[Math.floor(Math.random() * filteredTips.length)];
setCurrentTipState(selected);
lastSelectedTipRef.current = selected;
lastTipChangeTimeRef.current = now;
}
};
const setRandomWitty = (force: boolean = false) => {
if (!showWit) {
setCurrentWittyPhraseState(undefined);
lastSelectedWittyPhraseRef.current = undefined;
return;
}
const now = Date.now();
if (
!force &&
now - lastWittyChangeTimeRef.current < MIN_WIT_DISPLAY_TIME_MS &&
lastSelectedWittyPhraseRef.current
) {
setCurrentWittyPhraseState(lastSelectedWittyPhraseRef.current);
return;
}
const filteredWitty =
maxLength !== undefined
? wittyPhrasesList.filter((p) => p.length <= maxLength)
: wittyPhrasesList;
if (filteredWitty.length > 0) {
const selected =
filteredWitty[Math.floor(Math.random() * filteredWitty.length)];
setCurrentWittyPhraseState(selected);
lastSelectedWittyPhraseRef.current = selected;
lastWittyChangeTimeRef.current = now;
}
};
// Select initial random phrases or resume previous ones
setRandomTip(false);
setRandomWitty(false);
if (showTips) {
tipIntervalRef.current = setInterval(() => {
setRandomTip(true);
}, PHRASE_CHANGE_INTERVAL_MS);
}
if (showWit) {
wittyIntervalRef.current = setInterval(() => {
setRandomWitty(true);
}, WITTY_PHRASE_CHANGE_INTERVAL_MS);
}
return clearTimers;
}, [
isActive,
isWaiting,
shouldShowFocusHint,
loadingPhrasesMode,
showTips,
showWit,
customPhrases,
maxLength,
]);
return currentLoadingPhrase;
let currentTip = undefined;
let currentWittyPhrase = undefined;
if (shouldShowFocusHint) {
currentTip = INTERACTIVE_SHELL_WAITING_PHRASE;
} else if (isWaiting) {
currentTip = 'Waiting for user confirmation...';
} else if (isActive) {
currentTip = currentTipState;
currentWittyPhrase = currentWittyPhraseState;
}
return { currentTip, currentWittyPhrase };
};

View File

@@ -31,9 +31,6 @@ export const DefaultAppLayout: React.FC = () => {
flexDirection="column"
width={uiState.terminalWidth}
height={isAlternateBuffer ? terminalHeight : undefined}
paddingBottom={
isAlternateBuffer && !uiState.copyModeEnabled ? 1 : undefined
}
flexShrink={0}
flexGrow={0}
overflow="hidden"

View File

@@ -18,3 +18,5 @@ export const REDIRECTION_WARNING_NOTE_TEXT =
export const REDIRECTION_WARNING_TIP_LABEL = 'Tip: '; // Padded to align with "Note: "
export const getRedirectionWarningTipText = (shiftTabHint: string) =>
`Toggle auto-edit (${shiftTabHint}) to allow redirection in the future.`;
export const GENERIC_WORKING_LABEL = 'Working...';

View File

@@ -507,6 +507,7 @@ export interface PermissionConfirmationRequest {
export interface ActiveHook {
name: string;
eventName: string;
source?: string;
index?: number;
total?: number;
}

View File

@@ -19,7 +19,7 @@
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="121" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
<text x="855" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -19,7 +19,7 @@
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="121" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
<text x="855" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -19,7 +19,7 @@
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="121" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
<text x="855" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -8,7 +8,7 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
google_web_search │
google_web_search │
│ │
│ Searching... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -22,7 +22,7 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
run_shell_command │
run_shell_command │
│ │
│ Running command... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -36,7 +36,7 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
google_web_search │
google_web_search │
│ │
│ Searching... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"