Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 7 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,17 @@ 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 &&
!hasPendingActionRequired;
const showApprovalIndicator = !uiState.shellModeActive;
const showRawMarkdownIndicator = !uiState.renderMarkdown;
const showEscToCancelHint =
showLoadingIndicator &&
isActivelyStreaming &&
!uiState.embeddedShellFocused &&
!hasPendingActionRequired &&
uiState.streamingState !== StreamingState.WaitingForConfirmation;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The refactoring of the condition for showEscToCancelHint seems to have introduced a logic change that might be a regression. Previously, the visibility of the hint was tied to showLoadingIndicator, which included the condition (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible). The new logic uses !uiState.embeddedShellFocused, which is more restrictive. This means the 'esc to cancel' hint will no longer appear when an embedded shell is focused, even if a background shell is visible, which might not be the intended behavior. To restore the original logic, the condition should also check for uiState.isBackgroundShellVisible.

Suggested change
isActivelyStreaming &&
!uiState.embeddedShellFocused &&
!hasPendingActionRequired &&
uiState.streamingState !== StreamingState.WaitingForConfirmation;
isActivelyStreaming &&
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
!hasPendingActionRequired &&
uiState.streamingState !== StreamingState.WaitingForConfirmation;
References
  1. The Esc-Esc shortcut should clear the input buffer, including when it contains only whitespace. UI hints for this action should be consistent with this behavior.
  2. Maintain consistency with existing UI behavior across components. Defer non-standard UX pattern improvements to be addressed holistically rather than in a single component.


return (
Expand Down Expand Up @@ -158,7 +161,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 +207,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>
);
};
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
64 changes: 34 additions & 30 deletions packages/cli/src/ui/components/LoadingIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,41 +37,43 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);

if (
streamingState === StreamingState.Idle &&
!currentLoadingPhrase &&
!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).
const primaryText =
currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE
? currentLoadingPhrase
: thought?.subject || currentLoadingPhrase;
: thought?.subject || currentLoadingPhrase || undefined;

const textColor =
streamingState === StreamingState.Idle
? theme.text.secondary
: theme.text.primary;

const italic = streamingState === StreamingState.Responding;

const cancelAndTimerContent =
showCancelAndTimer &&
streamingState !== StreamingState.WaitingForConfirmation
streamingState !== StreamingState.WaitingForConfirmation &&
streamingState !== StreamingState.Idle
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null;

if (inline) {
return (
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
}
/>
</Box>
{streamingState !== StreamingState.Idle && (
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
}
/>
</Box>
)}
{primaryText && (
<Text color={theme.text.accent} wrap="truncate-end">
<Text color={textColor} italic={italic} wrap="truncate-end">
{primaryText}
</Text>
)}
Expand All @@ -94,17 +96,19 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
}
/>
</Box>
{streamingState !== StreamingState.Idle && (
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
}
/>
</Box>
)}
{primaryText && (
<Text color={theme.text.accent} wrap="truncate-end">
<Text color={textColor} italic={italic} wrap="truncate-end">
{primaryText}
</Text>
)}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/contexts/UIStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export interface UIState {
showEscapePrompt: boolean;
shortcutsHelpVisible: boolean;
elapsedTime: number;
currentLoadingPhrase: string;
currentLoadingPhrase: string | undefined;
historyRemountKey: number;
activeHooks: ActiveHook[];
messageQueue: string[];
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/ui/hooks/usePhraseCycler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ export const usePhraseCycler = (
? customPhrases
: WITTY_LOADING_PHRASES;

const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(
loadingPhrases[0],
);
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState<
string | undefined
>(isActive ? loadingPhrases[0] : undefined);

const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
const hasShownFirstRequestTipRef = useRef(false);
Expand All @@ -56,7 +56,7 @@ export const usePhraseCycler = (
}

if (!isActive) {
setCurrentLoadingPhrase(loadingPhrases[0]);
setCurrentLoadingPhrase(undefined);
return;
}

Expand Down
Loading