feat(cli): extract QuotaContext and resolve infinite render loop (#24959)

This commit is contained in:
Adib234
2026-04-13 14:32:18 -04:00
committed by GitHub
parent 36dca862cc
commit b91d177bde
12 changed files with 258 additions and 227 deletions
+73 -58
View File
@@ -602,6 +602,7 @@ const mockUIActions: UIActions = {
import { type TextBuffer } from '../ui/components/shared/text-buffer.js'; import { type TextBuffer } from '../ui/components/shared/text-buffer.js';
import { InputContext, type InputState } from '../ui/contexts/InputContext.js'; import { InputContext, type InputState } from '../ui/contexts/InputContext.js';
import { QuotaContext, type QuotaState } from '../ui/contexts/QuotaContext.js';
let capturedOverflowState: OverflowState | undefined; let capturedOverflowState: OverflowState | undefined;
let capturedOverflowActions: OverflowActions | undefined; let capturedOverflowActions: OverflowActions | undefined;
@@ -619,6 +620,7 @@ export const renderWithProviders = async (
shellFocus = true, shellFocus = true,
settings = mockSettings, settings = mockSettings,
uiState: providedUiState, uiState: providedUiState,
quotaState: providedQuotaState,
inputState: providedInputState, inputState: providedInputState,
width, width,
mouseEventsEnabled = false, mouseEventsEnabled = false,
@@ -631,6 +633,7 @@ export const renderWithProviders = async (
shellFocus?: boolean; shellFocus?: boolean;
settings?: LoadedSettings; settings?: LoadedSettings;
uiState?: Partial<UIState>; uiState?: Partial<UIState>;
quotaState?: Partial<QuotaState>;
inputState?: Partial<InputState>; inputState?: Partial<InputState>;
width?: number; width?: number;
mouseEventsEnabled?: boolean; mouseEventsEnabled?: boolean;
@@ -666,6 +669,16 @@ export const renderWithProviders = async (
}, },
) as UIState; ) as UIState;
const quotaState: QuotaState = {
userTier: undefined,
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
...providedQuotaState,
};
const inputState = { const inputState = {
buffer: { text: '' } as unknown as TextBuffer, buffer: { text: '' } as unknown as TextBuffer,
userMessages: [], userMessages: [],
@@ -727,65 +740,67 @@ export const renderWithProviders = async (
<AppContext.Provider value={appState}> <AppContext.Provider value={appState}>
<ConfigContext.Provider value={config}> <ConfigContext.Provider value={config}>
<SettingsContext.Provider value={settings}> <SettingsContext.Provider value={settings}>
<InputContext.Provider value={inputState}> <QuotaContext.Provider value={quotaState}>
<UIStateContext.Provider value={finalUiState}> <InputContext.Provider value={inputState}>
<VimModeProvider> <UIStateContext.Provider value={finalUiState}>
<ShellFocusContext.Provider value={shellFocus}> <VimModeProvider>
<SessionStatsProvider sessionId={config.getSessionId()}> <ShellFocusContext.Provider value={shellFocus}>
<StreamingContext.Provider <SessionStatsProvider sessionId={config.getSessionId()}>
value={finalUiState.streamingState} <StreamingContext.Provider
> value={finalUiState.streamingState}
<UIActionsContext.Provider value={finalUIActions}> >
<OverflowProvider> <UIActionsContext.Provider value={finalUIActions}>
<ToolActionsProvider <OverflowProvider>
config={config} <ToolActionsProvider
toolCalls={allToolCalls} config={config}
isExpanded={ toolCalls={allToolCalls}
toolActions?.isExpanded ?? isExpanded={
vi.fn().mockReturnValue(false) toolActions?.isExpanded ??
} vi.fn().mockReturnValue(false)
toggleExpansion={ }
toolActions?.toggleExpansion ?? vi.fn() toggleExpansion={
} toolActions?.toggleExpansion ?? vi.fn()
toggleAllExpansion={ }
toolActions?.toggleAllExpansion ?? vi.fn() toggleAllExpansion={
} toolActions?.toggleAllExpansion ?? vi.fn()
> }
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
> >
<KeypressProvider> <AskUserActionsProvider
<MouseProvider request={null}
mouseEventsEnabled={mouseEventsEnabled} onSubmit={vi.fn()}
> onCancel={vi.fn()}
<TerminalProvider> >
<ScrollProvider> <KeypressProvider>
<ContextCapture> <MouseProvider
<Box mouseEventsEnabled={mouseEventsEnabled}
width={terminalWidth} >
flexShrink={0} <TerminalProvider>
flexGrow={0} <ScrollProvider>
flexDirection="column" <ContextCapture>
> <Box
{comp} width={terminalWidth}
</Box> flexShrink={0}
</ContextCapture> flexGrow={0}
</ScrollProvider> flexDirection="column"
</TerminalProvider> >
</MouseProvider> {comp}
</KeypressProvider> </Box>
</AskUserActionsProvider> </ContextCapture>
</ToolActionsProvider> </ScrollProvider>
</OverflowProvider> </TerminalProvider>
</UIActionsContext.Provider> </MouseProvider>
</StreamingContext.Provider> </KeypressProvider>
</SessionStatsProvider> </AskUserActionsProvider>
</ShellFocusContext.Provider> </ToolActionsProvider>
</VimModeProvider> </OverflowProvider>
</UIStateContext.Provider> </UIActionsContext.Provider>
</InputContext.Provider> </StreamingContext.Provider>
</SessionStatsProvider>
</ShellFocusContext.Provider>
</VimModeProvider>
</UIStateContext.Provider>
</InputContext.Provider>
</QuotaContext.Provider>
</SettingsContext.Provider> </SettingsContext.Provider>
</ConfigContext.Provider> </ConfigContext.Provider>
</AppContext.Provider> </AppContext.Provider>
+7 -4
View File
@@ -123,16 +123,19 @@ vi.mock('ink', async (importOriginal) => {
}); });
import { InputContext, type InputState } from './contexts/InputContext.js'; import { InputContext, type InputState } from './contexts/InputContext.js';
import { QuotaContext, type QuotaState } from './contexts/QuotaContext.js';
// Helper component will read the context values provided by AppContainer // Helper component will read the context values provided by AppContainer
// so we can assert against them in our tests. // so we can assert against them in our tests.
let capturedUIState: UIState; let capturedUIState: UIState;
let capturedInputState: InputState; let capturedInputState: InputState;
let capturedQuotaState: QuotaState;
let capturedUIActions: UIActions; let capturedUIActions: UIActions;
let capturedOverflowActions: OverflowActions; let capturedOverflowActions: OverflowActions;
function TestContextConsumer() { function TestContextConsumer() {
capturedUIState = useContext(UIStateContext)!; capturedUIState = useContext(UIStateContext)!;
capturedInputState = useContext(InputContext)!; capturedInputState = useContext(InputContext)!;
capturedQuotaState = useContext(QuotaContext)!;
capturedUIActions = useContext(UIActionsContext)!; capturedUIActions = useContext(UIActionsContext)!;
capturedOverflowActions = useOverflowActions()!; capturedOverflowActions = useOverflowActions()!;
return null; return null;
@@ -1309,15 +1312,15 @@ describe('AppContainer State Management', () => {
}); });
describe('Quota and Fallback Integration', () => { describe('Quota and Fallback Integration', () => {
it('passes a null proQuotaRequest to UIStateContext by default', async () => { it('passes a null proQuotaRequest to QuotaContext by default', async () => {
// The default mock from beforeEach already sets proQuotaRequest to null // The default mock from beforeEach already sets proQuotaRequest to null
const { unmount } = await act(async () => renderAppContainer()); const { unmount } = await act(async () => renderAppContainer());
// Assert that the context value is as expected // Assert that the context value is as expected
expect(capturedUIState.quota.proQuotaRequest).toBeNull(); expect(capturedQuotaState.proQuotaRequest).toBeNull();
unmount(); unmount();
}); });
it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => { it('passes a valid proQuotaRequest to QuotaContext when provided by the hook', async () => {
// Arrange: Create a mock request object that a UI dialog would receive // Arrange: Create a mock request object that a UI dialog would receive
const mockRequest = { const mockRequest = {
failedModel: 'gemini-pro', failedModel: 'gemini-pro',
@@ -1332,7 +1335,7 @@ describe('AppContainer State Management', () => {
// Act: Render the container // Act: Render the container
const { unmount } = await act(async () => renderAppContainer()); const { unmount } = await act(async () => renderAppContainer());
// Assert: The mock request is correctly passed through the context // Assert: The mock request is correctly passed through the context
expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest); expect(capturedQuotaState.proQuotaRequest).toEqual(mockRequest);
unmount(); unmount();
}); });
+50 -42
View File
@@ -25,6 +25,7 @@ import {
import { App } from './App.js'; import { App } from './App.js';
import { AppContext } from './contexts/AppContext.js'; import { AppContext } from './contexts/AppContext.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import { QuotaContext } from './contexts/QuotaContext.js';
import { import {
UIActionsContext, UIActionsContext,
type UIActions, type UIActions,
@@ -2401,6 +2402,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
], ],
); );
const quotaState = useMemo(
() => ({
userTier,
stats: quotaStats,
proQuotaRequest,
validationRequest,
// G1 AI Credits dialog state
overageMenuRequest,
emptyWalletRequest,
}),
[
userTier,
quotaStats,
proQuotaRequest,
validationRequest,
overageMenuRequest,
emptyWalletRequest,
],
);
const uiState: UIState = useMemo( const uiState: UIState = useMemo(
() => ({ () => ({
history: historyManager.history, history: historyManager.history,
@@ -2473,15 +2494,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
showApprovalModeIndicator, showApprovalModeIndicator,
allowPlanMode, allowPlanMode,
currentModel, currentModel,
quota: {
userTier,
stats: quotaStats,
proQuotaRequest,
validationRequest,
// G1 AI Credits dialog state
overageMenuRequest,
emptyWalletRequest,
},
contextFileNames, contextFileNames,
errorCount, errorCount,
availableTerminalHeight, availableTerminalHeight,
@@ -2592,12 +2604,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage, queueErrorMessage,
showApprovalModeIndicator, showApprovalModeIndicator,
allowPlanMode, allowPlanMode,
userTier,
quotaStats,
proQuotaRequest,
validationRequest,
overageMenuRequest,
emptyWalletRequest,
contextFileNames, contextFileNames,
errorCount, errorCount,
availableTerminalHeight, availableTerminalHeight,
@@ -2816,34 +2822,36 @@ Logging in with Google... Restarting Gemini CLI to continue.
return ( return (
<UIStateContext.Provider value={uiState}> <UIStateContext.Provider value={uiState}>
<InputContext.Provider value={inputState}> <QuotaContext.Provider value={quotaState}>
<UIActionsContext.Provider value={uiActions}> <InputContext.Provider value={inputState}>
<ConfigContext.Provider value={config}> <UIActionsContext.Provider value={uiActions}>
<AppContext.Provider <ConfigContext.Provider value={config}>
value={{ <AppContext.Provider
version: props.version, value={{
startupWarnings: props.startupWarnings || [], version: props.version,
}} startupWarnings: props.startupWarnings || [],
> }}
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={isExpanded}
toggleExpansion={toggleExpansion}
toggleAllExpansion={toggleAllExpansion}
> >
<ShellFocusContext.Provider value={isFocused}> <ToolActionsProvider
<MouseProvider mouseEventsEnabled={mouseMode}> config={config}
<ScrollProvider> toolCalls={allToolCalls}
<App key={`app-${forceRerenderKey}`} /> isExpanded={isExpanded}
</ScrollProvider> toggleExpansion={toggleExpansion}
</MouseProvider> toggleAllExpansion={toggleAllExpansion}
</ShellFocusContext.Provider> >
</ToolActionsProvider> <ShellFocusContext.Provider value={isFocused}>
</AppContext.Provider> <MouseProvider mouseEventsEnabled={mouseMode}>
</ConfigContext.Provider> <ScrollProvider>
</UIActionsContext.Provider> <App key={`app-${forceRerenderKey}`} />
</InputContext.Provider> </ScrollProvider>
</MouseProvider>
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
</ConfigContext.Provider>
</UIActionsContext.Provider>
</InputContext.Provider>
</QuotaContext.Provider>
</UIStateContext.Provider> </UIStateContext.Provider>
); );
}; };
@@ -11,9 +11,9 @@ import {
CoreToolCallStatus, CoreToolCallStatus,
ApprovalMode, ApprovalMode,
makeFakeConfig, makeFakeConfig,
type SerializableConfirmationDetails,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { type UIState } from './contexts/UIStateContext.js'; import { type UIState } from './contexts/UIStateContext.js';
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
import { act } from 'react'; import { act } from 'react';
import { StreamingState } from './types.js'; import { StreamingState } from './types.js';
@@ -107,15 +107,6 @@ describe('Full Terminal Tool Confirmation Snapshot', () => {
constrainHeight: true, constrainHeight: true,
isConfigInitialized: true, isConfigInitialized: true,
cleanUiDetailsVisible: true, cleanUiDetailsVisible: true,
quota: {
userTier: 'PRO',
stats: {
limits: {},
usage: {},
},
proQuotaRequest: null,
validationRequest: null,
},
pendingHistoryItems: [ pendingHistoryItems: [
{ {
id: 2, id: 2,
@@ -145,6 +136,13 @@ describe('Full Terminal Tool Confirmation Snapshot', () => {
const { waitUntilReady, lastFrame, generateSvg, unmount } = const { waitUntilReady, lastFrame, generateSvg, unmount } =
await renderWithProviders(<App />, { await renderWithProviders(<App />, {
uiState: mockUIState, uiState: mockUIState,
quotaState: {
userTier: 'PRO',
stats: {
remaining: 100,
limit: 1000,
},
},
config: mockConfig, config: mockConfig,
settings: createMockSettings({ settings: createMockSettings({
merged: { merged: {
@@ -201,12 +201,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
isBackgroundTaskVisible: false, isBackgroundTaskVisible: false,
embeddedShellFocused: false, embeddedShellFocused: false,
showIsExpandableHint: false, showIsExpandableHint: false,
quota: {
userTier: undefined,
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
},
...overrides, ...overrides,
}) as UIState; }) as UIState;
@@ -245,6 +239,7 @@ const createMockConfig = (overrides = {}): Config =>
...overrides, ...overrides,
}) as unknown as Config; }) as unknown as Config;
import { QuotaContext, type QuotaState } from '../contexts/QuotaContext.js';
import { InputContext, type InputState } from '../contexts/InputContext.js'; import { InputContext, type InputState } from '../contexts/InputContext.js';
const renderComposer = async ( const renderComposer = async (
@@ -253,6 +248,7 @@ const renderComposer = async (
config = createMockConfig(), config = createMockConfig(),
uiActions = createMockUIActions(), uiActions = createMockUIActions(),
inputStateOverrides: Partial<InputState> = {}, inputStateOverrides: Partial<InputState> = {},
quotaStateOverrides: Partial<QuotaState> = {},
) => { ) => {
const inputState = { const inputState = {
buffer: { text: '' } as unknown as TextBuffer, buffer: { text: '' } as unknown as TextBuffer,
@@ -266,16 +262,28 @@ const renderComposer = async (
...inputStateOverrides, ...inputStateOverrides,
}; };
const quotaState: QuotaState = {
userTier: undefined,
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
...quotaStateOverrides,
};
const result = await render( const result = await render(
<ConfigContext.Provider value={config as unknown as Config}> <ConfigContext.Provider value={config as unknown as Config}>
<SettingsContext.Provider value={settings as unknown as LoadedSettings}> <SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<InputContext.Provider value={inputState}> <QuotaContext.Provider value={quotaState}>
<UIStateContext.Provider value={uiState}> <InputContext.Provider value={inputState}>
<UIActionsContext.Provider value={uiActions}> <UIStateContext.Provider value={uiState}>
<Composer isFocused={true} /> <UIActionsContext.Provider value={uiActions}>
</UIActionsContext.Provider> <Composer isFocused={true} />
</UIStateContext.Provider> </UIActionsContext.Provider>
</InputContext.Provider> </UIStateContext.Provider>
</InputContext.Provider>
</QuotaContext.Provider>
</SettingsContext.Provider> </SettingsContext.Provider>
</ConfigContext.Provider>, </ConfigContext.Provider>,
); );
@@ -9,6 +9,7 @@ import { DialogManager } from './DialogManager.js';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink'; import { Text } from 'ink';
import { type UIState } from '../contexts/UIStateContext.js'; import { type UIState } from '../contexts/UIStateContext.js';
import { type QuotaState } from '../contexts/QuotaContext.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import { type IdeInfo } from '@google/gemini-cli-core'; import { type IdeInfo } from '@google/gemini-cli-core';
@@ -75,14 +76,6 @@ describe('DialogManager', () => {
terminalWidth: 80, terminalWidth: 80,
confirmUpdateExtensionRequests: [], confirmUpdateExtensionRequests: [],
showIdeRestartPrompt: false, showIdeRestartPrompt: false,
quota: {
userTier: undefined,
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
shouldShowIdePrompt: false, shouldShowIdePrompt: false,
isFolderTrustDialogOpen: false, isFolderTrustDialogOpen: false,
loopDetectionConfirmationRequest: null, loopDetectionConfirmationRequest: null,
@@ -112,7 +105,7 @@ describe('DialogManager', () => {
unmount(); unmount();
}); });
const testCases: Array<[Partial<UIState>, string]> = [ const testCases: Array<[Partial<UIState>, string, Partial<QuotaState>?]> = [
[ [
{ {
showIdeRestartPrompt: true, showIdeRestartPrompt: true,
@@ -121,23 +114,17 @@ describe('DialogManager', () => {
'IdeTrustChangeDialog', 'IdeTrustChangeDialog',
], ],
[ [
{},
'ProQuotaDialog',
{ {
quota: { proQuotaRequest: {
userTier: undefined, failedModel: 'a',
stats: undefined, fallbackModel: 'b',
proQuotaRequest: { message: 'c',
failedModel: 'a', isTerminalQuotaError: false,
fallbackModel: 'b', resolve: vi.fn(),
message: 'c',
isTerminalQuotaError: false,
resolve: vi.fn(),
},
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
}, },
}, },
'ProQuotaDialog',
], ],
[ [
{ {
@@ -195,7 +182,11 @@ describe('DialogManager', () => {
it.each(testCases)( it.each(testCases)(
'renders %s when state is %o', 'renders %s when state is %o',
async (uiStateOverride, expectedComponent) => { async (
uiStateOverride: Partial<UIState>,
expectedComponent: string,
quotaStateOverride?: Partial<QuotaState>,
) => {
const { lastFrame, unmount } = await renderWithProviders( const { lastFrame, unmount } = await renderWithProviders(
<DialogManager {...defaultProps} />, <DialogManager {...defaultProps} />,
{ {
@@ -203,6 +194,7 @@ describe('DialogManager', () => {
...baseUiState, ...baseUiState,
...uiStateOverride, ...uiStateOverride,
} as Partial<UIState> as UIState, } as Partial<UIState> as UIState,
quotaState: quotaStateOverride,
}, },
); );
expect(lastFrame()).toContain(expectedComponent); expect(lastFrame()).toContain(expectedComponent);
@@ -27,6 +27,7 @@ import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'
import { ModelDialog } from './ModelDialog.js'; import { ModelDialog } from './ModelDialog.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import { useQuotaState } from '../contexts/QuotaContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js'; import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js'; import { useSettings } from '../contexts/SettingsContext.js';
@@ -52,6 +53,7 @@ export const DialogManager = ({
const settings = useSettings(); const settings = useSettings();
const uiState = useUIState(); const uiState = useUIState();
const quotaState = useQuotaState();
const uiActions = useUIActions(); const uiActions = useUIActions();
const { const {
constrainHeight, constrainHeight,
@@ -74,54 +76,50 @@ export const DialogManager = ({
/> />
); );
} }
if (uiState.quota.proQuotaRequest) { if (quotaState.proQuotaRequest) {
return ( return (
<ProQuotaDialog <ProQuotaDialog
failedModel={uiState.quota.proQuotaRequest.failedModel} failedModel={quotaState.proQuotaRequest.failedModel}
fallbackModel={uiState.quota.proQuotaRequest.fallbackModel} fallbackModel={quotaState.proQuotaRequest.fallbackModel}
message={uiState.quota.proQuotaRequest.message} message={quotaState.proQuotaRequest.message}
isTerminalQuotaError={ isTerminalQuotaError={quotaState.proQuotaRequest.isTerminalQuotaError}
uiState.quota.proQuotaRequest.isTerminalQuotaError isModelNotFoundError={!!quotaState.proQuotaRequest.isModelNotFoundError}
} authType={quotaState.proQuotaRequest.authType}
isModelNotFoundError={
!!uiState.quota.proQuotaRequest.isModelNotFoundError
}
authType={uiState.quota.proQuotaRequest.authType}
tierName={config?.getUserTierName()} tierName={config?.getUserTierName()}
onChoice={uiActions.handleProQuotaChoice} onChoice={uiActions.handleProQuotaChoice}
/> />
); );
} }
if (uiState.quota.validationRequest) { if (quotaState.validationRequest) {
return ( return (
<ValidationDialog <ValidationDialog
validationLink={uiState.quota.validationRequest.validationLink} validationLink={quotaState.validationRequest.validationLink}
validationDescription={ validationDescription={
uiState.quota.validationRequest.validationDescription quotaState.validationRequest.validationDescription
} }
learnMoreUrl={uiState.quota.validationRequest.learnMoreUrl} learnMoreUrl={quotaState.validationRequest.learnMoreUrl}
onChoice={uiActions.handleValidationChoice} onChoice={uiActions.handleValidationChoice}
/> />
); );
} }
if (uiState.quota.overageMenuRequest) { if (quotaState.overageMenuRequest) {
return ( return (
<OverageMenuDialog <OverageMenuDialog
failedModel={uiState.quota.overageMenuRequest.failedModel} failedModel={quotaState.overageMenuRequest.failedModel}
fallbackModel={uiState.quota.overageMenuRequest.fallbackModel} fallbackModel={quotaState.overageMenuRequest.fallbackModel}
resetTime={uiState.quota.overageMenuRequest.resetTime} resetTime={quotaState.overageMenuRequest.resetTime}
creditBalance={uiState.quota.overageMenuRequest.creditBalance} creditBalance={quotaState.overageMenuRequest.creditBalance}
onChoice={uiActions.handleOverageMenuChoice} onChoice={uiActions.handleOverageMenuChoice}
/> />
); );
} }
if (uiState.quota.emptyWalletRequest) { if (quotaState.emptyWalletRequest) {
return ( return (
<EmptyWalletDialog <EmptyWalletDialog
failedModel={uiState.quota.emptyWalletRequest.failedModel} failedModel={quotaState.emptyWalletRequest.failedModel}
fallbackModel={uiState.quota.emptyWalletRequest.fallbackModel} fallbackModel={quotaState.emptyWalletRequest.fallbackModel}
resetTime={uiState.quota.emptyWalletRequest.resetTime} resetTime={quotaState.emptyWalletRequest.resetTime}
onGetCredits={uiState.quota.emptyWalletRequest.onGetCredits} onGetCredits={quotaState.emptyWalletRequest.onGetCredits}
onChoice={uiActions.handleEmptyWalletChoice} onChoice={uiActions.handleEmptyWalletChoice}
/> />
); );
+18 -33
View File
@@ -267,17 +267,12 @@ describe('<Footer />', () => {
width: 120, width: 120,
uiState: { uiState: {
sessionStats: mockSessionStats, sessionStats: mockSessionStats,
quota: { },
userTier: undefined, quotaState: {
stats: { stats: {
remaining: 15, remaining: 15,
limit: 100, limit: 100,
resetTime: undefined, resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
}, },
}, },
}); });
@@ -292,17 +287,12 @@ describe('<Footer />', () => {
width: 120, width: 120,
uiState: { uiState: {
sessionStats: mockSessionStats, sessionStats: mockSessionStats,
quota: { },
userTier: undefined, quotaState: {
stats: { stats: {
remaining: 85, remaining: 85,
limit: 100, limit: 100,
resetTime: undefined, resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
}, },
}, },
}); });
@@ -317,17 +307,12 @@ describe('<Footer />', () => {
width: 120, width: 120,
uiState: { uiState: {
sessionStats: mockSessionStats, sessionStats: mockSessionStats,
quota: { },
userTier: undefined, quotaState: {
stats: { stats: {
remaining: 0, remaining: 0,
limit: 100, limit: 100,
resetTime: undefined, resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
}, },
}, },
}); });
+4 -2
View File
@@ -23,6 +23,7 @@ import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { QuotaDisplay } from './QuotaDisplay.js'; import { QuotaDisplay } from './QuotaDisplay.js';
import { DebugProfiler } from './DebugProfiler.js'; import { DebugProfiler } from './DebugProfiler.js';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import { useQuotaState } from '../contexts/QuotaContext.js';
import { useConfig } from '../contexts/ConfigContext.js'; import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js'; import { useSettings } from '../contexts/SettingsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js'; import { useVimMode } from '../contexts/VimModeContext.js';
@@ -174,6 +175,7 @@ interface FooterColumn {
export const Footer: React.FC = () => { export const Footer: React.FC = () => {
const uiState = useUIState(); const uiState = useUIState();
const quotaState = useQuotaState();
const { copyModeEnabled } = useInputState(); const { copyModeEnabled } = useInputState();
const config = useConfig(); const config = useConfig();
const settings = useSettings(); const settings = useSettings();
@@ -203,7 +205,6 @@ export const Footer: React.FC = () => {
promptTokenCount, promptTokenCount,
isTrustedFolder, isTrustedFolder,
terminalWidth, terminalWidth,
quotaStats,
} = { } = {
model: uiState.currentModel, model: uiState.currentModel,
targetDir: config.getTargetDir(), targetDir: config.getTargetDir(),
@@ -216,9 +217,10 @@ export const Footer: React.FC = () => {
promptTokenCount: uiState.sessionStats.lastPromptTokenCount, promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
isTrustedFolder: uiState.isTrustedFolder, isTrustedFolder: uiState.isTrustedFolder,
terminalWidth: uiState.terminalWidth, terminalWidth: uiState.terminalWidth,
quotaStats: uiState.quota.stats,
}; };
const quotaStats = quotaState.stats;
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full'; const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
const showErrorSummary = const showErrorSummary =
!showErrorDetails && !showErrorDetails &&
@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
import type { QuotaStats } from '../types.js';
import type { UserTierId } from '@google/gemini-cli-core';
import type {
ProQuotaDialogRequest,
ValidationDialogRequest,
OverageMenuDialogRequest,
EmptyWalletDialogRequest,
} from './UIStateContext.js';
export interface QuotaState {
userTier?: UserTierId;
stats?: QuotaStats;
proQuotaRequest?: ProQuotaDialogRequest | null;
validationRequest?: ValidationDialogRequest | null;
overageMenuRequest?: OverageMenuDialogRequest | null;
emptyWalletRequest?: EmptyWalletDialogRequest | null;
}
export const QuotaContext = createContext<QuotaState | null>(null);
export const useQuotaState = () => {
const context = useContext(QuotaContext);
if (!context) {
throw new Error('useQuotaState must be used within a QuotaProvider');
}
return context;
};
@@ -9,7 +9,6 @@ import type {
HistoryItem, HistoryItem,
ThoughtSummary, ThoughtSummary,
ConfirmationRequest, ConfirmationRequest,
QuotaStats,
LoopDetectionConfirmationRequest, LoopDetectionConfirmationRequest,
HistoryItemWithoutId, HistoryItemWithoutId,
StreamingState, StreamingState,
@@ -21,7 +20,6 @@ import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { import type {
IdeContext, IdeContext,
ApprovalMode, ApprovalMode,
UserTierId,
IdeInfo, IdeInfo,
AuthType, AuthType,
FallbackIntent, FallbackIntent,
@@ -86,16 +84,6 @@ import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js'; import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js'; import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
export interface QuotaState {
userTier: UserTierId | undefined;
stats: QuotaStats | undefined;
proQuotaRequest: ProQuotaDialogRequest | null;
validationRequest: ValidationDialogRequest | null;
// G1 AI Credits overage flow
overageMenuRequest: OverageMenuDialogRequest | null;
emptyWalletRequest: EmptyWalletDialogRequest | null;
}
export interface AccountSuspensionInfo { export interface AccountSuspensionInfo {
message: string; message: string;
appealUrl?: string; appealUrl?: string;
@@ -169,8 +157,6 @@ export interface UIState {
queueErrorMessage: string | null; queueErrorMessage: string | null;
showApprovalModeIndicator: ApprovalMode; showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean; allowPlanMode: boolean;
// Quota-related state
quota: QuotaState;
currentModel: string; currentModel: string;
contextFileNames: string[]; contextFileNames: string[];
errorCount: number; errorCount: number;
@@ -6,6 +6,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import { useQuotaState } from '../contexts/QuotaContext.js';
import { useSettings } from '../contexts/SettingsContext.js'; import { useSettings } from '../contexts/SettingsContext.js';
import { CoreToolCallStatus, ApprovalMode } from '@google/gemini-cli-core'; import { CoreToolCallStatus, ApprovalMode } from '@google/gemini-cli-core';
import { type HistoryItemToolGroup, StreamingState } from '../types.js'; import { type HistoryItemToolGroup, StreamingState } from '../types.js';
@@ -18,6 +19,7 @@ import { theme } from '../semantic-colors.js';
*/ */
export const useComposerStatus = () => { export const useComposerStatus = () => {
const uiState = useUIState(); const uiState = useUIState();
const quotaState = useQuotaState();
const settings = useSettings(); const settings = useSettings();
const hasPendingToolConfirmation = useMemo( const hasPendingToolConfirmation = useMemo(
@@ -40,8 +42,8 @@ export const useComposerStatus = () => {
Boolean(uiState.authConsentRequest) || Boolean(uiState.authConsentRequest) ||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 || (uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
Boolean(uiState.loopDetectionConfirmationRequest) || Boolean(uiState.loopDetectionConfirmationRequest) ||
Boolean(uiState.quota.proQuotaRequest) || Boolean(quotaState.proQuotaRequest) ||
Boolean(uiState.quota.validationRequest) || Boolean(quotaState.validationRequest) ||
Boolean(uiState.customDialog); Boolean(uiState.customDialog);
const isInteractiveShellWaiting = Boolean( const isInteractiveShellWaiting = Boolean(