Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 3 additions & 3 deletions packages/cli/src/ui/components/Composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,19 +308,19 @@ describe('Composer', () => {
expect(output).not.toContain('Should not show');
});

it('does not render LoadingIndicator when waiting for confirmation', () => {
it('renders LoadingIndicator when waiting for confirmation', () => {
const uiState = createMockUIState({
streamingState: StreamingState.WaitingForConfirmation,
thought: {
subject: 'Confirmation',
description: 'Should not show during confirmation',
description: 'Should show during confirmation',
},
});

const { lastFrame } = renderComposer(uiState);

const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
expect(output).toContain('LoadingIndicator');
});

it('does not render LoadingIndicator when a tool confirmation is pending', () => {
Expand Down
13 changes: 9 additions & 4 deletions packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,19 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
Boolean(uiState.proQuotaRequest) ||
Boolean(uiState.validationRequest) ||
Boolean(uiState.customDialog);
const isActivelyStreaming =
uiState.streamingState === StreamingState.Responding;
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
uiState.streamingState === StreamingState.Responding &&
(uiState.streamingState === StreamingState.Responding ||
uiState.streamingState === StreamingState.WaitingForConfirmation) &&
!hasPendingActionRequired;
const showApprovalIndicator = !uiState.shellModeActive;
const showRawMarkdownIndicator = !uiState.renderMarkdown;
const showEscToCancelHint =
showLoadingIndicator &&
isActivelyStreaming &&
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
!hasPendingActionRequired &&
uiState.streamingState !== StreamingState.WaitingForConfirmation;

return (
Expand Down Expand Up @@ -158,7 +163,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
alignItems="center"
flexGrow={1}
>
{!showLoadingIndicator && (
{!isActivelyStreaming && (
<Box
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
Expand Down Expand Up @@ -204,7 +209,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
{!showLoadingIndicator && (
{!isActivelyStreaming && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
</Box>
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/ui/components/ContextUsageDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export const ContextUsageDisplay = ({

return (
<Text color={theme.text.secondary}>
({percentageLeft}
{label})
{percentageLeft}
{label}
</Text>
);
};
10 changes: 4 additions & 6 deletions packages/cli/src/ui/components/Footer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe('<Footer />', () => {
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
expect(lastFrame()).toMatch(/\d+% context left/);
});

it('displays the model name and abbreviated context percentage', () => {
Expand All @@ -144,7 +144,7 @@ describe('<Footer />', () => {
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+%\)/);
expect(lastFrame()).toMatch(/\d+%/);
});

describe('sandbox and trust info', () => {
Expand Down Expand Up @@ -289,9 +289,8 @@ describe('<Footer />', () => {
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\(\d+% context left\)/);
expect(lastFrame()).not.toMatch(/\d+% context left/);
});

it('shows the context percentage when hideContextPercentage is false', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
Expand All @@ -305,9 +304,8 @@ describe('<Footer />', () => {
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
expect(lastFrame()).toMatch(/\d+% context left/);
});

it('renders complete footer in narrow terminal (baseline narrow)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 79,
Expand Down
29 changes: 10 additions & 19 deletions packages/cli/src/ui/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
} from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import { ThemedGradient } from './ThemedGradient.js';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
Expand All @@ -40,7 +39,6 @@ export const Footer: React.FC = () => {
errorCount,
showErrorDetails,
promptTokenCount,
nightly,
isTrustedFolder,
terminalWidth,
} = {
Expand All @@ -53,7 +51,6 @@ export const Footer: React.FC = () => {
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
nightly: uiState.nightly,
isTrustedFolder: uiState.isTrustedFolder,
terminalWidth: uiState.terminalWidth,
};
Expand Down Expand Up @@ -87,20 +84,14 @@ export const Footer: React.FC = () => {
{displayVimMode && (
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
)}
{!hideCWD &&
(nightly ? (
<ThemedGradient>
{displayPath}
{branchName && <Text> ({branchName}*)</Text>}
</ThemedGradient>
) : (
<Text color={theme.text.link}>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
))}
{!hideCWD && (
<Text color={theme.text.primary}>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
)}
{debugMode && (
<Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')}
Expand Down Expand Up @@ -146,9 +137,9 @@ export const Footer: React.FC = () => {
{!hideModelInfo && (
<Box alignItems="center" justifyContent="flex-end">
<Box alignItems="center">
<Text color={theme.text.accent}>
<Text color={theme.text.primary}>
<Text color={theme.text.secondary}>/model </Text>
{getDisplayString(model)}
<Text color={theme.text.secondary}> /model</Text>
{!hideContextPercentage && (
<>
{' '}
Expand Down
41 changes: 39 additions & 2 deletions packages/cli/src/ui/components/GeminiRespondingSpinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import type React from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Text, useIsScreenReaderEnabled } from 'ink';
import { CliSpinner } from './CliSpinner.js';
import type { SpinnerName } from 'cli-spinners';
Expand All @@ -15,6 +16,10 @@ import {
SCREEN_READER_RESPONDING,
} from '../textConstants.js';
import { theme } from '../semantic-colors.js';
import { Colors } from '../colors.js';
import tinygradient from 'tinygradient';

const COLOR_CYCLE_DURATION_MS = 4000;

interface GeminiRespondingSpinnerProps {
/**
Expand All @@ -37,13 +42,16 @@ export const GeminiRespondingSpinner: React.FC<
altText={SCREEN_READER_RESPONDING}
/>
);
} else if (nonRespondingDisplay) {
}

if (nonRespondingDisplay) {
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_LOADING}</Text>
) : (
<Text color={theme.text.primary}>{nonRespondingDisplay}</Text>
);
}

return null;
};

Expand All @@ -57,10 +65,39 @@ export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
altText,
}) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const [time, setTime] = useState(0);

const googleGradient = useMemo(() => {
const brandColors = [
Colors.AccentPurple,
Colors.AccentBlue,
Colors.AccentCyan,
Colors.AccentGreen,
Colors.AccentYellow,
Colors.AccentRed,
];
return tinygradient([...brandColors, brandColors[0]]);
}, []);

useEffect(() => {
if (isScreenReaderEnabled) {
return;
}

const interval = setInterval(() => {
setTime((prevTime) => prevTime + 30);
}, 30); // ~33fps for smooth color transitions

return () => clearInterval(interval);
}, [isScreenReaderEnabled]);

const progress = (time % COLOR_CYCLE_DURATION_MS) / COLOR_CYCLE_DURATION_MS;
const currentColor = googleGradient.rgbAt(progress).toHexString();

return isScreenReaderEnabled ? (
<Text>{altText}</Text>
) : (
<Text color={theme.text.primary}>
<Text color={currentColor}>
<CliSpinner type={spinnerType} />
</Text>
);
Expand Down
15 changes: 9 additions & 6 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ import {
} from '../utils/commandUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { DEFAULT_BACKGROUND_OPACITY } from '../constants.js';
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
} from '../constants.js';
import { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
Expand Down Expand Up @@ -1330,12 +1333,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
/>
) : null}
<HalfLinePaddedBox
backgroundBaseColor={
isShellFocused && !isEmbeddedShellFocused
? theme.border.focused
: theme.border.default
backgroundBaseColor={theme.text.secondary}
backgroundOpacity={
showCursor
? DEFAULT_INPUT_BACKGROUND_OPACITY
: DEFAULT_BACKGROUND_OPACITY
}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>
<Box
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/ui/components/LoadingIndicator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,12 @@ describe('<LoadingIndicator />', () => {
elapsedTime: 5,
};

it('should not render when streamingState is Idle and no loading phrase or thought', () => {
it('should render blank when streamingState is Idle and no loading phrase or thought', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
expect(lastFrame()).toBe('');
expect(lastFrame()?.trim()).toBe('');
});

it('should render spinner, phrase, and time when streamingState is Responding', () => {
Expand Down Expand Up @@ -146,7 +146,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
expect(lastFrame()).toBe(''); // Initial: Idle (no loading phrase)
expect(lastFrame()?.trim()).toBe(''); // Initial: Idle (no loading phrase)

// Transition to Responding
rerender(
Expand Down Expand Up @@ -183,7 +183,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator elapsedTime={5} />
</StreamingContext.Provider>,
);
expect(lastFrame()).toBe(''); // Idle with no loading phrase
expect(lastFrame()?.trim()).toBe(''); // Idle with no loading phrase and no spinner
unmount();
});

Expand Down
Loading
Loading