diff --git a/.gemini/settings.json b/.gemini/settings.json index 6a0121df17..cd0e72ecb5 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -2,6 +2,7 @@ "experimental": { "extensionReloading": true, "modelSteering": true, + "memoryManager": true, "topicUpdateNarration": true }, "general": { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ccc2ad70ce..84e6bc483e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,7 +110,9 @@ assign or unassign the issue as requested, provided the conditions are met (e.g., an issue must be unassigned to be assigned). Please note that you can have a maximum of 3 issues assigned to you at any given -time. +time and that only +[issues labeled "help wanted"](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22) +may be self-assigned. ### Pull request guidelines diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index f5532a07ca..00677943ad 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -327,8 +327,12 @@ Storage whenever Gemini CLI exits Plan Mode to start the implementation. ```bash #!/usr/bin/env bash -# Extract the plan path from the tool input JSON -plan_path=$(jq -r '.tool_input.plan_path // empty') +# Extract the plan filename from the tool input JSON +plan_filename=$(jq -r '.tool_input.plan_filename // empty') +plan_filename=$(basename -- "$plan_filename") + +# Construct the absolute path using the GEMINI_PLANS_DIR environment variable +plan_path="$GEMINI_PLANS_DIR/$plan_filename" if [ -f "$plan_path" ]; then # Generate a unique filename using a timestamp @@ -441,6 +445,10 @@ on the current phase of your task: switches to a high-speed **Flash** model. This provides a faster, more responsive experience during the implementation of the plan. +If the high-reasoning model is unavailable or you don't have access to it, +Gemini CLI automatically and silently falls back to a faster model to ensure +your workflow isn't interrupted. + This behavior is enabled by default to provide the best balance of quality and performance. You can disable this automatic switching in your settings: diff --git a/docs/hooks/index.md b/docs/hooks/index.md index 0d6ae6d447..0125a28eb2 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -138,6 +138,7 @@ multiple layers in the following order of precedence (highest to lowest): Hooks are executed with a sanitized environment. - `GEMINI_PROJECT_DIR`: The absolute path to the project root. +- `GEMINI_PLANS_DIR`: The absolute path to the plans directory. - `GEMINI_SESSION_ID`: The unique ID for the current session. - `GEMINI_CWD`: The current working directory. - `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility. diff --git a/package-lock.json b/package-lock.json index 0c6c449d32..0df4109d84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10839,6 +10839,18 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -18187,6 +18199,7 @@ "https-proxy-agent": "^7.0.6", "ignore": "^7.0.0", "ipaddr.js": "^1.9.1", + "isbinaryfile": "^5.0.7", "js-yaml": "^4.1.1", "json-stable-stringify": "^1.3.0", "marked": "^15.0.12", diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts index 4d704cc8dd..73c1c98113 100644 --- a/packages/cli/src/acp/commands/memory.ts +++ b/packages/cli/src/acp/commands/memory.ts @@ -7,6 +7,7 @@ import { addMemory, listInboxSkills, + listInboxPatches, listMemoryFiles, refreshMemory, showMemory, @@ -141,22 +142,34 @@ export class InboxMemoryCommand implements Command { }; } - const skills = await listInboxSkills(context.agentContext.config); + const [skills, patches] = await Promise.all([ + listInboxSkills(context.agentContext.config), + listInboxPatches(context.agentContext.config), + ]); - if (skills.length === 0) { - return { name: this.name, data: 'No extracted skills in inbox.' }; + if (skills.length === 0 && patches.length === 0) { + return { name: this.name, data: 'No items in inbox.' }; } - const lines = skills.map((s) => { + const lines: string[] = []; + for (const s of skills) { const date = s.extractedAt ? ` (extracted: ${new Date(s.extractedAt).toLocaleDateString()})` : ''; - return `- **${s.name}**: ${s.description}${date}`; - }); + lines.push(`- **${s.name}**: ${s.description}${date}`); + } + for (const p of patches) { + const targets = p.entries.map((e) => e.targetPath).join(', '); + const date = p.extractedAt + ? ` (extracted: ${new Date(p.extractedAt).toLocaleDateString()})` + : ''; + lines.push(`- **${p.name}** (update): patches ${targets}${date}`); + } + const total = skills.length + patches.length; return { name: this.name, - data: `Skill inbox (${skills.length}):\n${lines.join('\n')}`, + data: `Memory inbox (${total}):\n${lines.join('\n')}`, }; } } diff --git a/packages/cli/src/config/footerItems.ts b/packages/cli/src/config/footerItems.ts index 9f3943b692..612418a48f 100644 --- a/packages/cli/src/config/footerItems.ts +++ b/packages/cli/src/config/footerItems.ts @@ -34,8 +34,8 @@ export const ALL_ITEMS = [ }, { id: 'quota', - header: '/stats', - description: 'Remaining usage on daily limit (not shown when unavailable)', + header: 'quota', + description: 'Percentage of daily limit used (not shown when unavailable)', }, { id: 'memory-usage', diff --git a/packages/cli/src/nonInteractiveCliAgentSession.ts b/packages/cli/src/nonInteractiveCliAgentSession.ts index 7f36ce6cf5..4fee7eb610 100644 --- a/packages/cli/src/nonInteractiveCliAgentSession.ts +++ b/packages/cli/src/nonInteractiveCliAgentSession.ts @@ -37,6 +37,7 @@ import { LegacyAgentSession, ToolErrorType, geminiPartsToContentParts, + displayContentToString, debugLogger, } from '@google/gemini-cli-core'; @@ -470,7 +471,8 @@ export async function runNonInteractive({ case 'tool_response': { textOutput.ensureTrailingNewline(); if (streamFormatter) { - const displayText = getTextContent(event.displayContent); + const display = event.display?.result; + const displayText = displayContentToString(display); const errorMsg = getTextContent(event.content) ?? 'Tool error'; streamFormatter.emitEvent({ type: JsonStreamEventType.TOOL_RESULT, @@ -490,7 +492,8 @@ export async function runNonInteractive({ }); } if (event.isError) { - const displayText = getTextContent(event.displayContent); + const display = event.display?.result; + const displayText = displayContentToString(display); const errorMsg = getTextContent(event.content) ?? 'Tool error'; if (event.data?.['errorType'] === ToolErrorType.STOP_EXECUTION) { diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index bbc9576ff2..a9f786f11c 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -602,6 +602,7 @@ const mockUIActions: UIActions = { import { type TextBuffer } from '../ui/components/shared/text-buffer.js'; import { InputContext, type InputState } from '../ui/contexts/InputContext.js'; +import { QuotaContext, type QuotaState } from '../ui/contexts/QuotaContext.js'; let capturedOverflowState: OverflowState | undefined; let capturedOverflowActions: OverflowActions | undefined; @@ -619,6 +620,7 @@ export const renderWithProviders = async ( shellFocus = true, settings = mockSettings, uiState: providedUiState, + quotaState: providedQuotaState, inputState: providedInputState, width, mouseEventsEnabled = false, @@ -631,6 +633,7 @@ export const renderWithProviders = async ( shellFocus?: boolean; settings?: LoadedSettings; uiState?: Partial; + quotaState?: Partial; inputState?: Partial; width?: number; mouseEventsEnabled?: boolean; @@ -666,6 +669,16 @@ export const renderWithProviders = async ( }, ) as UIState; + const quotaState: QuotaState = { + userTier: undefined, + stats: undefined, + proQuotaRequest: null, + validationRequest: null, + overageMenuRequest: null, + emptyWalletRequest: null, + ...providedQuotaState, + }; + const inputState = { buffer: { text: '' } as unknown as TextBuffer, userMessages: [], @@ -727,65 +740,67 @@ export const renderWithProviders = async ( - - - - - - - - - - + + + + + + + + + - - - - - - - {comp} - - - - - - - - - - - - - - - - + + + + + + + + {comp} + + + + + + + + + + + + + + + + + diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index d78b56e11d..8f05b996dc 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -123,16 +123,19 @@ vi.mock('ink', async (importOriginal) => { }); 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 // so we can assert against them in our tests. let capturedUIState: UIState; let capturedInputState: InputState; +let capturedQuotaState: QuotaState; let capturedUIActions: UIActions; let capturedOverflowActions: OverflowActions; function TestContextConsumer() { capturedUIState = useContext(UIStateContext)!; capturedInputState = useContext(InputContext)!; + capturedQuotaState = useContext(QuotaContext)!; capturedUIActions = useContext(UIActionsContext)!; capturedOverflowActions = useOverflowActions()!; return null; @@ -1309,15 +1312,15 @@ describe('AppContainer State Management', () => { }); 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 const { unmount } = await act(async () => renderAppContainer()); // Assert that the context value is as expected - expect(capturedUIState.quota.proQuotaRequest).toBeNull(); + expect(capturedQuotaState.proQuotaRequest).toBeNull(); 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 const mockRequest = { failedModel: 'gemini-pro', @@ -1332,7 +1335,7 @@ describe('AppContainer State Management', () => { // Act: Render the container const { unmount } = await act(async () => renderAppContainer()); // Assert: The mock request is correctly passed through the context - expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest); + expect(capturedQuotaState.proQuotaRequest).toEqual(mockRequest); unmount(); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index eaf6fc3e75..f17ac0d756 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -25,6 +25,7 @@ import { import { App } from './App.js'; import { AppContext } from './contexts/AppContext.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; +import { QuotaContext } from './contexts/QuotaContext.js'; import { UIActionsContext, 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( () => ({ history: historyManager.history, @@ -2473,15 +2494,6 @@ Logging in with Google... Restarting Gemini CLI to continue. showApprovalModeIndicator, allowPlanMode, currentModel, - quota: { - userTier, - stats: quotaStats, - proQuotaRequest, - validationRequest, - // G1 AI Credits dialog state - overageMenuRequest, - emptyWalletRequest, - }, contextFileNames, errorCount, availableTerminalHeight, @@ -2592,12 +2604,6 @@ Logging in with Google... Restarting Gemini CLI to continue. queueErrorMessage, showApprovalModeIndicator, allowPlanMode, - userTier, - quotaStats, - proQuotaRequest, - validationRequest, - overageMenuRequest, - emptyWalletRequest, contextFileNames, errorCount, availableTerminalHeight, @@ -2816,34 +2822,36 @@ Logging in with Google... Restarting Gemini CLI to continue. return ( - - - - - + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + ); }; diff --git a/packages/cli/src/ui/ToolConfirmationFullFrame.test.tsx b/packages/cli/src/ui/ToolConfirmationFullFrame.test.tsx index c8456fb237..5fde51c429 100644 --- a/packages/cli/src/ui/ToolConfirmationFullFrame.test.tsx +++ b/packages/cli/src/ui/ToolConfirmationFullFrame.test.tsx @@ -11,9 +11,9 @@ import { CoreToolCallStatus, ApprovalMode, makeFakeConfig, + type SerializableConfirmationDetails, } from '@google/gemini-cli-core'; import { type UIState } from './contexts/UIStateContext.js'; -import type { SerializableConfirmationDetails } from '@google/gemini-cli-core'; import { act } from 'react'; import { StreamingState } from './types.js'; @@ -107,15 +107,6 @@ describe('Full Terminal Tool Confirmation Snapshot', () => { constrainHeight: true, isConfigInitialized: true, cleanUiDetailsVisible: true, - quota: { - userTier: 'PRO', - stats: { - limits: {}, - usage: {}, - }, - proQuotaRequest: null, - validationRequest: null, - }, pendingHistoryItems: [ { id: 2, @@ -145,6 +136,13 @@ describe('Full Terminal Tool Confirmation Snapshot', () => { const { waitUntilReady, lastFrame, generateSvg, unmount } = await renderWithProviders(, { uiState: mockUIState, + quotaState: { + userTier: 'PRO', + stats: { + remaining: 100, + limit: 1000, + }, + }, config: mockConfig, settings: createMockSettings({ merged: { diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 316b9a1780..8a7ca134a8 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -201,12 +201,6 @@ const createMockUIState = (overrides: Partial = {}): UIState => isBackgroundTaskVisible: false, embeddedShellFocused: false, showIsExpandableHint: false, - quota: { - userTier: undefined, - stats: undefined, - proQuotaRequest: null, - validationRequest: null, - }, ...overrides, }) as UIState; @@ -245,6 +239,7 @@ const createMockConfig = (overrides = {}): Config => ...overrides, }) as unknown as Config; +import { QuotaContext, type QuotaState } from '../contexts/QuotaContext.js'; import { InputContext, type InputState } from '../contexts/InputContext.js'; const renderComposer = async ( @@ -253,6 +248,7 @@ const renderComposer = async ( config = createMockConfig(), uiActions = createMockUIActions(), inputStateOverrides: Partial = {}, + quotaStateOverrides: Partial = {}, ) => { const inputState = { buffer: { text: '' } as unknown as TextBuffer, @@ -266,16 +262,28 @@ const renderComposer = async ( ...inputStateOverrides, }; + const quotaState: QuotaState = { + userTier: undefined, + stats: undefined, + proQuotaRequest: null, + validationRequest: null, + overageMenuRequest: null, + emptyWalletRequest: null, + ...quotaStateOverrides, + }; + const result = await render( - - - - - - - + + + + + + + + + , ); diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx index 31b28f5223..6acc76303c 100644 --- a/packages/cli/src/ui/components/DialogManager.test.tsx +++ b/packages/cli/src/ui/components/DialogManager.test.tsx @@ -9,6 +9,7 @@ import { DialogManager } from './DialogManager.js'; import { describe, it, expect, vi } from 'vitest'; import { Text } from 'ink'; import { type UIState } from '../contexts/UIStateContext.js'; +import { type QuotaState } from '../contexts/QuotaContext.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; import { type IdeInfo } from '@google/gemini-cli-core'; @@ -75,14 +76,6 @@ describe('DialogManager', () => { terminalWidth: 80, confirmUpdateExtensionRequests: [], showIdeRestartPrompt: false, - quota: { - userTier: undefined, - stats: undefined, - proQuotaRequest: null, - validationRequest: null, - overageMenuRequest: null, - emptyWalletRequest: null, - }, shouldShowIdePrompt: false, isFolderTrustDialogOpen: false, loopDetectionConfirmationRequest: null, @@ -112,7 +105,7 @@ describe('DialogManager', () => { unmount(); }); - const testCases: Array<[Partial, string]> = [ + const testCases: Array<[Partial, string, Partial?]> = [ [ { showIdeRestartPrompt: true, @@ -121,23 +114,17 @@ describe('DialogManager', () => { 'IdeTrustChangeDialog', ], [ + {}, + 'ProQuotaDialog', { - quota: { - userTier: undefined, - stats: undefined, - proQuotaRequest: { - failedModel: 'a', - fallbackModel: 'b', - message: 'c', - isTerminalQuotaError: false, - resolve: vi.fn(), - }, - validationRequest: null, - overageMenuRequest: null, - emptyWalletRequest: null, + proQuotaRequest: { + failedModel: 'a', + fallbackModel: 'b', + message: 'c', + isTerminalQuotaError: false, + resolve: vi.fn(), }, }, - 'ProQuotaDialog', ], [ { @@ -195,7 +182,11 @@ describe('DialogManager', () => { it.each(testCases)( 'renders %s when state is %o', - async (uiStateOverride, expectedComponent) => { + async ( + uiStateOverride: Partial, + expectedComponent: string, + quotaStateOverride?: Partial, + ) => { const { lastFrame, unmount } = await renderWithProviders( , { @@ -203,6 +194,7 @@ describe('DialogManager', () => { ...baseUiState, ...uiStateOverride, } as Partial as UIState, + quotaState: quotaStateOverride, }, ); expect(lastFrame()).toContain(expectedComponent); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index e7e23c834d..b231a62db5 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -27,6 +27,7 @@ import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js' import { ModelDialog } from './ModelDialog.js'; import { theme } from '../semantic-colors.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { useQuotaState } from '../contexts/QuotaContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; @@ -52,6 +53,7 @@ export const DialogManager = ({ const settings = useSettings(); const uiState = useUIState(); + const quotaState = useQuotaState(); const uiActions = useUIActions(); const { constrainHeight, @@ -74,54 +76,50 @@ export const DialogManager = ({ /> ); } - if (uiState.quota.proQuotaRequest) { + if (quotaState.proQuotaRequest) { return ( ); } - if (uiState.quota.validationRequest) { + if (quotaState.validationRequest) { return ( ); } - if (uiState.quota.overageMenuRequest) { + if (quotaState.overageMenuRequest) { return ( ); } - if (uiState.quota.emptyWalletRequest) { + if (quotaState.emptyWalletRequest) { return ( ); diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index bb2e0c5e4d..ab242928aa 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -267,21 +267,16 @@ describe('