Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2260,17 +2260,22 @@ Logging in with Google... Restarting Gemini CLI to continue.

const maxLength = terminalWidth - estimatedStatusLength - 5;

const { elapsedTime, currentLoadingPhrase, currentTip, currentWittyPhrase } =
useLoadingIndicator({
streamingState,
shouldShowFocusHint,
retryStatus,
showTips: showStatusTips,
showWit: showStatusWit,
customWittyPhrases: settings.merged.ui.customWittyPhrases,
errorVerbosity: settings.merged.ui.errorVerbosity,
maxLength,
});
const {
elapsedTime,
currentLoadingPhrase,
statusPhrase,
currentTip,
currentWittyPhrase,
} = useLoadingIndicator({
streamingState,
shouldShowFocusHint,
retryStatus,
showTips: showStatusTips,
showWit: showStatusWit,
customWittyPhrases: settings.merged.ui.customWittyPhrases,
errorVerbosity: settings.merged.ui.errorVerbosity,
maxLength,
});

const allowPlanMode =
config.isPlanEnabled() &&
Expand Down Expand Up @@ -2493,6 +2498,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isFocused,
elapsedTime,
currentLoadingPhrase,
statusPhrase,
currentTip,
currentWittyPhrase,
historyRemountKey,
Expand Down Expand Up @@ -2606,6 +2612,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isFocused,
elapsedTime,
currentLoadingPhrase,
statusPhrase,
currentTip,
currentWittyPhrase,
historyRemountKey,
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/src/ui/components/LoadingIndicator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,43 @@ describe('<LoadingIndicator />', () => {
unmount();
});

it('should prioritize statusPhrase over thought.subject', async () => {
const props = {
thought: {
subject: 'Thinking...',
description: 'A description',
},
statusPhrase: 'Trying to reach gemini-2.5-flash (Attempt 1/5)',
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Trying to reach gemini-2.5-flash (Attempt 1/5)');
expect(output).not.toContain('Thinking...');
unmount();
});

it('should prioritize statusPhrase over currentLoadingPhrase', async () => {
const props = {
statusPhrase: 'Trying to reach gemini-2.5-flash (Attempt 2/5)',
currentLoadingPhrase: 'Tip: Use /clear to reset history',
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Trying to reach gemini-2.5-flash (Attempt 2/5)');
expect(output).not.toContain('Tip: Use /clear to reset history');
unmount();
});

it('should not display thought indicator for non-thought loading phrases', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
Expand Down
12 changes: 8 additions & 4 deletions packages/cli/src/ui/components/LoadingIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';

interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
statusPhrase?: string;
wittyPhrase?: string;
showWit?: boolean;
showTips?: boolean;
Expand All @@ -35,6 +36,7 @@ interface LoadingIndicatorProps {

export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
currentLoadingPhrase,
statusPhrase,
wittyPhrase,
showWit = false,
elapsedTime,
Expand All @@ -54,22 +56,24 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
if (
streamingState === StreamingState.Idle &&
!currentLoadingPhrase &&
!statusPhrase &&
!thought
) {
return null;
}

// Prioritize the interactive shell waiting phrase over the thought subject
// because it conveys an actionable state for the user (waiting for input).
// Prioritize active operational status (e.g. retries) or interactive shell waiting
// over thought subject, while keeping cosmetic tips/witty phrases subordinate to thoughts.
const primaryText =
currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE
statusPhrase ??
(currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE
? currentLoadingPhrase
: thought?.subject
? (thoughtLabel ?? thought.subject)
: currentLoadingPhrase ||
(streamingState === StreamingState.Responding
? 'Thinking...'
: undefined);
: undefined));

const cancelAndTimerContent =
showCancelAndTimer && streamingState === StreamingState.Responding
Expand Down
256 changes: 256 additions & 0 deletions packages/cli/src/ui/components/StatusRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,260 @@ describe('<StatusRow />', () => {
await waitUntilReady();
expect(lastFrame()).toContain('Tip: Test Tip');
});

it('renders retry status phrase from uiState when retrying connection', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});

const uiState: Partial<UIState> = {
...defaultUiState,
statusPhrase: 'Trying to reach gemini-2.5-flash (Attempt 1/5)',
elapsedTime: 5,
};

const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);

await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Trying to reach gemini-2.5-flash (Attempt 1/5)');
expect(output).not.toContain('Thinking...');
});

it('prioritizes retry status over thought subject when thought is present', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});

const uiState: Partial<UIState> = {
...defaultUiState,
statusPhrase: 'Trying to reach gemini-2.5-flash (Attempt 1/5)',
thought: { subject: 'Thinking...' } as unknown as ThoughtSummary,
elapsedTime: 5,
};

const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);

await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Trying to reach gemini-2.5-flash (Attempt 1/5)');
expect(output).not.toContain('Thinking...');
});

it('renders statusPhrase passed directly via props', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});

const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
statusPhrase="Trying to reach gemini-2.5-pro (Attempt 2/3)"
/>,
{
width: 100,
uiState: defaultUiState,
},
);

await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Trying to reach gemini-2.5-pro (Attempt 2/3)');
});

it('renders generic retry phrase in low error verbosity mode when attempt threshold is met', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});

const uiState: Partial<UIState> = {
...defaultUiState,
statusPhrase: "This is taking a bit longer, we're still on it.",
elapsedTime: 8,
};

const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);

await waitUntilReady();
const output = lastFrame();
expect(output).toContain("This is taking a bit longer, we're still on it.");
});

it('renders active hook status and does not crash when active hooks are present', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
});

const uiState: Partial<UIState> = {
...defaultUiState,
activeHooks: [
{
name: 'linter',
eventName: 'before-command',
source: 'user',
},
],
elapsedTime: 2,
};

const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={true}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);

await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Executing Hook: linter');
});

it('renders retry status correctly in narrow terminal mode', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
});

const uiState: Partial<UIState> = {
...defaultUiState,
statusPhrase: 'Trying to reach gemini-2.5-flash (Attempt 1/5)',
elapsedTime: 3,
};

const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={true}
terminalWidth={60}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 60,
uiState,
},
);

await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Trying to reach gemini-2.5-flash (Attempt 1/5)');
});

it('renders statusPhrase even when showLoadingIndicator is false', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
});

const uiState: Partial<UIState> = {
...defaultUiState,
statusPhrase: 'Trying to reach gemini-2.5-flash (Attempt 2/5)',
elapsedTime: 4,
};

const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);

await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Trying to reach gemini-2.5-flash (Attempt 2/5)');
});
});
Loading
Loading