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/.github/workflows/agent-session-drift-check.yml b/.github/workflows/agent-session-drift-check.yml new file mode 100644 index 0000000000..3601f5ab09 --- /dev/null +++ b/.github/workflows/agent-session-drift-check.yml @@ -0,0 +1,132 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +name: 'Agent Session Drift Check' + +on: + pull_request: + branches: + - 'main' + - 'release/**' + paths: + - 'packages/cli/src/nonInteractiveCli.ts' + - 'packages/cli/src/nonInteractiveCliAgentSession.ts' + +concurrency: + group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' + cancel-in-progress: true + +jobs: + check-drift: + name: 'Check Agent Session Drift' + runs-on: 'ubuntu-latest' + if: "github.repository == 'google-gemini/gemini-cli'" + permissions: + contents: 'read' + pull-requests: 'write' + steps: + - name: 'Detect drift and comment' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v8 + with: + script: |- + // === Pair configuration — append here to cover more pairs === + const PAIRS = [ + { + legacy: 'packages/cli/src/nonInteractiveCli.ts', + session: 'packages/cli/src/nonInteractiveCliAgentSession.ts', + label: 'non-interactive CLI', + }, + // Future pairs can be added here. Remember to also add both + // paths to the `paths:` filter at the top of this workflow. + // Example: + // { + // legacy: 'packages/core/src/agents/local-invocation.ts', + // session: 'packages/core/src/agents/local-session-invocation.ts', + // label: 'local subagent invocation', + // }, + ]; + // ============================================================ + + const prNumber = context.payload.pull_request.number; + const { owner, repo } = context.repo; + + // Use the API to list changed files — no checkout/git diff needed. + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + const changed = new Set(files.map((f) => f.filename)); + + const warnings = []; + for (const { legacy, session, label } of PAIRS) { + const legacyChanged = changed.has(legacy); + const sessionChanged = changed.has(session); + if (legacyChanged && !sessionChanged) { + warnings.push( + `**${label}**: \`${legacy}\` was modified but \`${session}\` was not.`, + ); + } else if (!legacyChanged && sessionChanged) { + warnings.push( + `**${label}**: \`${session}\` was modified but \`${legacy}\` was not.`, + ); + } + } + + const MARKER = ''; + + // Look up our existing drift comment (for upsert/cleanup). + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + const existing = comments.find( + (c) => c.user?.type === 'Bot' && c.body?.includes(MARKER), + ); + + if (warnings.length === 0) { + core.info('No drift detected.'); + // If drift was previously flagged and is now resolved, remove the comment. + if (existing) { + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: existing.id, + }); + core.info(`Deleted stale drift comment ${existing.id}.`); + } + return; + } + + const body = [ + MARKER, + '### ⚠️ Invocation Drift Warning', + '', + 'The following file pairs should generally be kept in sync during the AgentSession migration:', + '', + ...warnings.map((w) => `- ${w}`), + '', + 'If this is intentional (e.g., a bug fix specific to one implementation), you can ignore this comment.', + '', + '_This check will be removed once the legacy implementations are deleted._', + ].join('\n'); + + if (existing) { + core.info(`Updating existing drift comment ${existing.id}.`); + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + core.info('Creating new drift comment.'); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + } diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index 94215e4795..e6385ad4bb 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -183,7 +183,7 @@ jobs: needs: - 'merge_queue_skipper' - 'parse_run_context' - runs-on: 'macos-latest' + runs-on: 'macos-latest-large' if: | github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82e9194a02..0bc2cf03ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,7 +224,7 @@ jobs: test_mac: name: 'Test (Mac) - ${{ matrix.node-version }}, ${{ matrix.shard }}' - runs-on: 'macos-latest' + runs-on: 'macos-latest-large' needs: - 'merge_queue_skipper' if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" diff --git a/.github/workflows/deflake.yml b/.github/workflows/deflake.yml index 98635dbda7..cd61346ffa 100644 --- a/.github/workflows/deflake.yml +++ b/.github/workflows/deflake.yml @@ -77,7 +77,7 @@ jobs: deflake_e2e_mac: name: 'E2E Test (macOS)' - runs-on: 'macos-latest' + runs-on: 'macos-latest-large' if: "github.repository == 'google-gemini/gemini-cli'" steps: - name: 'Checkout' 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/changelogs/latest.md b/docs/changelogs/latest.md index 3184abf79d..bccbc4bd77 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.37.1 +# Latest stable release: v0.37.2 -Released: April 09, 2026 +Released: April 13, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -26,6 +26,9 @@ npm install -g @google/gemini-cli ## What's Changed +- fix(patch): cherry-pick 9d741ab to release/v0.37.1-pr-24565 to patch version + v0.37.1 and create version 0.37.2 by @gemini-cli-robot in + [#25322](https://github.com/google-gemini/gemini-cli/pull/25322) - fix(acp): handle all InvalidStreamError types gracefully in prompt [#24540](https://github.com/google-gemini/gemini-cli/pull/24540) - feat(acp): add support for /about command @@ -422,4 +425,4 @@ npm install -g @google/gemini-cli [#24842](https://github.com/google-gemini/gemini-cli/pull/24842) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.1 +https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.2 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/core/subagents.md b/docs/core/subagents.md index a31cdfd324..d2069b5b12 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -87,11 +87,23 @@ Gemini CLI comes with the following built-in subagents: ### Generalist Agent -- **Name:** `generalist_agent` -- **Purpose:** Route tasks to the appropriate specialized subagent. -- **When to use:** Implicitly used by the main agent for routing. Not directly - invoked by the user. -- **Configuration:** Enabled by default. No specific configuration options. +- **Name:** `generalist` +- **Purpose:** A general, all-purpose subagent that uses the inherited tool + access and configurations from the main agent. Useful for executing broad, + resource-heavy subtasks in an isolated conversation, optimizing your main + agent's context by returning only the final result of that given task. +- **When to use:** Use this agent when a task requires many steps, handles large + volumes of information, or requires the same full capabilities as the main + agent. It is ideal for: + - **Multi-file modifications:** Applying refactors or fixing errors across + several files at once. + - **High-volume execution:** Running commands or tests that produce extensive + terminal output. + - **Action-oriented research:** Investigations where the agent needs to both + search code and run commands or make edits to find a solution. By delegating + these tasks, you prevent your main conversation from becoming cluttered or + slow. You can invoke it explicitly using `@generalist`. +- **Configuration:** Enabled by default. ### Browser Agent (experimental) 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 e46abcfc57..5d225e6ad1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10957,6 +10957,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", @@ -18306,6 +18318,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/extensions/github_fetch.test.ts b/packages/cli/src/config/extensions/github_fetch.test.ts index fe6edbedb2..c1eca8c9c7 100644 --- a/packages/cli/src/config/extensions/github_fetch.test.ts +++ b/packages/cli/src/config/extensions/github_fetch.test.ts @@ -62,6 +62,7 @@ describe('fetchJson', () => { const res = new EventEmitter() as IncomingMessage; res.statusCode = 302; res.headers = { location: 'https://example.com/final' }; + res.resume = vi.fn(); (callback as (res: IncomingMessage) => void)(res); res.emit('end'); return new EventEmitter() as ClientRequest; @@ -85,6 +86,7 @@ describe('fetchJson', () => { const res = new EventEmitter() as IncomingMessage; res.statusCode = 301; res.headers = { location: 'https://example.com/final-permanent' }; + res.resume = vi.fn(); (callback as (res: IncomingMessage) => void)(res); res.emit('end'); return new EventEmitter() as ClientRequest; diff --git a/packages/cli/src/config/extensions/github_fetch.ts b/packages/cli/src/config/extensions/github_fetch.ts index 33a9cb674f..7afd235605 100644 --- a/packages/cli/src/config/extensions/github_fetch.ts +++ b/packages/cli/src/config/extensions/github_fetch.ts @@ -31,7 +31,11 @@ export async function fetchJson( if (!res.headers.location) { return reject(new Error('No location header in redirect response')); } - fetchJson(res.headers.location, redirectCount++) + res.resume(); + fetchJson( + new URL(res.headers.location, url).toString(), + redirectCount + 1, + ) .then(resolve) .catch(reject); return; 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/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index d46e0295a1..12d01fac4c 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { ApiAuthDialog } from './ApiAuthDialog.js'; @@ -40,11 +40,16 @@ vi.mock('../components/shared/text-buffer.js', async (importOriginal) => { }; }); -vi.mock('../contexts/UIStateContext.js', () => ({ - useUIState: vi.fn(() => ({ - terminalWidth: 80, - })), -})); +vi.mock('../contexts/UIStateContext.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useUIState: vi.fn(() => ({ + terminalWidth: 80, + })), + }; +}); const mockedUseKeypress = useKeypress as Mock; const mockedUseTextBuffer = useTextBuffer as Mock; @@ -73,7 +78,7 @@ describe('ApiAuthDialog', () => { }); it('renders correctly', async () => { - const { lastFrame, unmount } = await render( + const { lastFrame, unmount } = await renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); @@ -81,7 +86,7 @@ describe('ApiAuthDialog', () => { }); it('renders with a defaultValue', async () => { - const { unmount } = await render( + const { unmount } = await renderWithProviders( { 'calls $expectedCall.name when $keyName is pressed', async ({ keyName, sequence, expectedCall, args }) => { mockBuffer.text = 'submitted-key'; // Set for the onSubmit case - const { unmount } = await render( + const { unmount } = await renderWithProviders( , ); // calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler) @@ -133,7 +138,7 @@ describe('ApiAuthDialog', () => { ); it('displays an error message', async () => { - const { lastFrame, unmount } = await render( + const { lastFrame, unmount } = await renderWithProviders( { }); it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => { - const { unmount } = await render( + const { unmount } = await renderWithProviders( , ); // Call 0 is ApiAuthDialog (isActive: true) diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 295d54eb73..7e1dbf9c00 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -13,7 +13,8 @@ import { useReducer, useContext, } from 'react'; -import { Box, Text } from 'ink'; +import { Box, Text, type DOMElement } from 'ink'; +import { useMouseClick } from '../hooks/useMouseClick.js'; import { theme } from '../semantic-colors.js'; import { checkExhaustive, type Question } from '@google/gemini-cli-core'; import { BaseSelectionList } from './shared/BaseSelectionList.js'; @@ -85,6 +86,24 @@ function autoBoldIfPlain(text: string): string { return text; } +const ClickableCheckbox: React.FC<{ + isChecked: boolean; + onClick: () => void; +}> = ({ isChecked, onClick }) => { + const ref = useRef(null); + useMouseClick(ref, () => { + onClick(); + }); + + return ( + + + [{isChecked ? 'x' : ' '}] + + + ); +}; + interface AskUserDialogState { answers: { [key: string]: string }; isEditingCustomOption: boolean; @@ -919,13 +938,14 @@ const ChoiceQuestionView: React.FC = ({ return ( {showCheck && ( - - [{isChecked ? 'x' : ' '}] - + { + if (!context.isSelected) { + handleSelect(optionItem); + } + }} + /> )} = ({ {showCheck && ( - - [{isChecked ? 'x' : ' '}] - + { + if (!context.isSelected) { + handleSelect(optionItem); + } + }} + /> )} {' '} 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('