diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..611f2e090854e91c63d06a8575c6e598b89dce41 --- /dev/null +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -0,0 +1,152 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`App > Snapshots > renders default layout correctly 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v1.2.3 + + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results + + + + + + + + + + + + + + + + + + + + + +Notifications + +Composer +" +`; + +exports[`App > Snapshots > renders screen reader layout correctly 1`] = ` +"Notifications +Footer + + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v1.2.3 + + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results +Composer +" +`; + +exports[`App > Snapshots > renders with dialogs visible 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v1.2.3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Notifications + +DialogManager +" +`; + +exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v1.2.3 + + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results +HistoryItemDisplay +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Action Required │ +│ │ +│ ? ls list directory │ +│ │ +│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ ls │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ Allow execution of [ls]? │ +│ │ +│ ● 1. Allow once │ +│ 2. Allow for this session │ +│ 3. No, suggest changes (esc) │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + +Notifications + +Composer +" +`; diff --git a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg new file mode 100644 index 0000000000000000000000000000000000000000..ac1af5663e473d04436e178d81d4056ec4124774 --- /dev/null +++ b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg @@ -0,0 +1,294 @@ + + + + + + + > + + Can you edit InputPrompt.tsx for me? + + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ + ? Edit + packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… + │ + │ + ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ + │ + │ + │ + ... first 42 lines hidden (Ctrl+O to show) ... + │ + │ + │ + │ + 43 + const + line43 + = + true + ; + │ + │ + │ + │ + 44 + const + line44 + = + true + ; + │ + │ + │ + │ + 45 + const + line45 + = + true + ; + │ + │ + │ + │ + 46 + const + line46 + = + true + ; + │ + │ + │ + │ + 47 + const + line47 + = + true + ; + │ + │▄ + │ + │ + 48 + const + line48 + = + true + ; + │ + │█ + │ + │ + 49 + const + line49 + = + true + ; + │ + │█ + │ + │ + 50 + const + line50 + = + true + ; + │ + │█ + │ + │ + 51 + const + line51 + = + true + ; + │ + │█ + │ + │ + 52 + const + line52 + = + true + ; + │ + │█ + │ + │ + 53 + const + line53 + = + true + ; + │ + │█ + │ + │ + 54 + const + line54 + = + true + ; + │ + │█ + │ + │ + 55 + const + line55 + = + true + ; + │ + │█ + │ + │ + 56 + const + line56 + = + true + ; + │ + │█ + │ + │ + 57 + const + line57 + = + true + ; + │ + │█ + │ + │ + 58 + const + line58 + = + true + ; + │ + │█ + │ + │ + 59 + const + line59 + = + true + ; + │ + │█ + │ + │ + 60 + const + line60 + = + true + ; + │ + │█ + │ + │ + + 61 + + + - + + + + return + + kittyProtocolSupporte...; + │ + │█ + │ + │ + + 61 + + + + + + + + return + + kittyProtocolSupporte...; + │ + │█ + │ + │ + 62 + buffer: TextBuffer; + │ + │█ + │ + │ + 63 + onSubmit + : ( + value + : + string + ) => + void + ; + │ + │█ + │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ + │█ + │ + Apply this change? + │█ + │ + │█ + │ + + ● + + + 1. + + + Allow once + + │█ + │ + 2. + Allow for this session + │█ + │ + 3. + Allow for this file in all future sessions + ~/.gemini/policies/auto-saved.toml + │█ + │ + 4. + Modify with external editor + │█ + │ + 5. + No, suggest changes (esc) + │█ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯█ + + \ No newline at end of file diff --git a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..0eae00fab2d809db8ab2daa3835341f44cccc585 --- /dev/null +++ b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap @@ -0,0 +1,43 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = ` +" > Can you edit InputPrompt.tsx for me? +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + +╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │ +│ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ ... first 42 lines hidden (Ctrl+O to show) ... │ │ +│ │ 43 const line43 = true; │ │ +│ │ 44 const line44 = true; │ │ +│ │ 45 const line45 = true; │ │ +│ │ 46 const line46 = true; │ │ +│ │ 47 const line47 = true; │ │▄ +│ │ 48 const line48 = true; │ │█ +│ │ 49 const line49 = true; │ │█ +│ │ 50 const line50 = true; │ │█ +│ │ 51 const line51 = true; │ │█ +│ │ 52 const line52 = true; │ │█ +│ │ 53 const line53 = true; │ │█ +│ │ 54 const line54 = true; │ │█ +│ │ 55 const line55 = true; │ │█ +│ │ 56 const line56 = true; │ │█ +│ │ 57 const line57 = true; │ │█ +│ │ 58 const line58 = true; │ │█ +│ │ 59 const line59 = true; │ │█ +│ │ 60 const line60 = true; │ │█ +│ │ 61 - return kittyProtocolSupporte...; │ │█ +│ │ 61 + return kittyProtocolSupporte...; │ │█ +│ │ 62 buffer: TextBuffer; │ │█ +│ │ 63 onSubmit: (value: string) => void; │ │█ +│ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ │█ +│ Apply this change? │█ +│ │█ +│ ● 1. Allow once │█ +│ 2. Allow for this session │█ +│ 3. Allow for this file in all future sessions ~/.gemini/policies/auto-saved.toml │█ +│ 4. Modify with external editor │█ +│ 5. No, suggest changes (esc) │█ +╰─────────────────────────────────────────────────────────────────────────────────────────────────╯█ +" +`; diff --git a/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..7a2057c80b0fe44c5b498bd314dc3cd3c96c7352 --- /dev/null +++ b/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap @@ -0,0 +1,19 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ApiAuthDialog > renders correctly 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Enter Gemini API Key │ +│ │ +│ Please enter your Gemini API key. It will be securely stored in your system keychain. │ +│ You can get an API key from https://aistudio.google.com/app/apikey │ +│ │ +│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ Paste your API key here │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..05bc9f422ebc3960cc0b846cb26152ab0e943943 --- /dev/null +++ b/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap @@ -0,0 +1,60 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ ? Get started │ +│ │ +│ How would you like to authenticate for this project? │ +│ │ +│ (selected) Sign in with Google(not selected) Use Gemini API Key(not selected) Vertex AI │ +│ │ +│ Something went wrong │ +│ │ +│ (Use Enter to select) │ +│ │ +│ Terms of Services and Privacy Notice for Gemini CLI │ +│ │ +│ https://geminicli.com/docs/resources/tos-privacy/ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ ? Get started │ +│ │ +│ How would you like to authenticate for this project? │ +│ │ +│ (selected) Sign in with Google(not selected) Use Gemini API Key(not selected) Vertex AI │ +│ │ +│ (Use Enter to select) │ +│ │ +│ Terms of Services and Privacy Notice for Gemini CLI │ +│ │ +│ https://geminicli.com/docs/resources/tos-privacy/ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ ? Get started │ +│ │ +│ How would you like to authenticate for this project? │ +│ │ +│ (selected) Use Gemini API Key │ +│ │ +│ (Use Enter to select) │ +│ │ +│ Terms of Services and Privacy Notice for Gemini CLI │ +│ │ +│ https://geminicli.com/docs/resources/tos-privacy/ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..b95994692d4b585084aa3dd24ca62702b38fd36c --- /dev/null +++ b/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`BannedAccountDialog > renders snapshot correctly 1`] = ` +" + Error: Account Suspended + + This service has been disabled in this account for violation of Terms of Service. Please submit an + appeal to continue using this product. + + Appeal URL: + [https://example.com/appeal] + + Appeal HereChange authenticationExit + + Escape to exit +" +`; diff --git a/packages/cli/src/ui/auth/__snapshots__/LoginRestartDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/LoginRestartDialog.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..33beb0e9d5d18115241f406dc98e401f4b4833d5 --- /dev/null +++ b/packages/cli/src/ui/auth/__snapshots__/LoginRestartDialog.test.tsx.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`LoginRestartDialog > renders correctly with custom message 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Authenticating to Vertex AI in Cloud Shell requires a restart to apply project settings. │ +│ Press R to restart, or Esc to choose a different authentication method. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`LoginRestartDialog > renders correctly with default message 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ You've successfully signed in with Google. Gemini CLI needs to be restarted. │ +│ Press R to restart, or Esc to choose a different authentication method. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/commands/__snapshots__/commandsCommand.test.ts.snap b/packages/cli/src/ui/commands/__snapshots__/commandsCommand.test.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..ef3877d4a62153081faf426fc47866c73ea5ecaf --- /dev/null +++ b/packages/cli/src/ui/commands/__snapshots__/commandsCommand.test.ts.snap @@ -0,0 +1,12 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`commandsCommand > list > should list .toml files from available sources 1`] = ` +"### User Commands (/mock/user/commands) +- user1.toml +### Project Commands (/mock/project/commands) +- proj1.toml +### Extension: ext1 Commands (/mock/ext1/commands) +- ext1.toml + +_Note: MCP prompts are dynamically loaded from configured MCP servers._" +`; diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fa1f709ba1b5567e6dd83eaa92ee4115ed46ac4 --- /dev/null +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -0,0 +1,181 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { aboutCommand } from './aboutCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; +import { IdeClient, getVersion } from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + IdeClient: { + getInstance: vi.fn().mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'), + }), + }, + UserAccountManager: vi.fn().mockImplementation(() => ({ + getCachedGoogleAccount: vi.fn().mockReturnValue('test-email@example.com'), + })), + getVersion: vi.fn(), + }; +}); + +describe('aboutCommand', () => { + let mockContext: CommandContext; + const originalPlatform = process.platform; + const originalEnv = { ...process.env }; + + beforeEach(() => { + mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + getModel: vi.fn(), + getIdeMode: vi.fn().mockReturnValue(true), + getUserTierName: vi.fn().mockReturnValue(undefined), + }, + }, + settings: { + merged: { + security: { + auth: { + selectedType: 'test-auth', + }, + }, + }, + }, + }, + ui: { + addItem: vi.fn(), + }, + } as unknown as CommandContext); + + vi.mocked(getVersion).mockResolvedValue('test-version'); + vi.spyOn( + mockContext.services.agentContext!.config, + 'getModel', + ).mockReturnValue('test-model'); + process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project'; + Object.defineProperty(process, 'platform', { + value: 'test-os', + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + process.env = originalEnv; + vi.clearAllMocks(); + }); + + it('should have the correct name and description', () => { + expect(aboutCommand.name).toBe('about'); + expect(aboutCommand.description).toBe('Show version info'); + }); + + it('should call addItem with all version info', async () => { + process.env['SANDBOX'] = ''; + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + await aboutCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ABOUT, + cliVersion: 'test-version', + osVersion: 'test-os', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + gcpProject: 'test-gcp-project', + ideClient: 'test-ide', + userEmail: 'test-email@example.com', + tier: undefined, + }); + }); + + it('should show the correct sandbox environment variable', async () => { + process.env['SANDBOX'] = 'gemini-sandbox'; + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + await aboutCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + sandboxEnv: 'gemini-sandbox', + }), + ); + }); + + it('should show sandbox-exec profile when applicable', async () => { + process.env['SANDBOX'] = 'sandbox-exec'; + process.env['SEATBELT_PROFILE'] = 'test-profile'; + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + await aboutCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + sandboxEnv: 'sandbox-exec (test-profile)', + }), + ); + }); + + it('should not show ide client when it is not detected', async () => { + vi.mocked(IdeClient.getInstance).mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined), + } as unknown as IdeClient); + + process.env['SANDBOX'] = ''; + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + await aboutCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ABOUT, + cliVersion: 'test-version', + osVersion: 'test-os', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + gcpProject: 'test-gcp-project', + ideClient: '', + }), + ); + }); + + it('should display the tier when getUserTierName returns a value', async () => { + vi.mocked( + mockContext.services.agentContext!.config.getUserTierName, + ).mockReturnValue('Enterprise Tier'); + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + await aboutCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + tier: 'Enterprise Tier', + }), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b436d69b86bb4cc8edbc8105f0e9f6382af07c8 --- /dev/null +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type CommandContext, + type SlashCommand, +} from './types.js'; +import process from 'node:process'; +import { MessageType, type HistoryItemAbout } from '../types.js'; +import { + IdeClient, + UserAccountManager, + debugLogger, + getVersion, +} from '@google/gemini-cli-core'; + +export const aboutCommand: SlashCommand = { + name: 'about', + description: 'Show version info', + kind: CommandKind.BUILT_IN, + autoExecute: true, + isSafeConcurrent: true, + action: async (context) => { + const osVersion = process.platform; + let sandboxEnv = 'no sandbox'; + if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { + sandboxEnv = process.env['SANDBOX']; + } else if (process.env['SANDBOX'] === 'sandbox-exec') { + sandboxEnv = `sandbox-exec (${ + process.env['SEATBELT_PROFILE'] || 'unknown' + })`; + } + const modelVersion = + context.services.agentContext?.config.getModel() || 'Unknown'; + const cliVersion = await getVersion(); + const selectedAuthType = + context.services.settings.merged.security.auth.selectedType || ''; + const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || ''; + const ideClient = await getIdeClientName(context); + + const userAccountManager = new UserAccountManager(); + const cachedAccount = userAccountManager.getCachedGoogleAccount(); + debugLogger.log('AboutCommand: Retrieved cached Google account', { + cachedAccount, + }); + const userEmail = cachedAccount ?? undefined; + + const tier = context.services.agentContext?.config.getUserTierName(); + + const aboutItem: Omit = { + type: MessageType.ABOUT, + cliVersion, + osVersion, + sandboxEnv, + modelVersion, + selectedAuthType, + gcpProject, + ideClient, + userEmail, + tier, + }; + + context.ui.addItem(aboutItem); + }, +}; + +async function getIdeClientName(context: CommandContext) { + if (!context.services.agentContext?.config.getIdeMode()) { + return ''; + } + const ideClient = await IdeClient.getInstance(); + return ideClient?.getDetectedIdeDisplayName() ?? ''; +} diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..68874ffbe8c62c9c9c2466e316e4140bc7e0ea83 --- /dev/null +++ b/packages/cli/src/ui/commands/agentsCommand.test.ts @@ -0,0 +1,514 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { agentsCommand } from './agentsCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { Config } from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { MessageType } from '../types.js'; +import { enableAgent, disableAgent } from '../../utils/agentSettings.js'; +import { renderAgentActionFeedback } from '../../utils/agentUtils.js'; + +vi.mock('../../utils/agentSettings.js', () => ({ + enableAgent: vi.fn(), + disableAgent: vi.fn(), +})); + +vi.mock('../../utils/agentUtils.js', () => ({ + renderAgentActionFeedback: vi.fn(), +})); + +describe('agentsCommand', () => { + let mockContext: ReturnType; + let mockConfig: { + getAgentRegistry: ReturnType; + config: Config; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockConfig = { + getAgentRegistry: vi.fn().mockReturnValue({ + getAllDefinitions: vi.fn().mockReturnValue([]), + getAllAgentNames: vi.fn().mockReturnValue([]), + reload: vi.fn(), + }), + get config() { + return this as unknown as Config; + }, + }; + + mockContext = createMockCommandContext({ + services: { + agentContext: mockConfig as unknown as Config, + settings: { + workspace: { path: '/mock/path' }, + merged: { agents: { overrides: {} } }, + } as unknown as LoadedSettings, + }, + }); + }); + + it('should show an error if config is not available', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + agentContext: null, + }, + }); + + const result = await agentsCommand.action!(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should show an error if agent registry is not available', async () => { + mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined); + + const result = await agentsCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }); + }); + + it('should call addItem with correct agents list', async () => { + const mockAgents = [ + { + name: 'agent1', + displayName: 'Agent One', + description: 'desc1', + kind: 'local', + }, + { + name: 'agent2', + displayName: undefined, + description: 'desc2', + kind: 'remote', + }, + ]; + mockConfig.getAgentRegistry().getAllDefinitions.mockReturnValue(mockAgents); + + await agentsCommand.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.AGENTS_LIST, + agents: mockAgents, + }), + ); + }); + + it('should reload the agent registry when reload subcommand is called', async () => { + const reloadSpy = vi.fn().mockResolvedValue({ + totalLoaded: 3, + localCount: 2, + remoteCount: 1, + newAgents: ['new-agent'], + updatedAgents: ['updated-agent'], + deletedAgents: ['deleted-agent'], + errors: [], + }); + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + reload: reloadSpy, + }); + + const reloadCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'reload', + ); + expect(reloadCommand).toBeDefined(); + + const result = (await reloadCommand!.action!(mockContext, '')) as { + type: 'message'; + content: string; + }; + + expect(reloadSpy).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Reloading agent registry...', + }), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Agents reloaded successfully:'), + }); + expect(result.content).toContain('- Total: 3 (2 local, 1 remote)'); + expect(result.content).toContain('- New: new-agent'); + expect(result.content).toContain('- Updated: updated-agent'); + expect(result.content).toContain('- Deleted: deleted-agent'); + expect(result.content).toContain( + 'Run /agents list to see all available agents.', + ); + }); + + it('should show "reloaded with errors" if errors occurred during reload', async () => { + const reloadSpy = vi.fn().mockResolvedValue({ + totalLoaded: 1, + localCount: 1, + remoteCount: 0, + newAgents: [], + updatedAgents: [], + deletedAgents: [], + errors: ['Some error'], + }); + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + reload: reloadSpy, + }); + + const reloadCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'reload', + ); + + const result = (await reloadCommand!.action!(mockContext, '')) as { + type: 'message'; + content: string; + }; + + expect(result.content).toContain('Agents reloaded with errors:'); + expect(result.content).toContain('- Errors: 1 encountered during reload'); + }); + + it('should show an error if agent registry is not available during reload', async () => { + mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined); + + const reloadCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'reload', + ); + const result = await reloadCommand!.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }); + }); + + it('should enable an agent successfully', async () => { + const reloadSpy = vi.fn().mockResolvedValue(undefined); + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getAllAgentNames: vi.fn().mockReturnValue([]), + reload: reloadSpy, + }); + // Add agent to disabled overrides so validation passes + mockContext.services.settings.merged.agents.overrides['test-agent'] = { + enabled: false, + }; + + vi.mocked(enableAgent).mockReturnValue({ + status: 'success', + agentName: 'test-agent', + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: [], + }); + vi.mocked(renderAgentActionFeedback).mockReturnValue('Enabled test-agent.'); + + const enableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'enable', + ); + expect(enableCommand).toBeDefined(); + + const result = await enableCommand!.action!(mockContext, 'test-agent'); + + expect(enableAgent).toHaveBeenCalledWith( + mockContext.services.settings, + 'test-agent', + ); + expect(reloadSpy).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Enabling test-agent...', + }), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Enabled test-agent.', + }); + }); + + it('should handle no-op when enabling an agent', async () => { + mockConfig + .getAgentRegistry() + .getAllAgentNames.mockReturnValue(['test-agent']); + + const enableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'enable', + ); + const result = await enableCommand!.action!(mockContext, 'test-agent'); + + expect(enableAgent).not.toHaveBeenCalled(); + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: "Agent 'test-agent' is already enabled.", + }); + }); + + it('should show usage error if no agent name provided for enable', async () => { + const enableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'enable', + ); + const result = await enableCommand!.action!(mockContext, ' '); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /agents enable ', + }); + }); + + it('should show an error if config is not available for enable', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { agentContext: null }, + }); + const enableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'enable', + ); + const result = await enableCommand!.action!(contextWithoutConfig, 'test'); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should disable an agent successfully', async () => { + const reloadSpy = vi.fn().mockResolvedValue(undefined); + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getAllAgentNames: vi.fn().mockReturnValue(['test-agent']), + reload: reloadSpy, + }); + vi.mocked(disableAgent).mockReturnValue({ + status: 'success', + agentName: 'test-agent', + action: 'disable', + modifiedScopes: [], + alreadyInStateScopes: [], + }); + vi.mocked(renderAgentActionFeedback).mockReturnValue( + 'Disabled test-agent.', + ); + + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + expect(disableCommand).toBeDefined(); + + const result = await disableCommand!.action!(mockContext, 'test-agent'); + + expect(disableAgent).toHaveBeenCalledWith( + mockContext.services.settings, + 'test-agent', + expect.anything(), // Scope is derived in the command + ); + expect(reloadSpy).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Disabling test-agent...', + }), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Disabled test-agent.', + }); + }); + + it('should show info message if agent is already disabled', async () => { + mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]); + mockContext.services.settings.merged.agents.overrides['test-agent'] = { + enabled: false, + }; + + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + const result = await disableCommand!.action!(mockContext, 'test-agent'); + + expect(disableAgent).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: "Agent 'test-agent' is already disabled.", + }); + }); + + it('should show error if agent is not found when disabling', async () => { + mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]); + + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + const result = await disableCommand!.action!(mockContext, 'test-agent'); + + expect(disableAgent).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: "Agent 'test-agent' not found.", + }); + }); + + it('should show usage error if no agent name provided for disable', async () => { + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + const result = await disableCommand!.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /agents disable ', + }); + }); + + it('should show an error if config is not available for disable', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { agentContext: null }, + }); + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + const result = await disableCommand!.action!(contextWithoutConfig, 'test'); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + describe('config sub-command', () => { + it('should return dialog action for a valid agent', async () => { + const mockDefinition = { + name: 'test-agent', + displayName: 'Test Agent', + description: 'test desc', + kind: 'local', + }; + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition), + }); + + const configCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'config', + ); + expect(configCommand).toBeDefined(); + + const result = await configCommand!.action!(mockContext, 'test-agent'); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'agentConfig', + props: { + name: 'test-agent', + displayName: 'Test Agent', + definition: mockDefinition, + }, + }); + }); + + it('should use name as displayName if displayName is missing', async () => { + const mockDefinition = { + name: 'test-agent', + description: 'test desc', + kind: 'local', + }; + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition), + }); + + const configCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'config', + ); + const result = await configCommand!.action!(mockContext, 'test-agent'); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'agentConfig', + props: { + name: 'test-agent', + displayName: 'test-agent', // Falls back to name + definition: mockDefinition, + }, + }); + }); + + it('should show error if agent is not found', async () => { + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getDiscoveredDefinition: vi.fn().mockReturnValue(undefined), + }); + + const configCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'config', + ); + const result = await configCommand!.action!(mockContext, 'non-existent'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: "Agent 'non-existent' not found.", + }); + }); + + it('should show usage error if no agent name provided', async () => { + const configCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'config', + ); + const result = await configCommand!.action!(mockContext, ' '); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /agents config ', + }); + }); + + it('should show an error if config is not available', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { agentContext: null }, + }); + const configCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'config', + ); + const result = await configCommand!.action!(contextWithoutConfig, 'test'); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should provide completions for discovered agents', async () => { + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getAllDiscoveredAgentNames: vi + .fn() + .mockReturnValue(['agent1', 'agent2', 'other']), + }); + + const configCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'config', + ); + expect(configCommand?.completion).toBeDefined(); + + const completions = await configCommand!.completion!(mockContext, 'age'); + expect(completions).toEqual(['agent1', 'agent2']); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..4af6564979c705261393a510b3d4106d004b63cb --- /dev/null +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -0,0 +1,394 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + SlashCommand, + CommandContext, + SlashCommandActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { MessageType, type HistoryItemAgentsList } from '../types.js'; +import { SettingScope } from '../../config/settings.js'; +import { disableAgent, enableAgent } from '../../utils/agentSettings.js'; +import { renderAgentActionFeedback } from '../../utils/agentUtils.js'; + +const agentsListCommand: SlashCommand = { + name: 'list', + description: 'List available local and remote agents', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext) => { + const config = context.services.agentContext?.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const agentRegistry = config.getAgentRegistry(); + if (!agentRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }; + } + + const agents = agentRegistry.getAllDefinitions().map((def) => ({ + name: def.name, + displayName: def.displayName, + description: def.description, + kind: def.kind, + })); + + const agentsListItem: HistoryItemAgentsList = { + type: MessageType.AGENTS_LIST, + agents, + }; + + context.ui.addItem(agentsListItem); + + return; + }, +}; + +async function enableAction( + context: CommandContext, + args: string, +): Promise { + const config = context.services.agentContext?.config; + const { settings } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const agentName = args.trim(); + if (!agentName) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /agents enable ', + }; + } + + const agentRegistry = config.getAgentRegistry(); + if (!agentRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }; + } + + const allAgents = agentRegistry.getAllAgentNames(); + const overrides = settings.merged.agents.overrides; + const disabledAgents = Object.keys(overrides).filter( + (name) => overrides[name]?.enabled === false, + ); + + if (allAgents.includes(agentName) && !disabledAgents.includes(agentName)) { + return { + type: 'message', + messageType: 'info', + content: `Agent '${agentName}' is already enabled.`, + }; + } + + if (!disabledAgents.includes(agentName) && !allAgents.includes(agentName)) { + return { + type: 'message', + messageType: 'error', + content: `Agent '${agentName}' not found.`, + }; + } + + const result = enableAgent(settings, agentName); + + if (result.status === 'no-op') { + return { + type: 'message', + messageType: 'info', + content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), + }; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: `Enabling ${agentName}...`, + }); + await agentRegistry.reload(); + + return { + type: 'message', + messageType: 'info', + content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), + }; +} + +async function disableAction( + context: CommandContext, + args: string, +): Promise { + const config = context.services.agentContext?.config; + const { settings } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const agentName = args.trim(); + if (!agentName) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /agents disable ', + }; + } + + const agentRegistry = config.getAgentRegistry(); + if (!agentRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }; + } + + const allAgents = agentRegistry.getAllAgentNames(); + const overrides = settings.merged.agents.overrides; + const disabledAgents = Object.keys(overrides).filter( + (name) => overrides[name]?.enabled === false, + ); + + if (disabledAgents.includes(agentName)) { + return { + type: 'message', + messageType: 'info', + content: `Agent '${agentName}' is already disabled.`, + }; + } + + if (!allAgents.includes(agentName)) { + return { + type: 'message', + messageType: 'error', + content: `Agent '${agentName}' not found.`, + }; + } + + const scope = context.services.settings.workspace.path + ? SettingScope.Workspace + : SettingScope.User; + const result = disableAgent(settings, agentName, scope); + + if (result.status === 'no-op') { + return { + type: 'message', + messageType: 'info', + content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), + }; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: `Disabling ${agentName}...`, + }); + await agentRegistry.reload(); + + return { + type: 'message', + messageType: 'info', + content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), + }; +} + +async function configAction( + context: CommandContext, + args: string, +): Promise { + const config = context.services.agentContext?.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const agentName = args.trim(); + if (!agentName) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /agents config ', + }; + } + + const agentRegistry = config.getAgentRegistry(); + if (!agentRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }; + } + + const definition = agentRegistry.getDiscoveredDefinition(agentName); + if (!definition) { + return { + type: 'message', + messageType: 'error', + content: `Agent '${agentName}' not found.`, + }; + } + + const displayName = definition.displayName || agentName; + + return { + type: 'dialog', + dialog: 'agentConfig', + props: { + name: agentName, + displayName, + definition, + }, + }; +} + +function completeAgentsToEnable(context: CommandContext, partialArg: string) { + const config = context.services.agentContext?.config; + const { settings } = context.services; + if (!config) return []; + + const overrides = settings.merged.agents.overrides; + const disabledAgents = Object.entries(overrides) + .filter(([_, override]) => override?.enabled === false) + .map(([name]) => name); + + return disabledAgents.filter((name) => name.startsWith(partialArg)); +} + +function completeAgentsToDisable(context: CommandContext, partialArg: string) { + const config = context.services.agentContext?.config; + if (!config) return []; + + const agentRegistry = config.getAgentRegistry(); + const allAgents = agentRegistry ? agentRegistry.getAllAgentNames() : []; + return allAgents.filter((name: string) => name.startsWith(partialArg)); +} + +function completeAllAgents(context: CommandContext, partialArg: string) { + const config = context.services.agentContext?.config; + if (!config) return []; + + const agentRegistry = config.getAgentRegistry(); + const allAgents = agentRegistry?.getAllDiscoveredAgentNames() ?? []; + return allAgents.filter((name: string) => name.startsWith(partialArg)); +} + +const enableCommand: SlashCommand = { + name: 'enable', + description: 'Enable a disabled agent', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: enableAction, + completion: completeAgentsToEnable, +}; + +const disableCommand: SlashCommand = { + name: 'disable', + description: 'Disable an enabled agent', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: disableAction, + completion: completeAgentsToDisable, +}; + +const configCommand: SlashCommand = { + name: 'config', + description: 'Configure an agent', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: configAction, + completion: completeAllAgents, +}; + +const agentsReloadCommand: SlashCommand = { + name: 'reload', + altNames: ['refresh'], + description: 'Reload the agent registry', + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext) => { + const config = context.services.agentContext?.config; + const agentRegistry = config?.getAgentRegistry(); + if (!agentRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: 'Reloading agent registry...', + }); + + const summary = await agentRegistry.reload(); + + let content = + summary.errors.length > 0 + ? 'Agents reloaded with errors:' + : 'Agents reloaded successfully:'; + content += `\n- Total: ${summary.totalLoaded} (${summary.localCount} local, ${summary.remoteCount} remote)`; + + if (summary.newAgents.length > 0) { + content += `\n- New: ${summary.newAgents.join(', ')}`; + } + if (summary.updatedAgents.length > 0) { + content += `\n- Updated: ${summary.updatedAgents.join(', ')}`; + } + if (summary.deletedAgents.length > 0) { + content += `\n- Deleted: ${summary.deletedAgents.join(', ')}`; + } + if (summary.errors.length > 0) { + content += `\n- Errors: ${summary.errors.length} encountered during reload`; + } + + content += '\n\nRun /agents list to see all available agents.'; + + return { + type: 'message', + messageType: 'info', + content, + }; + }, +}; + +export const agentsCommand: SlashCommand = { + name: 'agents', + description: 'Manage agents', + kind: CommandKind.BUILT_IN, + subCommands: [ + agentsListCommand, + agentsReloadCommand, + enableCommand, + disableCommand, + configCommand, + ], + action: async (context: CommandContext, args) => + // Default to list if no subcommand is provided + agentsListCommand.action!(context, args), +}; diff --git a/packages/cli/src/ui/commands/authCommand.test.ts b/packages/cli/src/ui/commands/authCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff4f2ba6140f9f6ff3e1910bfb9490989efe4a33 --- /dev/null +++ b/packages/cli/src/ui/commands/authCommand.test.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { authCommand } from './authCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { SettingScope } from '../../config/settings.js'; +import type { GeminiClient } from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual('@google/gemini-cli-core'); + return { + ...actual, + clearCachedCredentialFile: vi.fn().mockResolvedValue(undefined), + }; +}); + +describe('authCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext({ + services: { + agentContext: { + geminiClient: { + stripThoughtsFromHistory: vi.fn(), + }, + }, + }, + }); + // Add setValue mock to settings + mockContext.services.settings.setValue = vi.fn(); + vi.clearAllMocks(); + }); + + it('should have subcommands: signin and signout', () => { + expect(authCommand.subCommands).toBeDefined(); + expect(authCommand.subCommands).toHaveLength(2); + expect(authCommand.subCommands?.[0]?.name).toBe('signin'); + expect(authCommand.subCommands?.[0]?.altNames).toContain('login'); + expect(authCommand.subCommands?.[1]?.name).toBe('signout'); + expect(authCommand.subCommands?.[1]?.altNames).toContain('logout'); + }); + + it('should return a dialog action to open the auth dialog when called with no args', () => { + if (!authCommand.action) { + throw new Error('The auth command must have an action.'); + } + + const result = authCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'auth', + }); + }); + + it('should have the correct name and description', () => { + expect(authCommand.name).toBe('auth'); + expect(authCommand.description).toBe('Manage authentication'); + }); + + describe('auth signin subcommand', () => { + it('should return auth dialog action', () => { + const loginCommand = authCommand.subCommands?.[0]; + expect(loginCommand?.name).toBe('signin'); + const result = loginCommand!.action!(mockContext, ''); + expect(result).toEqual({ type: 'dialog', dialog: 'auth' }); + }); + }); + + describe('auth signout subcommand', () => { + it('should clear cached credentials', async () => { + const logoutCommand = authCommand.subCommands?.[1]; + expect(logoutCommand?.name).toBe('signout'); + + const { clearCachedCredentialFile } = await import( + '@google/gemini-cli-core' + ); + + await logoutCommand!.action!(mockContext, ''); + + expect(clearCachedCredentialFile).toHaveBeenCalledOnce(); + }); + + it('should clear selectedAuthType setting', async () => { + const logoutCommand = authCommand.subCommands?.[1]; + + await logoutCommand!.action!(mockContext, ''); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.selectedType', + undefined, + ); + }); + + it('should strip thoughts from history', async () => { + const logoutCommand = authCommand.subCommands?.[1]; + const mockStripThoughts = vi.fn(); + const mockClient = { + stripThoughtsFromHistory: mockStripThoughts, + } as unknown as GeminiClient; + if (mockContext.services.agentContext?.config) { + mockContext.services.agentContext.config.getGeminiClient = vi.fn( + () => mockClient, + ); + } + + await logoutCommand!.action!(mockContext, ''); + + expect( + mockContext.services.agentContext?.geminiClient + .stripThoughtsFromHistory, + ).toHaveBeenCalled(); + }); + + it('should return logout action to signal explicit state change', async () => { + const logoutCommand = authCommand.subCommands?.[1]; + const result = await logoutCommand!.action!(mockContext, ''); + + expect(result).toEqual({ type: 'logout' }); + }); + + it('should handle missing config gracefully', async () => { + const logoutCommand = authCommand.subCommands?.[1]; + mockContext.services.agentContext = null; + + const result = await logoutCommand!.action!(mockContext, ''); + + expect(result).toEqual({ type: 'logout' }); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..084763058c86f7b672fd0c27e61b07dc2c3b8eed --- /dev/null +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + OpenDialogActionReturn, + SlashCommand, + LogoutActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { clearCachedCredentialFile } from '@google/gemini-cli-core'; +import { SettingScope } from '../../config/settings.js'; + +const authLoginCommand: SlashCommand = { + name: 'signin', + altNames: ['login'], + description: 'Sign in or change the authentication method', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (_context, _args): OpenDialogActionReturn => ({ + type: 'dialog', + dialog: 'auth', + }), +}; + +const authLogoutCommand: SlashCommand = { + name: 'signout', + altNames: ['logout'], + description: 'Sign out and clear all cached credentials', + kind: CommandKind.BUILT_IN, + action: async (context, _args): Promise => { + await clearCachedCredentialFile(); + // Clear the selected auth type so user sees the auth selection menu + context.services.settings.setValue( + SettingScope.User, + 'security.auth.selectedType', + undefined, + ); + // Strip thoughts from history instead of clearing completely + context.services.agentContext?.geminiClient.stripThoughtsFromHistory(); + // Return logout action to signal explicit state change + return { + type: 'logout', + }; + }, +}; + +export const authCommand: SlashCommand = { + name: 'auth', + description: 'Manage authentication', + kind: CommandKind.BUILT_IN, + subCommands: [authLoginCommand, authLogoutCommand], + action: (context, args) => + // Default to login if no subcommand is provided + authLoginCommand.action!(context, args), +}; diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a51c7af12c860010ba5a90aa77b1969a87de8a16 --- /dev/null +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -0,0 +1,344 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import open from 'open'; +import path from 'node:path'; +import { bugCommand } from './bugCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { getVersion, type Config } from '@google/gemini-cli-core'; +import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; +import { formatBytes } from '../utils/formatters.js'; +import { MessageType } from '../types.js'; +import { captureHeapSnapshot } from '../utils/memorySnapshot.js'; + +const { memoryUsageMock } = vi.hoisted(() => ({ + memoryUsageMock: vi.fn(() => ({ + rss: 0, + heapTotal: 0, + heapUsed: 0, + external: 0, + arrayBuffers: 0, + })), +})); + +// Mock dependencies +vi.mock('open'); +vi.mock('../utils/formatters.js'); +vi.mock('../utils/memorySnapshot.js', () => ({ + captureHeapSnapshot: vi.fn(), + MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024, +})); +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + stat: vi.fn().mockResolvedValue({ size: 4096 }), + }; +}); +vi.mock('../utils/historyExportUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + exportHistoryToFile: vi.fn(), + }; +}); +import { exportHistoryToFile } from '../utils/historyExportUtils.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + IdeClient: { + getInstance: () => ({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'), + }), + }, + sessionId: 'test-session-id', + getVersion: vi.fn(), + INITIAL_HISTORY_LENGTH: 1, + debugLogger: { + error: vi.fn(), + log: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + }, + }; +}); +vi.mock('node:process', () => ({ + default: { + platform: 'test-platform', + version: 'v20.0.0', + // Keep other necessary process properties if needed by other parts of the code + env: process.env, + memoryUsage: memoryUsageMock, + }, +})); + +vi.mock('../utils/terminalCapabilityManager.js', () => ({ + terminalCapabilityManager: { + getTerminalName: vi.fn().mockReturnValue('Test Terminal'), + getTerminalBackgroundColor: vi.fn().mockReturnValue('#000000'), + isKittyProtocolEnabled: vi.fn().mockReturnValue(true), + }, +})); + +describe('bugCommand', () => { + beforeEach(() => { + vi.mocked(getVersion).mockResolvedValue('0.1.0'); + vi.mocked(formatBytes).mockReturnValue('100 MB'); + memoryUsageMock.mockReturnValue({ + rss: 0, + heapTotal: 0, + heapUsed: 0, + external: 0, + arrayBuffers: 0, + }); + vi.stubEnv('SANDBOX', 'gemini-test'); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('should generate the default GitHub issue URL', async () => { + const mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => undefined, + getIdeMode: () => true, + getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + } as unknown as Config, + geminiClient: { + getChat: () => ({ + getHistory: () => [], + }), + }, + }, + }, + }); + + if (!bugCommand.action) throw new Error('Action is not defined'); + await bugCommand.action(mockContext, 'A test bug'); + + const expectedInfo = ` +* **CLI Version:** 0.1.0 +* **Git Commit:** ${GIT_COMMIT_INFO} +* **Session ID:** test-session-id +* **Operating System:** test-platform v20.0.0 +* **Sandbox Environment:** test +* **Model Version:** gemini-pro +* **Auth Type:** oauth-personal +* **Memory Usage:** 100 MB +* **Terminal Name:** Test Terminal +* **Terminal Background:** #000000 +* **Kitty Keyboard Protocol:** Supported +* **IDE Client:** VSCode +`; + const expectedUrl = `https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title=A%20test%20bug&info=${encodeURIComponent(expectedInfo)}&problem=A%20test%20bug`; + + expect(open).toHaveBeenCalledWith(expectedUrl); + }); + + it('should export chat history if available', async () => { + const history = [ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'hi' }] }, + ]; + const mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => undefined, + getIdeMode: () => true, + getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), + storage: { + getProjectTempDir: () => '/tmp/gemini', + }, + getSessionId: vi.fn().mockReturnValue('test-session-id'), + } as unknown as Config, + geminiClient: { + getChat: () => ({ + getHistory: () => history, + }), + }, + }, + }, + }); + + if (!bugCommand.action) throw new Error('Action is not defined'); + await bugCommand.action(mockContext, 'Bug with history'); + + const expectedPath = path.join( + '/tmp/gemini', + 'bug-report-history-1704067200000.json', + ); + expect(exportHistoryToFile).toHaveBeenCalledWith({ + history, + filePath: expectedPath, + }); + + const addItemCall = vi.mocked(mockContext.ui.addItem).mock.calls[0]; + const messageText = addItemCall[0].text; + expect(messageText).toContain(expectedPath); + expect(messageText).toContain('📄 **Chat History Exported**'); + expect(messageText).toContain('Privacy Disclaimer:'); + expect(messageText).not.toContain('additional-context='); + expect(messageText).toContain('problem='); + const reminder = + '\n\n[ACTION REQUIRED] 📎 PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.'; + expect(messageText).toContain(encodeURIComponent(reminder)); + }); + + it('should use a custom URL template from config if provided', async () => { + const customTemplate = + 'https://internal.bug-tracker.com/new?desc={title}&details={info}'; + const mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => ({ urlTemplate: customTemplate }), + getIdeMode: () => true, + getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + } as unknown as Config, + geminiClient: { + getChat: () => ({ + getHistory: () => [], + }), + }, + }, + }, + }); + + if (!bugCommand.action) throw new Error('Action is not defined'); + await bugCommand.action(mockContext, 'A custom bug'); + + const expectedInfo = ` +* **CLI Version:** 0.1.0 +* **Git Commit:** ${GIT_COMMIT_INFO} +* **Session ID:** test-session-id +* **Operating System:** test-platform v20.0.0 +* **Sandbox Environment:** test +* **Model Version:** gemini-pro +* **Auth Type:** vertex-ai +* **Memory Usage:** 100 MB +* **Terminal Name:** Test Terminal +* **Terminal Background:** #000000 +* **Kitty Keyboard Protocol:** Supported +* **IDE Client:** VSCode +`; + const expectedUrl = customTemplate + .replace('{title}', encodeURIComponent('A custom bug')) + .replace('{info}', encodeURIComponent(expectedInfo)); + + expect(open).toHaveBeenCalledWith(expectedUrl); + }); + + const buildHighMemoryContext = (tempDir: string | undefined) => + createMockCommandContext({ + services: { + agentContext: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => undefined, + getIdeMode: () => false, + getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }), + storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined, + getSessionId: vi.fn().mockReturnValue('test-session-id'), + } as unknown as Config, + geminiClient: { getChat: () => ({ getHistory: () => [] }) }, + }, + }, + }); + + it('captures a heap snapshot AFTER opening the bug URL when RSS exceeds 2 GB', async () => { + memoryUsageMock.mockReturnValue({ + rss: 3 * 1024 * 1024 * 1024, + heapTotal: 0, + heapUsed: 0, + external: 0, + arrayBuffers: 0, + }); + vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined); + + const tempDir = path.join('/tmp', 'gemini-test'); + const context = buildHighMemoryContext(tempDir); + + if (!bugCommand.action) throw new Error('Action is not defined'); + await bugCommand.action(context, 'A memory bug'); + + const now = new Date('2024-01-01T00:00:00Z').getTime(); + const expectedSnapshotPath = path.join( + tempDir, + `bug-memory-${now}.heapsnapshot`, + ); + expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedSnapshotPath); + + const addItem = vi.mocked(context.ui.addItem); + const callOrder = addItem.mock.invocationCallOrder; + const openOrder = vi.mocked(open).mock.invocationCallOrder[0]; + // The URL message must precede the "capturing" message so the user sees + // the URL before the 20+ second snapshot starts. + expect(callOrder[0]).toBeLessThan(openOrder); + expect(callOrder[1]).toBeGreaterThan(openOrder); + expect(addItem.mock.calls[1][0].text).toContain('High memory usage'); + expect(addItem.mock.calls[2][0].text).toContain('Heap snapshot saved'); + expect(addItem.mock.calls[2][0].text).toContain(expectedSnapshotPath); + expect(addItem.mock.calls[2][0].type).toBe(MessageType.INFO); + }); + + it('skips auto-capture when RSS is below the 2 GB threshold', async () => { + memoryUsageMock.mockReturnValue({ + rss: 1 * 1024 * 1024 * 1024, + heapTotal: 0, + heapUsed: 0, + external: 0, + arrayBuffers: 0, + }); + const context = buildHighMemoryContext('/tmp/gemini-test'); + + if (!bugCommand.action) throw new Error('Action is not defined'); + await bugCommand.action(context, 'A light bug'); + + expect(captureHeapSnapshot).not.toHaveBeenCalled(); + }); + + it('reports an error if the auto-capture fails but does not throw', async () => { + memoryUsageMock.mockReturnValue({ + rss: 3 * 1024 * 1024 * 1024, + heapTotal: 0, + heapUsed: 0, + external: 0, + arrayBuffers: 0, + }); + vi.mocked(captureHeapSnapshot).mockRejectedValueOnce( + new Error('inspector failure'), + ); + const context = buildHighMemoryContext('/tmp/gemini-test'); + + if (!bugCommand.action) throw new Error('Action is not defined'); + await expect( + bugCommand.action(context, 'A memory bug'), + ).resolves.toBeUndefined(); + + const addItem = vi.mocked(context.ui.addItem).mock.calls; + const lastCall = addItem[addItem.length - 1][0]; + expect(lastCall.type).toBe(MessageType.ERROR); + expect(lastCall.text).toContain('inspector failure'); + }); +}); diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..19bc7183d0543e5c4e320b36c4a75fcdd3cc6ea6 --- /dev/null +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import open from 'open'; +import process from 'node:process'; +import { + type CommandContext, + type SlashCommand, + CommandKind, +} from './types.js'; +import { MessageType } from '../types.js'; +import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; +import { formatBytes } from '../utils/formatters.js'; +import { + IdeClient, + getVersion, + INITIAL_HISTORY_LENGTH, + debugLogger, +} from '@google/gemini-cli-core'; +import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; +import { exportHistoryToFile } from '../utils/historyExportUtils.js'; +import { + captureHeapSnapshot, + MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES, +} from '../utils/memorySnapshot.js'; +import { stat } from 'node:fs/promises'; +import path from 'node:path'; + +export const bugCommand: SlashCommand = { + name: 'bug', + description: 'Submit a bug report', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: async (context: CommandContext, args?: string): Promise => { + const bugDescription = (args || '').trim(); + const agentContext = context.services.agentContext; + const config = agentContext?.config; + const osVersion = `${process.platform} ${process.version}`; + let sandboxEnv = 'no sandbox'; + if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { + sandboxEnv = process.env['SANDBOX'].replace(/^gemini-(?:code-)?/, ''); + } else if (process.env['SANDBOX'] === 'sandbox-exec') { + sandboxEnv = `sandbox-exec (${ + process.env['SEATBELT_PROFILE'] || 'unknown' + })`; + } + const modelVersion = config?.getModel() || 'Unknown'; + const cliVersion = await getVersion(); + const memoryUsage = formatBytes(process.memoryUsage().rss); + const ideClient = await getIdeClientName(context); + const terminalName = + terminalCapabilityManager.getTerminalName() || 'Unknown'; + const terminalBgColor = + terminalCapabilityManager.getTerminalBackgroundColor() || 'Unknown'; + const kittyProtocol = terminalCapabilityManager.isKittyProtocolEnabled() + ? 'Supported' + : 'Unsupported'; + const authType = config?.getContentGeneratorConfig()?.authType || 'Unknown'; + + let info = ` +* **CLI Version:** ${cliVersion} +* **Git Commit:** ${GIT_COMMIT_INFO} +* **Session ID:** ${config?.getSessionId() || 'Unknown'} +* **Operating System:** ${osVersion} +* **Sandbox Environment:** ${sandboxEnv} +* **Model Version:** ${modelVersion} +* **Auth Type:** ${authType} +* **Memory Usage:** ${memoryUsage} +* **Terminal Name:** ${terminalName} +* **Terminal Background:** ${terminalBgColor} +* **Kitty Keyboard Protocol:** ${kittyProtocol} +`; + if (ideClient) { + info += `* **IDE Client:** ${ideClient}\n`; + } + + const chat = agentContext?.geminiClient?.getChat(); + const history = chat?.getHistory() || []; + let historyFileMessage = ''; + let problemValue = bugDescription; + + if (history.length > INITIAL_HISTORY_LENGTH) { + const tempDir = config?.storage?.getProjectTempDir(); + if (tempDir) { + const historyFileName = `bug-report-history-${Date.now()}.json`; + const historyFilePath = path.join(tempDir, historyFileName); + try { + await exportHistoryToFile({ history, filePath: historyFilePath }); + historyFileMessage = `\n\n--------------------------------------------------------------------------------\n\n📄 **Chat History Exported**\nTo help us debug, we've exported your current chat history to:\n${historyFilePath}\n\nPlease consider attaching this file to your GitHub issue if you feel comfortable doing so.\n\n**Privacy Disclaimer:** Please do not upload any logs containing sensitive or private information that you are not comfortable sharing publicly.`; + problemValue += `\n\n[ACTION REQUIRED] 📎 PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.`; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + debugLogger.error( + `Failed to export chat history for bug report: ${errorMessage}`, + ); + } + } + } + + let bugReportUrl = + 'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}&problem={problem}'; + + const bugCommandSettings = config?.getBugCommand(); + if (bugCommandSettings?.urlTemplate) { + bugReportUrl = bugCommandSettings.urlTemplate; + } + + bugReportUrl = bugReportUrl + .replace('{title}', encodeURIComponent(bugDescription)) + .replace('{info}', encodeURIComponent(info)) + .replace('{problem}', encodeURIComponent(problemValue)); + + context.ui.addItem( + { + type: MessageType.INFO, + text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}${historyFileMessage}`, + }, + Date.now(), + ); + + try { + await open(bugReportUrl); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Could not open URL in browser: ${errorMessage}`, + }, + Date.now(), + ); + } + + const rss = process.memoryUsage().rss; + const tempDir = config?.storage?.getProjectTempDir(); + if (rss >= MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES && tempDir) { + const snapshotPath = path.join( + tempDir, + `bug-memory-${Date.now()}.heapsnapshot`, + ); + context.ui.addItem( + { + type: MessageType.INFO, + text: `High memory usage detected (${formatBytes(rss)}). Capturing V8 heap snapshot to ${snapshotPath}.\nThis can take 20+ seconds and the CLI may be temporarily unresponsive; please do not exit.`, + }, + Date.now(), + ); + try { + const startedAt = Date.now(); + await captureHeapSnapshot(snapshotPath); + const durationMs = Date.now() - startedAt; + let sizeText = ''; + try { + const { size } = await stat(snapshotPath); + sizeText = ` (${formatBytes(size)})`; + } catch { + // Size reporting is best-effort; the snapshot itself was captured successfully. + } + context.ui.addItem( + { + type: MessageType.INFO, + text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${snapshotPath}\n\nConsider attaching it to your bug report only if it does not contain sensitive information.`, + }, + Date.now(), + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + debugLogger.error( + `Failed to capture heap snapshot for bug report: ${errorMessage}`, + ); + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to capture heap snapshot: ${errorMessage}`, + }, + Date.now(), + ); + } + } + }, +}; + +async function getIdeClientName(context: CommandContext) { + if (!context.services.agentContext?.config.getIdeMode()) { + return ''; + } + const ideClient = await IdeClient.getInstance(); + return ideClient.getDetectedIdeDisplayName() ?? ''; +} diff --git a/packages/cli/src/ui/commands/bugMemoryCommand.test.ts b/packages/cli/src/ui/commands/bugMemoryCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a93db95271975b79425fc40a45f833b98a7ac04 --- /dev/null +++ b/packages/cli/src/ui/commands/bugMemoryCommand.test.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import { bugMemoryCommand } from './bugMemoryCommand.js'; +import { captureHeapSnapshot } from '../utils/memorySnapshot.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; +import type { Config } from '@google/gemini-cli-core'; + +vi.mock('../utils/memorySnapshot.js', () => ({ + captureHeapSnapshot: vi.fn(), + MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024, +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + stat: vi.fn().mockResolvedValue({ size: 1234 }), + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger: { + error: vi.fn(), + log: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +function makeContextWithTempDir(tempDir: string | undefined) { + return createMockCommandContext({ + services: { + agentContext: { + config: { + storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined, + } as unknown as Config, + }, + }, + }); +} + +describe('bugMemoryCommand', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('declares itself as a non-auto-executing built-in command', () => { + expect(bugMemoryCommand.name).toBe('bug-memory'); + expect(bugMemoryCommand.autoExecute).toBe(false); + expect(bugMemoryCommand.description).toBeTruthy(); + }); + + it('captures a heap snapshot and reports the file path', async () => { + const tempDir = path.join('/tmp', 'gemini-test'); + const context = makeContextWithTempDir(tempDir); + vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined); + + if (!bugMemoryCommand.action) throw new Error('Action missing'); + await bugMemoryCommand.action(context, ''); + + const expectedPath = path.join( + tempDir, + `bug-memory-${new Date('2024-01-01T00:00:00Z').getTime()}.heapsnapshot`, + ); + expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedPath); + + const addItemCalls = vi.mocked(context.ui.addItem).mock.calls; + expect(addItemCalls).toHaveLength(2); + expect(addItemCalls[0][0]).toMatchObject({ type: MessageType.INFO }); + expect(addItemCalls[0][0].text).toContain(expectedPath); + expect(addItemCalls[1][0]).toMatchObject({ type: MessageType.INFO }); + expect(addItemCalls[1][0].text).toContain('Heap snapshot saved'); + expect(addItemCalls[1][0].text).toContain(expectedPath); + }); + + it('surfaces an error if capture fails', async () => { + const context = makeContextWithTempDir('/tmp/gemini-test'); + vi.mocked(captureHeapSnapshot).mockRejectedValueOnce( + new Error('inspector disconnected'), + ); + + if (!bugMemoryCommand.action) throw new Error('Action missing'); + await bugMemoryCommand.action(context, ''); + + const addItemCalls = vi.mocked(context.ui.addItem).mock.calls; + const lastCall = addItemCalls[addItemCalls.length - 1][0]; + expect(lastCall.type).toBe(MessageType.ERROR); + expect(lastCall.text).toContain('inspector disconnected'); + }); + + it('emits an error when no project temp directory is available', async () => { + const context = makeContextWithTempDir(undefined); + + if (!bugMemoryCommand.action) throw new Error('Action missing'); + await bugMemoryCommand.action(context, ''); + + expect(captureHeapSnapshot).not.toHaveBeenCalled(); + const addItemCalls = vi.mocked(context.ui.addItem).mock.calls; + expect(addItemCalls).toHaveLength(1); + expect(addItemCalls[0][0].type).toBe(MessageType.ERROR); + expect(addItemCalls[0][0].text).toContain('temp directory'); + }); +}); diff --git a/packages/cli/src/ui/commands/bugMemoryCommand.ts b/packages/cli/src/ui/commands/bugMemoryCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd43ce890237d3e8d7d0d7acb2f55c9cbd492815 --- /dev/null +++ b/packages/cli/src/ui/commands/bugMemoryCommand.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { stat } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { debugLogger } from '@google/gemini-cli-core'; +import { + type CommandContext, + type SlashCommand, + CommandKind, +} from './types.js'; +import { MessageType } from '../types.js'; +import { formatBytes } from '../utils/formatters.js'; +import { captureHeapSnapshot } from '../utils/memorySnapshot.js'; + +export const bugMemoryCommand: SlashCommand = { + name: 'bug-memory', + description: 'Capture a V8 heap snapshot to disk to attach to a bug report', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: async (context: CommandContext): Promise => { + const tempDir = + context.services.agentContext?.config?.storage?.getProjectTempDir(); + if (!tempDir) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: 'Cannot capture heap snapshot: project temp directory is unavailable.', + }, + Date.now(), + ); + return; + } + + const filePath = path.join( + tempDir, + `bug-memory-${Date.now()}.heapsnapshot`, + ); + const rss = process.memoryUsage().rss; + + context.ui.addItem( + { + type: MessageType.INFO, + text: `Capturing V8 heap snapshot (current RSS: ${formatBytes(rss)}).\nThis can take 20+ seconds and the CLI may be temporarily unresponsive — please do not exit.\nDestination: ${filePath}`, + }, + Date.now(), + ); + + const startedAt = Date.now(); + try { + await captureHeapSnapshot(filePath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + debugLogger.error(`Failed to capture heap snapshot: ${message}`); + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to capture heap snapshot: ${message}`, + }, + Date.now(), + ); + return; + } + + const durationMs = Date.now() - startedAt; + let sizeText = ''; + try { + const { size } = await stat(filePath); + sizeText = ` (${formatBytes(size)})`; + } catch { + // Size reporting is best-effort; the snapshot itself was captured successfully. + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${filePath}\n\nLoad it in Chrome DevTools → Memory → "Load" to analyze. Attach it to your bug report only if it does not contain sensitive information.`, + }, + Date.now(), + ); + }, +}; diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..04d0753ee8f63d00f3ecca093d40292078b5a8d5 --- /dev/null +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -0,0 +1,763 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import type { SlashCommand, CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { Content } from '@google/genai'; +import { AuthType, type GeminiClient } from '@google/gemini-cli-core'; + +import * as fsPromises from 'node:fs/promises'; +import { chatCommand, debugCommand } from './chatCommand.js'; +import { + serializeHistoryToMarkdown, + exportHistoryToFile, +} from '../utils/historyExportUtils.js'; +import type { Stats } from 'node:fs'; +import type { HistoryItemWithoutId } from '../types.js'; +import path from 'node:path'; + +vi.mock('fs/promises', () => ({ + stat: vi.fn(), + readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]), + writeFile: vi.fn(), +})); + +vi.mock('../utils/historyExportUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + exportHistoryToFile: vi.fn(), + }; +}); + +describe('chatCommand', () => { + const mockFs = vi.mocked(fsPromises); + const mockExport = vi.mocked(exportHistoryToFile); + + let mockContext: CommandContext; + let mockGetChat: ReturnType; + let mockSaveCheckpoint: ReturnType; + let mockLoadCheckpoint: ReturnType; + let mockDeleteCheckpoint: ReturnType; + let mockGetHistory: ReturnType; + + const getSubCommand = ( + name: 'list' | 'save' | 'resume' | 'delete' | 'share', + ): SlashCommand => { + const subCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === name, + ); + if (!subCommand) { + throw new Error(`/chat ${name} command not found.`); + } + return subCommand; + }; + + beforeEach(() => { + mockGetHistory = vi.fn().mockReturnValue([]); + mockGetChat = vi.fn().mockReturnValue({ + getHistory: mockGetHistory, + }); + mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined); + mockLoadCheckpoint = vi.fn().mockResolvedValue({ history: [] }); + mockDeleteCheckpoint = vi.fn().mockResolvedValue(true); + + mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + getProjectRoot: () => '/project/root', + getContentGeneratorConfig: () => ({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), + storage: { + getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash', + }, + }, + geminiClient: { + getChat: mockGetChat, + } as unknown as GeminiClient, + }, + logger: { + saveCheckpoint: mockSaveCheckpoint, + loadCheckpoint: mockLoadCheckpoint, + deleteCheckpoint: mockDeleteCheckpoint, + initialize: vi.fn().mockResolvedValue(undefined), + }, + }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should have the correct main command definition', () => { + expect(chatCommand.name).toBe('chat'); + expect(chatCommand.description).toBe( + 'Browse auto-saved conversations and manage chat checkpoints', + ); + expect(chatCommand.autoExecute).toBe(true); + expect(chatCommand.subCommands).toHaveLength(6); + }); + + describe('list subcommand', () => { + let listCommand: SlashCommand; + + beforeEach(() => { + listCommand = getSubCommand('list'); + }); + + it('should add a chat_list item to the UI', async () => { + const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json']; + const date1 = new Date(); + const date2 = new Date(date1.getTime() + 1000); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockFs.readdir.mockResolvedValue(fakeFiles as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockFs.stat.mockImplementation(async (path: any): Promise => { + if (path.endsWith('test1.json')) { + return { mtime: date1 } as Stats; + } + return { mtime: date2 } as Stats; + }); + + await listCommand?.action?.(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: 'chat_list', + chats: [ + { + name: 'test1', + mtime: date1.toISOString(), + }, + { + name: 'test2', + mtime: date2.toISOString(), + }, + ], + }); + }); + }); + describe('save subcommand', () => { + let saveCommand: SlashCommand; + const tag = 'my-tag'; + let mockCheckpointExists: ReturnType; + + beforeEach(() => { + saveCommand = getSubCommand('save'); + mockCheckpointExists = vi.fn().mockResolvedValue(false); + mockContext.services.logger.checkpointExists = mockCheckpointExists; + }); + + it('should return an error if tag is missing', async () => { + const result = await saveCommand?.action?.(mockContext, ' '); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Missing tag. Usage: /resume save ', + }); + }); + + it('should inform if conversation history is empty or only contains system context', async () => { + mockGetHistory.mockReturnValue([]); + let result = await saveCommand?.action?.(mockContext, tag); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No conversation found to save.', + }); + + mockGetHistory.mockReturnValue([ + { role: 'user', parts: [{ text: 'context for our chat' }] }, + ]); + result = await saveCommand?.action?.(mockContext, tag); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No conversation found to save.', + }); + + mockGetHistory.mockReturnValue([ + { role: 'user', parts: [{ text: 'context for our chat' }] }, + { role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] }, + { role: 'user', parts: [{ text: 'Hello, how are you?' }] }, + ]); + result = await saveCommand?.action?.(mockContext, tag); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Conversation checkpoint saved with tag: ${tag}.`, + }); + }); + + it('should return confirm_action if checkpoint already exists', async () => { + mockCheckpointExists.mockResolvedValue(true); + mockContext.invocation = { + raw: `/chat save ${tag}`, + name: 'save', + args: tag, + }; + + const result = await saveCommand?.action?.(mockContext, tag); + + expect(mockCheckpointExists).toHaveBeenCalledWith(tag); + expect(mockSaveCheckpoint).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + type: 'confirm_action', + originalInvocation: { raw: `/chat save ${tag}` }, + }); + // Check that prompt is a React element + expect(result).toHaveProperty('prompt'); + }); + + it('should save the conversation if overwrite is confirmed', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'context for our chat' }] }, + { role: 'user', parts: [{ text: 'hello' }] }, + ]; + mockGetHistory.mockReturnValue(history); + mockContext.overwriteConfirmed = true; + + const result = await saveCommand?.action?.(mockContext, tag); + + expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check + expect(mockSaveCheckpoint).toHaveBeenCalledWith( + { history, authType: AuthType.LOGIN_WITH_GOOGLE }, + tag, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Conversation checkpoint saved with tag: ${tag}.`, + }); + }); + }); + + describe('resume subcommand', () => { + const goodTag = 'good-tag'; + const badTag = 'bad-tag'; + + let resumeCommand: SlashCommand; + beforeEach(() => { + resumeCommand = getSubCommand('resume'); + }); + + it('should return an error if tag is missing', async () => { + const result = await resumeCommand?.action?.(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Missing tag. Usage: /resume resume ', + }); + }); + + it('should inform if checkpoint is not found', async () => { + mockLoadCheckpoint.mockResolvedValue({ history: [] }); + + const result = await resumeCommand?.action?.(mockContext, badTag); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `No saved checkpoint found with tag: ${badTag}.`, + }); + }); + + it('should resume a conversation with matching authType', async () => { + const conversation: Content[] = [ + { role: 'user', parts: [{ text: 'system setup' }] }, + { role: 'user', parts: [{ text: 'hello gemini' }] }, + { role: 'model', parts: [{ text: 'hello world' }] }, + ]; + mockLoadCheckpoint.mockResolvedValue({ + history: conversation, + authType: AuthType.LOGIN_WITH_GOOGLE, + }); + + const result = await resumeCommand?.action?.(mockContext, goodTag); + + expect(result).toEqual({ + type: 'load_history', + history: [ + { type: 'user', text: 'hello gemini' }, + { type: 'gemini', text: 'hello world' }, + ] as HistoryItemWithoutId[], + clientHistory: conversation, + }); + }); + + it('should block resuming a conversation with mismatched authType', async () => { + const conversation: Content[] = [ + { role: 'user', parts: [{ text: 'system setup' }] }, + { role: 'user', parts: [{ text: 'hello gemini' }] }, + { role: 'model', parts: [{ text: 'hello world' }] }, + ]; + mockLoadCheckpoint.mockResolvedValue({ + history: conversation, + authType: AuthType.USE_GEMINI, + }); + + const result = await resumeCommand?.action?.(mockContext, goodTag); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: `Cannot resume chat. It was saved with a different authentication method (${AuthType.USE_GEMINI}) than the current one (${AuthType.LOGIN_WITH_GOOGLE}).`, + }); + }); + + it('should resume a legacy conversation without authType', async () => { + const conversation: Content[] = [ + { role: 'user', parts: [{ text: 'system setup' }] }, + { role: 'user', parts: [{ text: 'hello gemini' }] }, + { role: 'model', parts: [{ text: 'hello world' }] }, + ]; + mockLoadCheckpoint.mockResolvedValue({ history: conversation }); + + const result = await resumeCommand?.action?.(mockContext, goodTag); + + expect(result).toEqual({ + type: 'load_history', + history: [ + { type: 'user', text: 'hello gemini' }, + { type: 'gemini', text: 'hello world' }, + ] as HistoryItemWithoutId[], + clientHistory: conversation, + }); + }); + + describe('completion', () => { + it('should provide completion suggestions', async () => { + const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json']; + mockFs.readdir.mockImplementation( + (async (_: string): Promise => + fakeFiles) as unknown as typeof fsPromises.readdir, + ); + + mockFs.stat.mockImplementation( + (async (_: string): Promise => + ({ + mtime: new Date(), + }) as Stats) as unknown as typeof fsPromises.stat, + ); + + const result = await resumeCommand?.completion?.(mockContext, 'a'); + + expect(result).toEqual(['alpha']); + }); + + it('should suggest filenames sorted by modified time (newest first)', async () => { + const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json']; + const date = new Date(); + mockFs.readdir.mockImplementation( + (async (_: string): Promise => + fakeFiles) as unknown as typeof fsPromises.readdir, + ); + mockFs.stat.mockImplementation((async ( + path: string, + ): Promise => { + if (path.endsWith('test1.json')) { + return { mtime: date } as Stats; + } + return { mtime: new Date(date.getTime() + 1000) } as Stats; + }) as unknown as typeof fsPromises.stat); + + const result = await resumeCommand?.completion?.(mockContext, ''); + // Sort items by last modified time (newest first) + expect(result).toEqual(['test2', 'test1']); + }); + }); + }); + + describe('delete subcommand', () => { + let deleteCommand: SlashCommand; + const tag = 'my-tag'; + beforeEach(() => { + deleteCommand = getSubCommand('delete'); + }); + + it('should return an error if tag is missing', async () => { + const result = await deleteCommand?.action?.(mockContext, ' '); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Missing tag. Usage: /resume delete ', + }); + }); + + it('should return an error if checkpoint is not found', async () => { + mockDeleteCheckpoint.mockResolvedValue(false); + const result = await deleteCommand?.action?.(mockContext, tag); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: `Error: No checkpoint found with tag '${tag}'.`, + }); + }); + + it('should delete the conversation', async () => { + const result = await deleteCommand?.action?.(mockContext, tag); + + expect(mockDeleteCheckpoint).toHaveBeenCalledWith(tag); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Conversation checkpoint '${tag}' has been deleted.`, + }); + }); + + describe('completion', () => { + it('should provide completion suggestions', async () => { + const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json']; + mockFs.readdir.mockImplementation( + (async (_: string): Promise => + fakeFiles) as unknown as typeof fsPromises.readdir, + ); + + mockFs.stat.mockImplementation( + (async (_: string): Promise => + ({ + mtime: new Date(), + }) as Stats) as unknown as typeof fsPromises.stat, + ); + + const result = await deleteCommand?.completion?.(mockContext, 'a'); + + expect(result).toEqual(['alpha']); + }); + }); + }); + + describe('share subcommand', () => { + let shareCommand: SlashCommand; + const mockHistory = [ + { role: 'user', parts: [{ text: 'context' }] }, + { role: 'model', parts: [{ text: 'context response' }] }, + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi there!' }] }, + ]; + + beforeEach(() => { + shareCommand = getSubCommand('share'); + vi.spyOn(process, 'cwd').mockReturnValue( + path.resolve('/usr/local/google/home/myuser/gemini-cli'), + ); + vi.spyOn(Date, 'now').mockReturnValue(1234567890); + mockGetHistory.mockReturnValue(mockHistory); + mockFs.writeFile.mockClear(); + }); + + it('should default to a json file if no path is provided', async () => { + const result = await shareCommand?.action?.(mockContext, ''); + const expectedPath = path.join( + process.cwd(), + 'gemini-conversation-1234567890.json', + ); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, + }); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Conversation shared to ${expectedPath}`, + }); + }); + + it('should share the conversation to a JSON file', async () => { + const filePath = 'my-chat.json'; + const result = await shareCommand?.action?.(mockContext, filePath); + const expectedPath = path.join(process.cwd(), 'my-chat.json'); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, + }); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Conversation shared to ${expectedPath}`, + }); + }); + + it('should share the conversation to a Markdown file', async () => { + const filePath = 'my-chat.md'; + const result = await shareCommand?.action?.(mockContext, filePath); + const expectedPath = path.join(process.cwd(), 'my-chat.md'); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, + }); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Conversation shared to ${expectedPath}`, + }); + }); + + it('should return an error for unsupported file extensions', async () => { + const filePath = 'my-chat.txt'; + const result = await shareCommand?.action?.(mockContext, filePath); + expect(mockExport).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Invalid file format. Only .md and .json are supported.', + }); + }); + + it('should inform if there is no conversation to share', async () => { + mockGetHistory.mockReturnValue([ + { role: 'user', parts: [{ text: 'context' }] }, + ]); + const result = await shareCommand?.action?.(mockContext, 'my-chat.json'); + expect(mockExport).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No conversation found to share.', + }); + }); + + it('should handle errors during file writing', async () => { + const error = new Error('Permission denied'); + mockExport.mockRejectedValue(error); + const result = await shareCommand?.action?.(mockContext, 'my-chat.json'); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: `Error sharing conversation: ${error.message}`, + }); + }); + + it('should output valid JSON schema', async () => { + const filePath = 'my-chat.json'; + await shareCommand?.action?.(mockContext, filePath); + const expectedPath = path.join(process.cwd(), 'my-chat.json'); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, + }); + }); + + it('should output correct markdown format', async () => { + const filePath = 'my-chat.md'; + await shareCommand?.action?.(mockContext, filePath); + const expectedPath = path.join(process.cwd(), 'my-chat.md'); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, + }); + }); + }); + + describe('serializeHistoryToMarkdown', () => { + it('should correctly serialize chat history to Markdown with icons', () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi there!' }] }, + { role: 'user', parts: [{ text: 'How are you?' }] }, + ]; + + const expectedMarkdown = + '## USER 🧑‍💻\n\nHello\n\n---\n\n' + + '## MODEL ✨\n\nHi there!\n\n---\n\n' + + '## USER 🧑‍💻\n\nHow are you?'; + + const result = serializeHistoryToMarkdown(history); + expect(result).toBe(expectedMarkdown); + }); + + it('should handle empty history', () => { + const history: Content[] = []; + const result = serializeHistoryToMarkdown(history); + expect(result).toBe(''); + }); + + it('should handle items with no text parts', () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [] }, + { role: 'user', parts: [{ text: 'How are you?' }] }, + ]; + + const expectedMarkdown = `## USER 🧑‍💻 + +Hello + +--- + +## MODEL ✨ + + + +--- + +## USER 🧑‍💻 + +How are you?`; + + const result = serializeHistoryToMarkdown(history); + expect(result).toBe(expectedMarkdown); + }); + + it('should correctly serialize function calls and responses', () => { + const history: Content[] = [ + { + role: 'user', + parts: [{ text: 'Please call a function.' }], + }, + { + role: 'model', + parts: [ + { + functionCall: { + name: 'my-function', + args: { arg1: 'value1' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'my-function', + response: { result: 'success' }, + }, + }, + ], + }, + ]; + + const expectedMarkdown = `## USER 🧑‍💻 + +Please call a function. + +--- + +## MODEL ✨ + +**Tool Command**: +\`\`\`json +{ + "name": "my-function", + "args": { + "arg1": "value1" + } +} +\`\`\` + +--- + +## USER 🧑‍💻 + +**Tool Response**: +\`\`\`json +{ + "name": "my-function", + "response": { + "result": "success" + } +} +\`\`\``; + + const result = serializeHistoryToMarkdown(history); + expect(result).toBe(expectedMarkdown); + }); + + it('should handle items with undefined role', () => { + const history: Array> = [ + { role: 'user', parts: [{ text: 'Hello' }] }, + { parts: [{ text: 'Hi there!' }] }, + ]; + + const expectedMarkdown = `## USER 🧑‍💻 + +Hello + +--- + +## MODEL ✨ + +Hi there!`; + + const result = serializeHistoryToMarkdown(history as Content[]); + expect(result).toBe(expectedMarkdown); + }); + describe('debug subcommand', () => { + let mockGetLatestApiRequest: ReturnType; + + beforeEach(() => { + mockGetLatestApiRequest = vi.fn(); + if (!mockContext.services.agentContext!.config) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockContext.services.agentContext!.config as any) = {}; + } + mockContext.services.agentContext!.config.getLatestApiRequest = + mockGetLatestApiRequest; + vi.spyOn(process, 'cwd').mockReturnValue('/project/root'); + vi.spyOn(Date, 'now').mockReturnValue(1234567890); + mockFs.writeFile.mockClear(); + }); + + it('should return an error if no API request is found', async () => { + mockGetLatestApiRequest.mockReturnValue(undefined); + + const result = await debugCommand.action?.(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No recent API request found to export.', + }); + expect(mockFs.writeFile).not.toHaveBeenCalled(); + }); + + it('should convert and write the API request to a json file', async () => { + const mockRequest = { + contents: [{ role: 'user', parts: [{ text: 'test' }] }], + }; + mockGetLatestApiRequest.mockReturnValue(mockRequest); + + const result = await debugCommand.action?.(mockContext, ''); + + const expectedFilename = 'gcli-request-1234567890.json'; + const expectedPath = path.join('/project/root', expectedFilename); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + expectedPath, + expect.stringContaining('"role": "user"'), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Debug API request saved to ${expectedFilename}`, + }); + }); + + it('should handle errors during file write', async () => { + const mockRequest = { contents: [] }; + mockGetLatestApiRequest.mockReturnValue(mockRequest); + mockFs.writeFile.mockRejectedValue(new Error('Write failed')); + + const result = await debugCommand.action?.(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Error saving debug request: Write failed', + }); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..05fd081dfb513dc6f3540cb38aa7f4cb84ad95ab --- /dev/null +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -0,0 +1,430 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fsPromises from 'node:fs/promises'; +import React from 'react'; +import { Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import type { + CommandContext, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { + decodeTagName, + type MessageActionReturn, + INITIAL_HISTORY_LENGTH, +} from '@google/gemini-cli-core'; +import path from 'node:path'; +import type { + HistoryItemWithoutId, + HistoryItemChatList, + ChatDetail, +} from '../types.js'; +import { MessageType } from '../types.js'; +import { exportHistoryToFile } from '../utils/historyExportUtils.js'; +import { convertToRestPayload } from '@google/gemini-cli-core'; + +const CHECKPOINT_MENU_GROUP = 'checkpoints'; + +const getSavedChatTags = async ( + context: CommandContext, + mtSortDesc: boolean, +): Promise => { + const cfg = context.services.agentContext?.config; + const geminiDir = cfg?.storage?.getProjectTempDir(); + if (!geminiDir) { + return []; + } + try { + const file_head = 'checkpoint-'; + const file_tail = '.json'; + const files = await fsPromises.readdir(geminiDir); + const chatDetails: ChatDetail[] = []; + + for (const file of files) { + if (file.startsWith(file_head) && file.endsWith(file_tail)) { + const filePath = path.join(geminiDir, file); + const stats = await fsPromises.stat(filePath); + const tagName = file.slice(file_head.length, -file_tail.length); + chatDetails.push({ + name: decodeTagName(tagName), + mtime: stats.mtime.toISOString(), + }); + } + } + + chatDetails.sort((a, b) => + mtSortDesc + ? b.mtime.localeCompare(a.mtime) + : a.mtime.localeCompare(b.mtime), + ); + + return chatDetails; + } catch { + return []; + } +}; + +const listCommand: SlashCommand = { + name: 'list', + description: 'List saved manual conversation checkpoints', + kind: CommandKind.BUILT_IN, + autoExecute: true, + takesArgs: false, + action: async (context): Promise => { + const chatDetails = await getSavedChatTags(context, false); + + const item: HistoryItemChatList = { + type: MessageType.CHAT_LIST, + chats: chatDetails, + }; + + context.ui.addItem(item); + }, +}; + +const saveCommand: SlashCommand = { + name: 'save', + description: + 'Save the current conversation as a checkpoint. Usage: /resume save ', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: async (context, args): Promise => { + const tag = args.trim(); + if (!tag) { + return { + type: 'message', + messageType: 'error', + content: 'Missing tag. Usage: /resume save ', + }; + } + + const { logger } = context.services; + const config = context.services.agentContext?.config; + await logger.initialize(); + + if (!context.overwriteConfirmed) { + const exists = await logger.checkpointExists(tag); + if (exists) { + return { + type: 'confirm_action', + prompt: React.createElement( + Text, + null, + 'A checkpoint with the tag ', + React.createElement(Text, { color: theme.text.accent }, tag), + ' already exists. Do you want to overwrite it?', + ), + originalInvocation: { + raw: context.invocation?.raw || `/resume save ${tag}`, + }, + }; + } + } + + const chat = context.services.agentContext?.geminiClient?.getChat(); + if (!chat) { + return { + type: 'message', + messageType: 'error', + content: 'No chat client available to save conversation.', + }; + } + + const history = chat.getHistory(); + if (history.length > INITIAL_HISTORY_LENGTH) { + const authType = config?.getContentGeneratorConfig()?.authType; + await logger.saveCheckpoint({ history, authType }, tag); + return { + type: 'message', + messageType: 'info', + content: `Conversation checkpoint saved with tag: ${decodeTagName( + tag, + )}.`, + }; + } else { + return { + type: 'message', + messageType: 'info', + content: 'No conversation found to save.', + }; + } + }, +}; + +const resumeCheckpointCommand: SlashCommand = { + name: 'resume', + altNames: ['load'], + description: + 'Resume a conversation from a checkpoint. Usage: /resume resume ', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context, args) => { + const tag = args.trim(); + if (!tag) { + return { + type: 'message', + messageType: 'error', + content: 'Missing tag. Usage: /resume resume ', + }; + } + + const { logger } = context.services; + const config = context.services.agentContext?.config; + await logger.initialize(); + const checkpoint = await logger.loadCheckpoint(tag); + const conversation = checkpoint.history; + + if (conversation.length === 0) { + return { + type: 'message', + messageType: 'info', + content: `No saved checkpoint found with tag: ${decodeTagName(tag)}.`, + }; + } + + const currentAuthType = config?.getContentGeneratorConfig()?.authType; + if ( + checkpoint.authType && + currentAuthType && + checkpoint.authType !== currentAuthType + ) { + return { + type: 'message', + messageType: 'error', + content: `Cannot resume chat. It was saved with a different authentication method (${checkpoint.authType}) than the current one (${currentAuthType}).`, + }; + } + + const rolemap: { [key: string]: MessageType } = { + user: MessageType.USER, + model: MessageType.GEMINI, + }; + + const uiHistory: HistoryItemWithoutId[] = []; + + for (const item of conversation.slice(INITIAL_HISTORY_LENGTH)) { + const text = + item.parts + ?.filter((m) => !!m.text) + .map((m) => m.text) + .join('') || ''; + if (!text) { + continue; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + uiHistory.push({ + type: (item.role && rolemap[item.role]) || MessageType.GEMINI, + text, + } as HistoryItemWithoutId); + } + return { + type: 'load_history', + history: uiHistory, + clientHistory: conversation, + }; + }, + completion: async (context, partialArg) => { + const chatDetails = await getSavedChatTags(context, true); + return chatDetails + .map((chat) => chat.name) + .filter((name) => name.startsWith(partialArg)); + }, +}; + +const deleteCommand: SlashCommand = { + name: 'delete', + description: 'Delete a conversation checkpoint. Usage: /resume delete ', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context, args): Promise => { + const tag = args.trim(); + if (!tag) { + return { + type: 'message', + messageType: 'error', + content: 'Missing tag. Usage: /resume delete ', + }; + } + + const { logger } = context.services; + await logger.initialize(); + const deleted = await logger.deleteCheckpoint(tag); + + if (deleted) { + return { + type: 'message', + messageType: 'info', + content: `Conversation checkpoint '${decodeTagName(tag)}' has been deleted.`, + }; + } else { + return { + type: 'message', + messageType: 'error', + content: `Error: No checkpoint found with tag '${decodeTagName(tag)}'.`, + }; + } + }, + completion: async (context, partialArg) => { + const chatDetails = await getSavedChatTags(context, true); + return chatDetails + .map((chat) => chat.name) + .filter((name) => name.startsWith(partialArg)); + }, +}; + +const shareCommand: SlashCommand = { + name: 'share', + description: + 'Share the current conversation to a markdown or json file. Usage: /resume share ', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: async (context, args): Promise => { + let filePathArg = args.trim(); + if (!filePathArg) { + filePathArg = `gemini-conversation-${Date.now()}.json`; + } + + const filePath = path.resolve(filePathArg); + const extension = path.extname(filePath); + if (extension !== '.md' && extension !== '.json') { + return { + type: 'message', + messageType: 'error', + content: 'Invalid file format. Only .md and .json are supported.', + }; + } + + const chat = context.services.agentContext?.geminiClient?.getChat(); + if (!chat) { + return { + type: 'message', + messageType: 'error', + content: 'No chat client available to share conversation.', + }; + } + + const history = chat.getHistory(); + + // An empty conversation has a hidden message that sets up the context for + // the chat. Thus, to check whether a conversation has been started, we + // can't check for length 0. + if (history.length <= INITIAL_HISTORY_LENGTH) { + return { + type: 'message', + messageType: 'info', + content: 'No conversation found to share.', + }; + } + + try { + await exportHistoryToFile({ history, filePath }); + return { + type: 'message', + messageType: 'info', + content: `Conversation shared to ${filePath}`, + }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + return { + type: 'message', + messageType: 'error', + content: `Error sharing conversation: ${errorMessage}`, + }; + } + }, +}; + +export const debugCommand: SlashCommand = { + name: 'debug', + description: 'Export the most recent API request as a JSON payload', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context): Promise => { + const req = context.services.agentContext?.config.getLatestApiRequest(); + if (!req) { + return { + type: 'message', + messageType: 'error', + content: 'No recent API request found to export.', + }; + } + + const restPayload = convertToRestPayload(req); + const filename = `gcli-request-${Date.now()}.json`; + const filePath = path.join(process.cwd(), filename); + + try { + await fsPromises.writeFile( + filePath, + JSON.stringify(restPayload, null, 2), + ); + return { + type: 'message', + messageType: 'info', + content: `Debug API request saved to ${filename}`, + }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + return { + type: 'message', + messageType: 'error', + content: `Error saving debug request: ${errorMessage}`, + }; + } + }, +}; + +export const checkpointSubCommands: SlashCommand[] = [ + listCommand, + saveCommand, + resumeCheckpointCommand, + deleteCommand, + shareCommand, +]; + +const checkpointCompatibilityCommand: SlashCommand = { + name: 'checkpoints', + altNames: ['checkpoint'], + description: 'Compatibility command for nested checkpoint operations', + kind: CommandKind.BUILT_IN, + hidden: true, + autoExecute: false, + subCommands: checkpointSubCommands, +}; + +export const chatResumeSubCommands: SlashCommand[] = [ + ...checkpointSubCommands.map((subCommand) => ({ + ...subCommand, + suggestionGroup: CHECKPOINT_MENU_GROUP, + })), + checkpointCompatibilityCommand, +]; + +import { parseSlashCommand } from '../../utils/commands.js'; + +export const chatCommand: SlashCommand = { + name: 'chat', + description: 'Browse auto-saved conversations and manage chat checkpoints', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context, args) => { + if (args) { + const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands); + if (parsed.commandToExecute?.action) { + return parsed.commandToExecute.action(context, parsed.args); + } + } + return { + type: 'dialog', + dialog: 'sessionBrowser', + }; + }, + subCommands: chatResumeSubCommands, +}; diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..51e3ace2f527127db718d69cf9ae7735fbbedf9e --- /dev/null +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; +import { clearCommand } from './clearCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +// Mock the telemetry service +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual('@google/gemini-cli-core'); + return { + ...actual, + uiTelemetryService: { + setLastPromptTokenCount: vi.fn(), + clear: vi.fn(), + }, + }; +}); + +import { uiTelemetryService, type GeminiClient } from '@google/gemini-cli-core'; + +describe('clearCommand', () => { + let mockContext: CommandContext; + let mockResetChat: ReturnType; + let mockHintClear: ReturnType; + + beforeEach(() => { + mockResetChat = vi.fn().mockResolvedValue(undefined); + mockHintClear = vi.fn(); + const mockGetChatRecordingService = vi.fn(); + vi.clearAllMocks(); + + mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + getEnableHooks: vi.fn().mockReturnValue(false), + resetNewSessionState: vi.fn(), + getMessageBus: vi.fn().mockReturnValue(undefined), + getHookSystem: vi.fn().mockReturnValue({ + fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), + fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), + }), + injectionService: { + clear: mockHintClear, + }, + }, + geminiClient: { + resetChat: mockResetChat, + getChat: () => ({ + getChatRecordingService: mockGetChatRecordingService, + }), + } as unknown as GeminiClient, + }, + }, + }); + }); + + it('should set debug message, reset chat, reset telemetry, clear hints, and clear UI when config is available', async () => { + if (!clearCommand.action) { + throw new Error('clearCommand must have an action.'); + } + + await clearCommand.action(mockContext, ''); + + expect(mockContext.ui.setDebugMessage).toHaveBeenCalledWith( + 'Clearing terminal and resetting chat.', + ); + expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1); + + expect(mockResetChat).toHaveBeenCalledTimes(1); + expect(mockHintClear).toHaveBeenCalledTimes(1); + expect( + mockContext.services.agentContext?.config.resetNewSessionState, + ).toHaveBeenCalledTimes(1); + expect(uiTelemetryService.clear).toHaveBeenCalled(); + expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1); + expect(mockContext.ui.clear).toHaveBeenCalledTimes(1); + + // Check the order of operations. + const setDebugMessageOrder = (mockContext.ui.setDebugMessage as Mock).mock + .invocationCallOrder[0]; + const resetChatOrder = mockResetChat.mock.invocationCallOrder[0]; + const resetTelemetryOrder = (uiTelemetryService.clear as Mock).mock + .invocationCallOrder[0]; + const clearOrder = (mockContext.ui.clear as Mock).mock + .invocationCallOrder[0]; + + expect(setDebugMessageOrder).toBeLessThan(resetChatOrder); + expect(resetChatOrder).toBeLessThan(resetTelemetryOrder); + expect(resetTelemetryOrder).toBeLessThan(clearOrder); + }); + + it('should not attempt to reset chat if config service is not available', async () => { + if (!clearCommand.action) { + throw new Error('clearCommand must have an action.'); + } + + const nullConfigContext = createMockCommandContext({ + services: { + agentContext: null, + }, + }); + + await clearCommand.action(nullConfigContext, ''); + + expect(nullConfigContext.ui.setDebugMessage).toHaveBeenCalledWith( + 'Clearing terminal.', + ); + expect(mockResetChat).not.toHaveBeenCalled(); + expect(uiTelemetryService.clear).toHaveBeenCalled(); + expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1); + expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..6755dde030a50eef4c035a011e0d1febe78a81d9 --- /dev/null +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + uiTelemetryService, + SessionEndReason, + SessionStartSource, + flushTelemetry, + resetBrowserSession, +} from '@google/gemini-cli-core'; +import { CommandKind, type SlashCommand } from './types.js'; +import { MessageType } from '../types.js'; +import { randomUUID } from 'node:crypto'; + +export const clearCommand: SlashCommand = { + name: 'clear', + altNames: ['new'], + description: 'Clear the screen and start a new session', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context, _args) => { + const geminiClient = context.services.agentContext?.geminiClient; + const config = context.services.agentContext?.config; + + // Fire SessionEnd hook before clearing + const hookSystem = config?.getHookSystem(); + if (hookSystem) { + await hookSystem.fireSessionEndEvent(SessionEndReason.Clear); + } + + // Reset user steering hints + config?.injectionService.clear(); + + // Start a new conversation recording with a new session ID + // We MUST do this before calling resetChat() so the new ChatRecordingService + // initialized by GeminiChat picks up the new session ID. + let newSessionId: string | undefined; + if (config) { + newSessionId = randomUUID(); + config.resetNewSessionState(newSessionId); + } + + if (geminiClient) { + context.ui.setDebugMessage('Clearing terminal and resetting chat.'); + + // Close persistent browser sessions before resetting chat + await resetBrowserSession(); + + // If resetChat fails, the exception will propagate and halt the command, + // which is the correct behavior to signal a failure to the user. + await geminiClient.resetChat(); + } else { + context.ui.setDebugMessage('Clearing terminal.'); + } + + // Fire SessionStart hook after clearing + let result; + if (hookSystem) { + result = await hookSystem.fireSessionStartEvent(SessionStartSource.Clear); + } + + // Give the event loop a chance to process any pending telemetry operations + // This ensures logger.emit() calls have fully propagated to the BatchLogRecordProcessor + await new Promise((resolve) => setImmediate(resolve)); + + // Flush telemetry to ensure hooks are written to disk immediately + // This is critical for tests and environments with I/O latency + if (config) { + await flushTelemetry(config); + } + + uiTelemetryService.clear(newSessionId); + context.ui.clear(); + + if (result?.systemMessage) { + context.ui.addItem( + { + type: MessageType.INFO, + text: result.systemMessage, + }, + Date.now(), + ); + } + }, +}; diff --git a/packages/cli/src/ui/commands/commandsCommand.test.ts b/packages/cli/src/ui/commands/commandsCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ccbf36e33d2a76b7def6502a328a52ead912692 --- /dev/null +++ b/packages/cli/src/ui/commands/commandsCommand.test.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Storage, type Config } from '@google/gemini-cli-core'; +import { commandsCommand } from './commandsCommand.js'; +import { MessageType } from '../types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { CommandContext } from './types.js'; +import { FileCommandLoader } from '../../services/FileCommandLoader.js'; + +vi.mock('../../services/FileCommandLoader.js'); + +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual< + typeof import('@google/gemini-cli-core') + >('@google/gemini-cli-core'); + return { + ...actual, + Storage: class extends actual.Storage { + static override getUserCommandsDir() { + return '/mock/user/commands'; + } + override getProjectCommandsDir() { + return '/mock/project/commands'; + } + }, + }; +}); + +describe('commandsCommand', () => { + let context: CommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + context = createMockCommandContext({ + ui: { + reloadCommands: vi.fn(), + addItem: vi.fn(), + }, + services: { + agentContext: { + getProjectRoot: vi.fn().mockReturnValue('/mock/project'), + getFolderTrust: vi.fn().mockReturnValue(false), + isTrustedFolder: vi.fn().mockReturnValue(false), + getExtensions: vi.fn().mockReturnValue([ + { name: 'ext1', path: '/mock/ext1', isActive: true }, + { name: 'ext2', path: '/mock/ext2', isActive: false }, + ]), + storage: new Storage('/mock/project'), + } as unknown as Config, + }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('default action', () => { + it('should return an info message prompting subcommand usage', async () => { + const result = await commandsCommand.action!(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Use "/commands list" to view available .toml files, or "/commands reload" to reload custom command definitions.', + }); + }); + }); + + describe('list', () => { + it('should list .toml files from available sources', async () => { + vi.mocked( + FileCommandLoader.prototype.listAvailableFiles, + ).mockResolvedValue([ + { + displayName: 'User', + path: '/mock/user/commands', + files: ['user1.toml'], + }, + { + displayName: 'Project', + path: '/mock/project/commands', + files: ['proj1.toml'], + }, + { + displayName: 'Extension: ext1', + path: '/mock/ext1/commands', + files: ['ext1.toml'], + }, + ]); + + const listCmd = commandsCommand.subCommands!.find( + (s) => s.name === 'list', + )!; + + await listCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.any(String), + }), + expect.any(Number), + ); + + // Snapshot the text content + const addItemCall = vi.mocked(context.ui.addItem).mock.calls[0][0]; + + expect((addItemCall as { text: string }).text).toMatchSnapshot(); + }); + + it('should show "No custom command files found" message if no .toml files exist', async () => { + vi.mocked( + FileCommandLoader.prototype.listAvailableFiles, + ).mockResolvedValue([]); + + const listCmd = commandsCommand.subCommands!.find( + (s) => s.name === 'list', + )!; + + const result = await listCmd.action!(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining( + 'No custom command files (.toml) found.', + ), + }); + }); + }); + + describe('reload', () => { + it('should call reloadCommands and show a success message', async () => { + const reloadCmd = commandsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + + await reloadCmd.action!(context, ''); + + expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Custom commands reloaded successfully.', + }), + expect.any(Number), + ); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/commandsCommand.ts b/packages/cli/src/ui/commands/commandsCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..32658b691a5dd2e4c6604958680d4a5d697e1a90 --- /dev/null +++ b/packages/cli/src/ui/commands/commandsCommand.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FileCommandLoader } from '../../services/FileCommandLoader.js'; +import { + type CommandContext, + type SlashCommand, + type SlashCommandActionReturn, + CommandKind, +} from './types.js'; +import { + MessageType, + type HistoryItemError, + type HistoryItemInfo, +} from '../types.js'; + +/** + * Action for the default `/commands` invocation. + * Displays a message prompting the user to use a subcommand. + */ +async function defaultAction( + _context: CommandContext, + _args: string, +): Promise { + return { + type: 'message', + messageType: 'info', + content: + 'Use "/commands list" to view available .toml files, or "/commands reload" to reload custom command definitions.', + }; +} + +/** + * Action for `/commands list`. + * Lists available .toml command files from user, project, and extension directories. + */ +async function listSubcommandAction( + context: CommandContext, +): Promise { + try { + const config = context.services.agentContext?.config ?? null; + const loader = new FileCommandLoader(config); + const groups = await loader.listAvailableFiles(); + + const results: string[] = []; + for (const group of groups) { + results.push(`### ${group.displayName} Commands (${group.path})`); + if (group.error) { + results.push(`- (Error reading directory: ${group.error})`); + } else { + group.files.forEach((file) => results.push(`- ${file}`)); + } + } + + results.push( + '\n_Note: MCP prompts are dynamically loaded from configured MCP servers._', + ); + + if (results.length === 1) { + // Only the note is present + return { + type: 'message', + messageType: 'info', + content: + 'No custom command files (.toml) found.\n\n_Note: MCP prompts are dynamically loaded from configured MCP servers._', + }; + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: results.join('\n'), + } as HistoryItemInfo, + Date.now(), + ); + } catch (error) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to list commands: ${error instanceof Error ? error.message : String(error)}`, + } as HistoryItemError, + Date.now(), + ); + } +} + +/** + * Action for `/commands reload`. + * Triggers a full re-discovery and reload of all slash commands, including + * user/project-level .toml files, MCP prompts, and extension commands. + */ +async function reloadAction( + context: CommandContext, +): Promise { + try { + context.ui.reloadCommands(); + + context.ui.addItem( + { + type: MessageType.INFO, + text: 'Custom commands reloaded successfully.', + } as HistoryItemInfo, + Date.now(), + ); + } catch (error) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to reload commands: ${error instanceof Error ? error.message : String(error)}`, + } as HistoryItemError, + Date.now(), + ); + } +} + +export const commandsCommand: SlashCommand = { + name: 'commands', + description: 'Manage custom slash commands. Usage: /commands [list|reload]', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [ + { + name: 'list', + description: + 'List available custom command .toml files. Usage: /commands list', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: listSubcommandAction, + }, + { + name: 'reload', + altNames: ['refresh'], + description: + 'Reload custom command definitions from .toml files. Usage: /commands reload', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: reloadAction, + }, + ], + action: defaultAction, +}; diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c91c42b8c41fd37c5094fede02eaee9fa7989728 --- /dev/null +++ b/packages/cli/src/ui/commands/compressCommand.test.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CompressionStatus, + type ChatCompressionInfo, + type GeminiClient, +} from '@google/gemini-cli-core'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { compressCommand } from './compressCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; + +describe('compressCommand', () => { + let context: ReturnType; + let mockTryCompressChat: ReturnType; + + beforeEach(() => { + mockTryCompressChat = vi.fn(); + context = createMockCommandContext({ + services: { + agentContext: { + geminiClient: { + tryCompressChat: mockTryCompressChat, + } as unknown as GeminiClient, + }, + }, + }); + }); + + it('should do nothing if a compression is already pending', async () => { + context.ui.pendingItem = { + type: MessageType.COMPRESSION, + compression: { + isPending: true, + originalTokenCount: null, + newTokenCount: null, + compressionStatus: null, + }, + }; + await compressCommand.action!(context, ''); + await new Promise((r) => setTimeout(r, 0)); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Already compressing, wait for previous request to complete', + }), + expect.any(Number), + ); + expect(context.ui.setPendingItem).not.toHaveBeenCalled(); + expect(mockTryCompressChat).not.toHaveBeenCalled(); + }); + + it('should set pending item, call tryCompressChat, and add result on success', async () => { + const compressedResult: ChatCompressionInfo = { + originalTokenCount: 200, + compressionStatus: CompressionStatus.COMPRESSED, + newTokenCount: 100, + }; + mockTryCompressChat.mockResolvedValue(compressedResult); + + await compressCommand.action!(context, ''); + await new Promise((r) => setTimeout(r, 0)); + + expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(1, { + type: MessageType.COMPRESSION, + compression: { + isPending: true, + compressionStatus: null, + originalTokenCount: null, + newTokenCount: null, + }, + }); + + expect(mockTryCompressChat).toHaveBeenCalledWith( + expect.stringMatching(/^compress-\d+$/), + true, + ); + + expect(context.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.COMPRESSION, + compression: { + isPending: false, + compressionStatus: CompressionStatus.COMPRESSED, + originalTokenCount: 200, + newTokenCount: 100, + }, + }, + expect.any(Number), + ); + + expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(2, null); + }); + + it('should add an error message if tryCompressChat returns falsy', async () => { + mockTryCompressChat.mockResolvedValue(null); + + await compressCommand.action!(context, ''); + await new Promise((r) => setTimeout(r, 0)); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Failed to compress chat history.', + }), + expect.any(Number), + ); + expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); + }); + + it('should add an error message if tryCompressChat throws', async () => { + const error = new Error('Compression failed'); + mockTryCompressChat.mockRejectedValue(error); + + await compressCommand.action!(context, ''); + await new Promise((r) => setTimeout(r, 0)); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: `Failed to compress chat history: ${error.message}`, + }), + expect.any(Number), + ); + expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); + }); + + it('should clear the pending item in a finally block', async () => { + mockTryCompressChat.mockRejectedValue(new Error('some error')); + await compressCommand.action!(context, ''); + await new Promise((r) => setTimeout(r, 0)); + expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); + }); + + describe('metadata', () => { + it('should have the correct name and aliases', () => { + expect(compressCommand.name).toBe('compress'); + expect(compressCommand.altNames).toContain('summarize'); + expect(compressCommand.altNames).toContain('compact'); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..37ffc4930a085def5f36da65fd3c3bf83a05ae38 --- /dev/null +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MessageType, type HistoryItemCompression } from '../types.js'; +import { CommandKind, type SlashCommand } from './types.js'; + +export const compressCommand: SlashCommand = { + name: 'compress', + altNames: ['summarize', 'compact'], + description: 'Compresses the context by replacing it with a summary', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context) => { + const { ui } = context; + if (ui.pendingItem) { + ui.addItem( + { + type: MessageType.ERROR, + text: 'Already compressing, wait for previous request to complete', + }, + Date.now(), + ); + return; + } + + const pendingMessage: HistoryItemCompression = { + type: MessageType.COMPRESSION, + compression: { + isPending: true, + originalTokenCount: null, + newTokenCount: null, + compressionStatus: null, + }, + }; + + ui.setPendingItem(pendingMessage); + + void (async () => { + try { + const promptId = `compress-${Date.now()}`; + const compressed = + await context.services.agentContext?.geminiClient?.tryCompressChat( + promptId, + true, + ); + if (compressed) { + ui.addItem( + { + type: MessageType.COMPRESSION, + compression: { + isPending: false, + originalTokenCount: compressed.originalTokenCount, + newTokenCount: compressed.newTokenCount, + compressionStatus: compressed.compressionStatus, + }, + } as HistoryItemCompression, + Date.now(), + ); + } else { + ui.addItem( + { + type: MessageType.ERROR, + text: 'Failed to compress chat history.', + }, + Date.now(), + ); + } + } catch (e) { + ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to compress chat history: ${ + e instanceof Error ? e.message : String(e) + }`, + }, + Date.now(), + ); + } finally { + ui.setPendingItem(null); + } + })(); + }, +}; diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a1d36ca217ae5d859d5e4ad274e98b4c9066d84 --- /dev/null +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -0,0 +1,317 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; +import { copyCommand } from './copyCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { copyToClipboard } from '../utils/commandUtils.js'; + +vi.mock('../utils/commandUtils.js', () => ({ + copyToClipboard: vi.fn(), +})); + +describe('copyCommand', () => { + let mockContext: CommandContext; + let mockCopyToClipboard: Mock; + let mockGetChat: Mock; + let mockGetHistory: Mock; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCopyToClipboard = vi.mocked(copyToClipboard); + mockGetChat = vi.fn(); + mockGetHistory = vi.fn(); + + mockContext = createMockCommandContext({ + services: { + agentContext: { + geminiClient: { + getChat: mockGetChat, + }, + }, + }, + }); + + mockGetChat.mockReturnValue({ + getHistory: mockGetHistory, + }); + }); + + it('should return info message when no history is available', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetChat.mockReturnValue(undefined); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No output in history', + }); + + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + + it('should return info message when history is empty', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([]); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No output in history', + }); + + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + + it('should return info message when no AI messages are found in history', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithUserOnly = [ + { + role: 'user', + parts: [{ text: 'Hello' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithUserOnly); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No output in history', + }); + + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + + it('should copy last AI message to clipboard successfully', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithAiMessage = [ + { + role: 'user', + parts: [{ text: 'Hello' }], + }, + { + role: 'model', + parts: [{ text: 'Hi there! How can I help you?' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithAiMessage); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Last output copied to the clipboard', + }); + + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'Hi there! How can I help you?', + expect.anything(), + ); + }); + + it('should handle multiple text parts in AI message', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithMultipleParts = [ + { + role: 'model', + parts: [{ text: 'Part 1: ' }, { text: 'Part 2: ' }, { text: 'Part 3' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithMultipleParts); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, ''); + + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'Part 1: Part 2: Part 3', + expect.anything(), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Last output copied to the clipboard', + }); + }); + + it('should filter out non-text parts', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithMixedParts = [ + { + role: 'model', + parts: [ + { text: 'Text part' }, + { image: 'base64data' }, // Non-text part + { text: ' more text' }, + ], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithMixedParts); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, ''); + + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'Text part more text', + expect.anything(), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Last output copied to the clipboard', + }); + }); + + it('should get the last AI message when multiple AI messages exist', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithMultipleAiMessages = [ + { + role: 'model', + parts: [{ text: 'First AI response' }], + }, + { + role: 'user', + parts: [{ text: 'User message' }], + }, + { + role: 'model', + parts: [{ text: 'Second AI response' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithMultipleAiMessages); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, ''); + + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'Second AI response', + expect.anything(), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Last output copied to the clipboard', + }); + }); + + it('should handle clipboard copy error', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithAiMessage = [ + { + role: 'model', + parts: [{ text: 'AI response' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithAiMessage); + const clipboardError = new Error('Clipboard access denied'); + mockCopyToClipboard.mockRejectedValue(clipboardError); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: `Failed to copy to the clipboard. ${clipboardError.message}`, + }); + + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'AI response', + expect.anything(), + ); + }); + + it('should handle non-Error clipboard errors', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithAiMessage = [ + { + role: 'model', + parts: [{ text: 'AI response' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithAiMessage); + const rejectedValue = 'String error'; + mockCopyToClipboard.mockRejectedValue(rejectedValue); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: `Failed to copy to the clipboard. ${rejectedValue}`, + }); + + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'AI response', + expect.anything(), + ); + }); + + it('should return info message when no text parts found in AI message', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const historyWithEmptyParts = [ + { + role: 'model', + parts: [{ image: 'base64data' }], // No text parts + }, + ]; + + mockGetHistory.mockReturnValue(historyWithEmptyParts); + + const result = await copyCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Last AI output contains no text to copy.', + }); + + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + + it('should handle unavailable config service', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const nullConfigContext = createMockCommandContext({ + services: { agentContext: null }, + }); + + const result = await copyCommand.action(nullConfigContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No output in history', + }); + + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..746d6899a6ada03b2b559cc975e34d5ef9e043cc --- /dev/null +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger } from '@google/gemini-cli-core'; +import { copyToClipboard } from '../utils/commandUtils.js'; +import { + CommandKind, + type SlashCommand, + type SlashCommandActionReturn, +} from './types.js'; + +export const copyCommand: SlashCommand = { + name: 'copy', + description: 'Copy the last result or code snippet to clipboard', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context, _args): Promise => { + const chat = context.services.agentContext?.geminiClient?.getChat(); + const history = chat?.getHistory(); + + // Get the last message from the AI (model role) + const lastAiMessage = history + ? history.filter((item) => item.role === 'model').pop() + : undefined; + + if (!lastAiMessage) { + return { + type: 'message', + messageType: 'info', + content: 'No output in history', + }; + } + // Extract text from the parts + const lastAiOutput = lastAiMessage.parts + ?.filter((part) => part.text) + .map((part) => part.text) + .join(''); + + if (lastAiOutput) { + try { + const settings = context.services.settings.merged; + await copyToClipboard(lastAiOutput, settings); + + return { + type: 'message', + messageType: 'info', + content: 'Last output copied to the clipboard', + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + debugLogger.debug(message); + + return { + type: 'message', + messageType: 'error', + content: `Failed to copy to the clipboard. ${message}`, + }; + } + } else { + return { + type: 'message', + messageType: 'info', + content: 'Last AI output contains no text to copy.', + }; + } + }, +}; diff --git a/packages/cli/src/ui/commands/corgiCommand.test.ts b/packages/cli/src/ui/commands/corgiCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1976ca369873bb372c4180caf07aa81992f6a198 --- /dev/null +++ b/packages/cli/src/ui/commands/corgiCommand.test.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { corgiCommand } from './corgiCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('corgiCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + vi.spyOn(mockContext.ui, 'toggleCorgiMode'); + }); + + it('should call the toggleCorgiMode function on the UI context', async () => { + if (!corgiCommand.action) { + throw new Error('The corgi command must have an action.'); + } + + await corgiCommand.action(mockContext, ''); + + expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1); + }); + + it('should have the correct name and description', () => { + expect(corgiCommand.name).toBe('corgi'); + expect(corgiCommand.description).toBe('Toggles corgi mode'); + }); +}); diff --git a/packages/cli/src/ui/commands/corgiCommand.ts b/packages/cli/src/ui/commands/corgiCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..87f1dd94438798db6c6802b5a7c740db9109fe7f --- /dev/null +++ b/packages/cli/src/ui/commands/corgiCommand.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from './types.js'; + +export const corgiCommand: SlashCommand = { + name: 'corgi', + description: 'Toggles corgi mode', + hidden: true, + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (context, _args) => { + context.ui.toggleCorgiMode(); + }, +}; diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dd5b8a471a0bdebf4eb106e2a26d2c29b5231aae --- /dev/null +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -0,0 +1,461 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { directoryCommand } from './directoryCommand.js'; +import { + expandHomeDir, + getDirectorySuggestions, +} from '../utils/directoryUtils.js'; +import type { Config, WorkspaceContext } from '@google/gemini-cli-core'; +import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js'; +import type { CommandContext, OpenCustomDialogActionReturn } from './types.js'; +import { MessageType } from '../types.js'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as trustedFolders from '../../config/trustedFolders.js'; +import type { LoadedTrustedFolders } from '../../config/trustedFolders.js'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + realpathSync: vi.fn((p) => p), + }; +}); + +vi.mock('../utils/directoryUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getDirectorySuggestions: vi.fn(), + }; +}); + +describe('directoryCommand', () => { + let mockContext: CommandContext; + let mockConfig: Config; + let mockWorkspaceContext: WorkspaceContext; + const addCommand = directoryCommand.subCommands?.find( + (c) => c.name === 'add', + ); + const showCommand = directoryCommand.subCommands?.find( + (c) => c.name === 'show', + ); + + beforeEach(() => { + mockWorkspaceContext = { + targetDir: path.resolve('/test/dir'), + addDirectory: vi.fn(), + addDirectories: vi.fn().mockReturnValue({ added: [], failed: [] }), + getDirectories: vi + .fn() + .mockReturnValue([ + path.resolve('/home/user/project1'), + path.resolve('/home/user/project2'), + ]), + } as unknown as WorkspaceContext; + + mockConfig = { + getWorkspaceContext: () => mockWorkspaceContext, + isRestrictiveSandbox: vi.fn().mockReturnValue(false), + getGeminiClient: vi.fn().mockReturnValue({ + addDirectoryContext: vi.fn(), + getChatRecordingService: vi.fn().mockReturnValue({ + recordDirectories: vi.fn(), + }), + }), + getWorkingDir: () => path.resolve('/test/dir'), + shouldLoadMemoryFromIncludeDirectories: () => false, + getMemoryContextManager: vi.fn(), + getDebugMode: () => false, + getFileService: () => ({}), + getFileFilteringOptions: () => ({ ignore: [], include: [] }), + setUserMemory: vi.fn(), + setGeminiMdFileCount: vi.fn(), + get config() { + return this; + }, + } as unknown as Config; + + mockContext = { + services: { + agentContext: mockConfig, + settings: { + merged: { + memoryDiscoveryMaxDirs: 1000, + security: { + folderTrust: { + enabled: false, + }, + }, + }, + }, + }, + ui: { + addItem: vi.fn(), + }, + } as unknown as CommandContext; + }); + + describe('show', () => { + it('should display the list of directories', () => { + if (!showCommand?.action) throw new Error('No action'); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + showCommand.action(mockContext, ''); + expect(mockWorkspaceContext.getDirectories).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Current workspace directories:\n- ${path.resolve( + '/home/user/project1', + )}\n- ${path.resolve('/home/user/project2')}`, + }), + ); + }); + }); + + describe('add', () => { + it('should show an error in a restrictive sandbox', async () => { + if (!addCommand?.action) throw new Error('No action'); + vi.mocked(mockConfig.isRestrictiveSandbox).mockReturnValue(true); + const result = await addCommand.action(mockContext, '/some/path'); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.', + }); + }); + + it('should show an error if no path is provided', () => { + if (!addCommand?.action) throw new Error('No action'); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + addCommand.action(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Please provide at least one path to add.', + }), + ); + }); + + it('should call addDirectory and show a success message for a single path', async () => { + const newPath = path.resolve('/home/user/new-project'); + vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ + added: [newPath], + failed: [], + }); + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, newPath); + expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([ + newPath, + ]); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${newPath}`, + }), + ); + }); + + it('should call addDirectory for each path and show a success message for multiple paths', async () => { + const newPath1 = path.resolve('/home/user/new-project1'); + const newPath2 = path.resolve('/home/user/new-project2'); + vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ + added: [newPath1, newPath2], + failed: [], + }); + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, `${newPath1},${newPath2}`); + expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([ + newPath1, + newPath2, + ]); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${newPath1}\n- ${newPath2}`, + }), + ); + }); + + it('should show an error if addDirectory throws an exception', async () => { + const error = new Error('Directory does not exist'); + const newPath = path.resolve('/home/user/invalid-project'); + vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ + added: [], + failed: [{ path: newPath, error }], + }); + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, newPath); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: `Error adding '${newPath}': ${error.message}`, + }), + ); + }); + + it('should add directory directly when folder trust is disabled', async () => { + if (!addCommand?.action) throw new Error('No action'); + vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(false); + const newPath = path.resolve('/home/user/new-project'); + vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ + added: [newPath], + failed: [], + }); + + await addCommand.action(mockContext, newPath); + + expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([ + newPath, + ]); + }); + + it('should show an info message for an already added directory', async () => { + const existingPath = path.resolve('/home/user/project1'); + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, existingPath); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `The following directories are already in the workspace:\n- ${existingPath}`, + }), + ); + expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalledWith( + existingPath, + ); + }); + + it('should show an info message for an already added directory specified as a relative path', async () => { + const existingPath = path.resolve('/home/user/project1'); + const relativePath = './project1'; + const absoluteRelativePath = path.resolve( + path.resolve('/test/dir'), + relativePath, + ); + + vi.mocked(fs.realpathSync).mockImplementation((p) => { + if (p === absoluteRelativePath) return existingPath; + return p as string; + }); + + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, relativePath); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `The following directories are already in the workspace:\n- ${relativePath}`, + }), + ); + }); + + it('should handle a mix of successful and failed additions', async () => { + const validPath = path.resolve('/home/user/valid-project'); + const invalidPath = path.resolve('/home/user/invalid-project'); + const error = new Error('Directory does not exist'); + vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ + added: [validPath], + failed: [{ path: invalidPath, error }], + }); + + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, `${validPath},${invalidPath}`); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${validPath}`, + }), + ); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: `Error adding '${invalidPath}': ${error.message}`, + }), + ); + }); + + describe('completion', () => { + const completion = addCommand!.completion!; + + it('should return empty suggestions for an empty path', async () => { + const results = await completion(mockContext, ''); + expect(results).toEqual([]); + }); + + it('should return empty suggestions for whitespace only path', async () => { + const results = await completion(mockContext, ' '); + expect(results).toEqual([]); + }); + + it('should return suggestions for a single path', async () => { + vi.mocked(getDirectorySuggestions).mockResolvedValue(['docs/', 'src/']); + + const results = await completion(mockContext, 'd'); + + expect(getDirectorySuggestions).toHaveBeenCalledWith('d'); + expect(results).toEqual(['docs/', 'src/']); + }); + + it('should return suggestions for multiple paths', async () => { + vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']); + + const results = await completion(mockContext, 'docs/,s'); + + expect(getDirectorySuggestions).toHaveBeenCalledWith('s'); + expect(results).toEqual(['docs/,src/']); + }); + + it('should handle leading whitespace in suggestions', async () => { + vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']); + + const results = await completion(mockContext, 'docs/, s'); + + expect(getDirectorySuggestions).toHaveBeenCalledWith('s'); + expect(results).toEqual(['docs/, src/']); + }); + + it('should filter out existing directories from suggestions', async () => { + const existingPath = path.resolve(process.cwd(), 'existing'); + vi.mocked(mockWorkspaceContext.getDirectories).mockReturnValue([ + existingPath, + ]); + vi.mocked(getDirectorySuggestions).mockResolvedValue([ + 'existing/', + 'new/', + ]); + + const results = await completion(mockContext, 'ex'); + + expect(results).toEqual(['new/']); + }); + }); + }); + + describe('add with folder trust enabled', () => { + let mockIsPathTrusted: Mock; + + beforeEach(() => { + vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(true); + // isWorkspaceTrusted is no longer checked, so we don't need to mock it returning true + mockIsPathTrusted = vi.fn(); + const mockLoadedFolders = { + isPathTrusted: mockIsPathTrusted, + } as unknown as LoadedTrustedFolders; + vi.spyOn(trustedFolders, 'loadTrustedFolders').mockReturnValue( + mockLoadedFolders, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should add a trusted directory', async () => { + if (!addCommand?.action) throw new Error('No action'); + mockIsPathTrusted.mockReturnValue(true); + const newPath = path.resolve('/home/user/trusted-project'); + vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ + added: [newPath], + failed: [], + }); + + await addCommand.action(mockContext, newPath); + + expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([ + newPath, + ]); + }); + + it('should return a custom dialog for an explicitly untrusted directory (upgrade flow)', async () => { + if (!addCommand?.action) throw new Error('No action'); + mockIsPathTrusted.mockReturnValue(false); // DO_NOT_TRUST + const newPath = path.resolve('/home/user/untrusted-project'); + + const result = await addCommand.action(mockContext, newPath); + + expect(result).toEqual( + expect.objectContaining({ + type: 'custom_dialog', + component: expect.objectContaining({ + type: expect.any(Function), // React component for MultiFolderTrustDialog + }), + }), + ); + if (!result) { + throw new Error('Command did not return a result'); + } + const component = (result as OpenCustomDialogActionReturn) + .component as React.ReactElement; + expect(component.props.folders.includes(newPath)).toBeTruthy(); + }); + + it('should return a custom dialog for a directory with undefined trust', async () => { + if (!addCommand?.action) throw new Error('No action'); + mockIsPathTrusted.mockReturnValue(undefined); + const newPath = path.resolve('/home/user/undefined-trust-project'); + + const result = await addCommand.action(mockContext, newPath); + + expect(result).toEqual( + expect.objectContaining({ + type: 'custom_dialog', + component: expect.objectContaining({ + type: expect.any(Function), // React component for MultiFolderTrustDialog + }), + }), + ); + if (!result) { + throw new Error('Command did not return a result'); + } + const component = (result as OpenCustomDialogActionReturn) + .component as React.ReactElement; + expect(component.props.folders.includes(newPath)).toBeTruthy(); + }); + + it('should prompt for directory even if workspace is untrusted', async () => { + if (!addCommand?.action) throw new Error('No action'); + // Even if workspace is untrusted, we should still check directory trust + vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ + isTrusted: false, + source: 'file', + }); + mockIsPathTrusted.mockReturnValue(undefined); + const newPath = path.resolve('/home/user/new-project'); + + const result = await addCommand.action(mockContext, newPath); + + expect(result).toEqual( + expect.objectContaining({ + type: 'custom_dialog', + }), + ); + }); + }); + + it('should correctly expand a Windows-style home directory path', () => { + const windowsPath = '%userprofile%\\Documents'; + const expectedPath = path.win32.join(os.homedir(), 'Documents'); + const result = expandHomeDir(windowsPath); + expect(path.win32.normalize(result)).toBe( + path.win32.normalize(expectedPath), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ed1ded95008b5445fccaab2098da2f0eb031aa17 --- /dev/null +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -0,0 +1,299 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + isFolderTrustEnabled, + loadTrustedFolders, +} from '../../config/trustedFolders.js'; +import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js'; +import { + CommandKind, + type SlashCommand, + type CommandContext, +} from './types.js'; +import { MessageType, type HistoryItem } from '../types.js'; +import { type Config } from '@google/gemini-cli-core'; +import { + expandHomeDir, + getDirectorySuggestions, + batchAddDirectories, +} from '../utils/directoryUtils.js'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; + +async function finishAddingDirectories( + config: Config, + addItem: ( + itemData: Omit, + baseTimestamp?: number, + ) => number, + added: string[], + errors: string[], +) { + if (!config) { + addItem({ + type: MessageType.ERROR, + text: 'Configuration is not available.', + }); + return; + } + + if (added.length > 0) { + try { + if (config.shouldLoadMemoryFromIncludeDirectories()) { + await config.getMemoryContextManager()?.refresh(); + } + addItem({ + type: MessageType.INFO, + text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`, + }); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + errors.push(`Error refreshing memory: ${(error as Error).message}`); + } + } + + if (added.length > 0) { + const gemini = config.geminiClient; + if (gemini) { + await gemini.addDirectoryContext(); + + // Persist directories to session file for resume support + const chatRecordingService = gemini.getChatRecordingService(); + const workspaceContext = config.getWorkspaceContext(); + chatRecordingService?.recordDirectories( + workspaceContext.getDirectories(), + ); + } + addItem({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${added.join('\n- ')}`, + }); + } + + if (errors.length > 0) { + addItem({ type: MessageType.ERROR, text: errors.join('\n') }); + } +} + +export const directoryCommand: SlashCommand = { + name: 'directory', + altNames: ['dir'], + description: 'Manage workspace directories', + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'add', + description: + 'Add directories to the workspace. Use comma to separate multiple paths', + kind: CommandKind.BUILT_IN, + autoExecute: false, + showCompletionLoading: false, + completion: async (context: CommandContext, partialArg: string) => { + // Support multiple paths separated by commas + const parts = partialArg.split(','); + const lastPart = parts[parts.length - 1]; + const leadingWhitespace = lastPart.match(/^\s*/)?.[0] ?? ''; + const trimmedLastPart = lastPart.trimStart(); + + if (trimmedLastPart === '') { + return []; + } + + const suggestions = await getDirectorySuggestions(trimmedLastPart); + + // Filter out existing directories + let filteredSuggestions = suggestions; + if (context.services.agentContext?.config) { + const workspaceContext = + context.services.agentContext.config.getWorkspaceContext(); + const existingDirs = new Set( + workspaceContext.getDirectories().map((dir) => path.resolve(dir)), + ); + + filteredSuggestions = suggestions.filter((s) => { + const expanded = expandHomeDir(s); + const absolute = path.resolve(expanded); + + if (existingDirs.has(absolute)) { + return false; + } + if ( + absolute.endsWith(path.sep) && + existingDirs.has(absolute.slice(0, -1)) + ) { + return false; + } + return true; + }); + } + + if (parts.length > 1) { + const prefix = parts.slice(0, -1).join(',') + ','; + return filteredSuggestions.map((s) => prefix + leadingWhitespace + s); + } + + return filteredSuggestions.map((s) => leadingWhitespace + s); + }, + action: async (context: CommandContext, args: string) => { + const { + ui: { addItem }, + services: { agentContext, settings }, + } = context; + const [...rest] = args.split(' '); + + if (!agentContext) { + addItem({ + type: MessageType.ERROR, + text: 'Configuration is not available.', + }); + return; + } + + if (agentContext.config.isRestrictiveSandbox()) { + return { + type: 'message' as const, + messageType: 'error' as const, + content: + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.', + }; + } + + const pathsToAdd = rest + .join(' ') + .split(',') + .filter((p) => p); + if (pathsToAdd.length === 0) { + addItem({ + type: MessageType.ERROR, + text: 'Please provide at least one path to add.', + }); + return; + } + + const added: string[] = []; + const errors: string[] = []; + const alreadyAdded: string[] = []; + + const workspaceContext = agentContext.config.getWorkspaceContext(); + const currentWorkspaceDirs = workspaceContext.getDirectories(); + const pathsToProcess: string[] = []; + + for (const pathToAdd of pathsToAdd) { + const trimmedPath = pathToAdd.trim(); + const expandedPath = expandHomeDir(trimmedPath); + try { + const absolutePath = path.resolve( + workspaceContext.targetDir, + expandedPath, + ); + const resolvedPath = fs.realpathSync(absolutePath); + if (currentWorkspaceDirs.includes(resolvedPath)) { + alreadyAdded.push(trimmedPath); + continue; + } + } catch { + // Path might not exist or be inaccessible. + // We'll let batchAddDirectories handle it later. + } + pathsToProcess.push(trimmedPath); + } + + if (alreadyAdded.length > 0) { + addItem({ + type: MessageType.INFO, + text: `The following directories are already in the workspace:\n- ${alreadyAdded.join( + '\n- ', + )}`, + }); + } + + if (pathsToProcess.length === 0) { + return; + } + + if (isFolderTrustEnabled(settings.merged)) { + const trustedFolders = loadTrustedFolders(); + const dirsToConfirm: string[] = []; + const trustedDirs: string[] = []; + + for (const pathToAdd of pathsToProcess) { + const expandedPath = path.resolve(expandHomeDir(pathToAdd.trim())); + const isTrusted = trustedFolders.isPathTrusted(expandedPath); + // If explicitly trusted, add immediately. + // If undefined or explicitly untrusted (DO_NOT_TRUST), prompt for confirmation. + // This allows users to "upgrade" a DO_NOT_TRUST folder to trusted via the dialog. + if (isTrusted === true) { + trustedDirs.push(pathToAdd.trim()); + } else { + dirsToConfirm.push(pathToAdd.trim()); + } + } + + if (trustedDirs.length > 0) { + const result = batchAddDirectories(workspaceContext, trustedDirs); + added.push(...result.added); + errors.push(...result.errors); + } + + if (dirsToConfirm.length > 0) { + return { + type: 'custom_dialog', + component: ( + + ), + }; + } + } else { + const result = batchAddDirectories(workspaceContext, pathsToProcess); + added.push(...result.added); + errors.push(...result.errors); + } + + await finishAddingDirectories( + agentContext.config, + addItem, + added, + errors, + ); + return; + }, + }, + { + name: 'show', + description: 'Show all directories in the workspace', + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext) => { + const { + ui: { addItem }, + services: { agentContext }, + } = context; + if (!agentContext) { + addItem({ + type: MessageType.ERROR, + text: 'Configuration is not available.', + }); + return; + } + const workspaceContext = agentContext.config.getWorkspaceContext(); + const directories = workspaceContext.getDirectories(); + const directoryList = directories.map((dir) => `- ${dir}`).join('\n'); + addItem({ + type: MessageType.INFO, + text: `Current workspace directories:\n${directoryList}`, + }); + }, + }, + ], +}; diff --git a/packages/cli/src/ui/commands/docsCommand.test.ts b/packages/cli/src/ui/commands/docsCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..58e2e17335e41bedc0cf8d9438ce9faf737099c3 --- /dev/null +++ b/packages/cli/src/ui/commands/docsCommand.test.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import open from 'open'; +import { docsCommand } from './docsCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; + +// Mock the 'open' library +vi.mock('open', () => ({ + default: vi.fn(), +})); + +describe('docsCommand', () => { + let mockContext: CommandContext; + beforeEach(() => { + // Create a fresh mock context before each test + mockContext = createMockCommandContext(); + // Reset the `open` mock + vi.mocked(open).mockClear(); + }); + + afterEach(() => { + // Restore any stubbed environment variables + vi.unstubAllEnvs(); + }); + + it("should add an info message and call 'open' in a non-sandbox environment", async () => { + if (!docsCommand.action) { + throw new Error('docsCommand must have an action.'); + } + + const docsUrl = 'https://goo.gle/gemini-cli-docs'; + + await docsCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Opening documentation in your browser: ${docsUrl}`, + }, + expect.any(Number), + ); + + expect(open).toHaveBeenCalledWith(docsUrl); + }); + + it('should only add an info message in a sandbox environment', async () => { + if (!docsCommand.action) { + throw new Error('docsCommand must have an action.'); + } + + // Simulate a sandbox environment + vi.stubEnv('SANDBOX', 'gemini-sandbox'); + const docsUrl = 'https://goo.gle/gemini-cli-docs'; + + await docsCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`, + }, + expect.any(Number), + ); + + // Ensure 'open' was not called in the sandbox + expect(open).not.toHaveBeenCalled(); + }); + + it("should not open browser for 'sandbox-exec'", async () => { + if (!docsCommand.action) { + throw new Error('docsCommand must have an action.'); + } + + // Simulate the specific 'sandbox-exec' environment + vi.stubEnv('SANDBOX', 'sandbox-exec'); + const docsUrl = 'https://goo.gle/gemini-cli-docs'; + + await docsCommand.action(mockContext, ''); + + // The logic should fall through to the 'else' block + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Opening documentation in your browser: ${docsUrl}`, + }, + expect.any(Number), + ); + + // 'open' should be called in this specific sandbox case + expect(open).toHaveBeenCalledWith(docsUrl); + }); +}); diff --git a/packages/cli/src/ui/commands/docsCommand.ts b/packages/cli/src/ui/commands/docsCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..6286557cd18be3077880c5121f9da000cc9fd5e2 --- /dev/null +++ b/packages/cli/src/ui/commands/docsCommand.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import open from 'open'; +import process from 'node:process'; +import { + type CommandContext, + type SlashCommand, + CommandKind, +} from './types.js'; +import { MessageType } from '../types.js'; + +export const docsCommand: SlashCommand = { + name: 'docs', + description: 'Open full Gemini CLI documentation in your browser', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext): Promise => { + const docsUrl = 'https://goo.gle/gemini-cli-docs'; + + if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { + context.ui.addItem( + { + type: MessageType.INFO, + text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`, + }, + Date.now(), + ); + } else { + context.ui.addItem( + { + type: MessageType.INFO, + text: `Opening documentation in your browser: ${docsUrl}`, + }, + Date.now(), + ); + await open(docsUrl); + } + }, +}; diff --git a/packages/cli/src/ui/commands/editorCommand.test.ts b/packages/cli/src/ui/commands/editorCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b3f32235d99cfaae5780ea4bb36d3d4f389df6c --- /dev/null +++ b/packages/cli/src/ui/commands/editorCommand.test.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { editorCommand } from './editorCommand.js'; +// 1. Import the mock context utility +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('editorCommand', () => { + it('should return a dialog action to open the editor dialog', () => { + if (!editorCommand.action) { + throw new Error('The editor command must have an action.'); + } + const mockContext = createMockCommandContext(); + const result = editorCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'editor', + }); + }); + + it('should have the correct name and description', () => { + expect(editorCommand.name).toBe('editor'); + expect(editorCommand.description).toBe('Set external editor preference'); + }); +}); diff --git a/packages/cli/src/ui/commands/editorCommand.ts b/packages/cli/src/ui/commands/editorCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4e668ae537a5ed8537af1b1caaef24d5afed6b3 --- /dev/null +++ b/packages/cli/src/ui/commands/editorCommand.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type OpenDialogActionReturn, + type SlashCommand, +} from './types.js'; + +export const editorCommand: SlashCommand = { + name: 'editor', + description: 'Set external editor preference', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (): OpenDialogActionReturn => ({ + type: 'dialog', + dialog: 'editor', + }), +}; diff --git a/packages/cli/src/ui/commands/exportSessionCommand.test.ts b/packages/cli/src/ui/commands/exportSessionCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..08d6a924f44ce3275577fe3e6b62bad29b2416b2 --- /dev/null +++ b/packages/cli/src/ui/commands/exportSessionCommand.test.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { exportSessionCommand } from './exportSessionCommand.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { SessionSelector } from '../../utils/sessionUtils.js'; +import type { CommandContext } from './types.js'; +import { Storage, type ConversationRecord } from '@google/gemini-cli-core'; + +vi.mock('node:fs/promises'); +vi.mock('../../utils/sessionUtils.js'); + +describe('exportSessionCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(Storage.prototype, 'initialize').mockResolvedValue(undefined); + vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue( + path.join(path.sep, 'tmp', 'mock-dir'), + ); + mockContext = { + services: { + agentContext: { + config: { + sessionId: 'test-session-id', + getSessionId: () => 'test-session-id', + storage: new Storage(process.cwd()), + }, + }, + }, + invocation: { + args: ' export.json ', + name: 'export-session', + raw: '/export-session export.json', + }, + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + pendingItem: null, + }, + } as unknown as CommandContext; + }); + + it('should return error if no path is provided', async () => { + mockContext.invocation!.args = ' '; + const result = await exportSessionCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Please provide a file path'), + }); + }); + + it('should return error if sessionId is missing', async () => { + mockContext.services.agentContext!.config.getSessionId = () => + undefined as unknown as string; + const result = await exportSessionCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }); + }); + + it('should export the session successfully', async () => { + const mockSessionData: ConversationRecord = { + sessionId: 'test-session-id', + messages: [], + projectHash: 'hash', + startTime: 'time', + lastUpdated: 'time', + }; + vi.mocked(SessionSelector.prototype.resolveSession).mockResolvedValue({ + sessionData: mockSessionData, + sessionPath: path.join( + path.sep, + 'tmp', + 'mock-dir', + 'chats', + 'session.jsonl', + ), + displayInfo: 'test', + }); + + vi.mocked(fs.access).mockRejectedValue(new Error('Not found')); + + const result = await exportSessionCommand.action!(mockContext, ''); + + expect(result).toBeUndefined(); + expect(fs.writeFile).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'export.json'), + JSON.stringify(mockSessionData, null, 2), + 'utf-8', + ); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'export_session', + exportSession: { isPending: true }, + }), + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'export_session', + exportSession: { + isPending: false, + targetPath: expect.stringContaining('export.json'), + }, + }), + expect.any(Number), + ); + expect(mockContext.ui.setPendingItem).toHaveBeenLastCalledWith(null); + }); + + it('should return error if resolveSession fails', async () => { + vi.mocked(SessionSelector.prototype.resolveSession).mockRejectedValue( + new Error('Session not found'), + ); + + const result = await exportSessionCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to export session: Session not found', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/exportSessionCommand.ts b/packages/cli/src/ui/commands/exportSessionCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..af56574dff4c9ad6e54f3540a541a8fe8b18672a --- /dev/null +++ b/packages/cli/src/ui/commands/exportSessionCommand.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { + type CommandContext, + type SlashCommand, + type SlashCommandActionReturn, + CommandKind, +} from './types.js'; +import { MessageType, type HistoryItemExportSession } from '../types.js'; +import { SessionSelector } from '../../utils/sessionUtils.js'; + +export const exportSessionCommand: SlashCommand = { + name: 'export-session', + description: 'Export the current session to a JSON file', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async ( + context: CommandContext, + ): Promise => { + const { ui } = context; + const args = context.invocation?.args.trim(); + if (!args) { + return { + type: 'message', + messageType: 'error', + content: + 'Please provide a file path to export the session to. Example: /export-session ./my-session.json', + }; + } + + const sessionId = context.services.agentContext?.config.getSessionId(); + if (!sessionId) { + return { + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }; + } + + if (ui.pendingItem) { + ui.addItem( + { + type: MessageType.ERROR, + text: 'Operation already in progress, please wait.', + }, + Date.now(), + ); + return; + } + + const pendingMessage: HistoryItemExportSession = { + type: MessageType.EXPORT_SESSION, + exportSession: { + isPending: true, + }, + }; + + try { + ui.setPendingItem(pendingMessage); + const storage = context.services.agentContext!.config.storage; + const sessionSelector = new SessionSelector(storage); + const { sessionData } = await sessionSelector.resolveSession(sessionId); + + const targetPath = path.resolve(process.cwd(), args); + + await fs.writeFile( + targetPath, + JSON.stringify(sessionData, null, 2), + 'utf-8', + ); + + ui.addItem( + { + type: MessageType.EXPORT_SESSION, + exportSession: { + isPending: false, + targetPath, + }, + } as HistoryItemExportSession, + Date.now(), + ); + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to export session: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } finally { + ui.setPendingItem(null); + } + }, +}; diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f801c5278524d829f7f9f077b9bad395ff7847d --- /dev/null +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -0,0 +1,1227 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type ReactElement } from 'react'; + +import type { + ExtensionLoader, + GeminiCLIExtension, +} from '@google/gemini-cli-core'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; +import { + completeExtensions, + completeExtensionsAndScopes, + extensionsCommand, +} from './extensionsCommand.js'; +import { + ConfigExtensionDialog, + type ConfigExtensionDialogProps, +} from '../components/ConfigExtensionDialog.js'; +import { + ExtensionRegistryView, + type ExtensionRegistryViewProps, +} from '../components/views/ExtensionRegistryView.js'; +import { type CommandContext, type SlashCommand } from './types.js'; + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockedFunction, +} from 'vitest'; +import { type ExtensionUpdateAction } from '../state/extensions.js'; +import { + ExtensionManager, + inferInstallMetadata, +} from '../../config/extension-manager.js'; +import { SettingScope } from '../../config/settings.js'; +import { stat } from 'node:fs/promises'; +import { type RegistryExtension } from '../../config/extensionRegistryClient.js'; +import { waitFor } from '../../test-utils/async.js'; + +vi.mock('../../config/extension-manager.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + inferInstallMetadata: vi.fn(), + }; +}); + +import open from 'open'; +import type { Stats } from 'node:fs'; + +vi.mock('open', () => ({ + default: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + stat: vi.fn(), +})); + +vi.mock('../../config/extensions/extensionSettings.js', () => ({ + ExtensionSettingScope: { + USER: 'user', + WORKSPACE: 'workspace', + }, + getScopedEnvContents: vi.fn().mockResolvedValue({}), + promptForSetting: vi.fn(), + updateSetting: vi.fn(), +})); + +vi.mock('prompts', () => ({ + default: vi.fn(), +})); + +vi.mock('../../config/extensions/update.js', () => ({ + updateExtension: vi.fn(), + checkForAllExtensionUpdates: vi.fn(), +})); + +const mockDisableExtension = vi.fn(); +const mockEnableExtension = vi.fn(); +const mockInstallExtension = vi.fn(); +const mockUninstallExtension = vi.fn(); +const mockGetExtensions = vi.fn(); + +const inactiveExt: GeminiCLIExtension = { + name: 'ext-one', + id: 'ext-one-id', + version: '1.0.0', + isActive: false, // should suggest disabled extensions + path: '/test/dir/ext-one', + contextFiles: [], + installMetadata: { + type: 'git', + autoUpdate: false, + source: 'https://github.com/some/extension.git', + }, +}; +const activeExt: GeminiCLIExtension = { + name: 'ext-two', + id: 'ext-two-id', + version: '1.0.0', + isActive: true, // should not suggest enabled extensions + path: '/test/dir/ext-two', + contextFiles: [], + installMetadata: { + type: 'git', + autoUpdate: false, + source: 'https://github.com/some/extension.git', + }, +}; +const allExt: GeminiCLIExtension = { + name: 'all-ext', + id: 'all-ext-id', + version: '1.0.0', + isActive: true, + path: '/test/dir/all-ext', + contextFiles: [], + installMetadata: { + type: 'git', + autoUpdate: false, + source: 'https://github.com/some/extension.git', + }, +}; + +describe('extensionsCommand', () => { + let mockContext: CommandContext; + const mockDispatchExtensionState = vi.fn(); + let mockExtensionLoader: unknown; + let mockReloadSkills: MockedFunction<() => Promise>; + let mockReloadAgents: MockedFunction<() => Promise>; + + beforeEach(() => { + vi.resetAllMocks(); + + mockExtensionLoader = Object.create(ExtensionManager.prototype); + Object.assign(mockExtensionLoader as object, { + enableExtension: mockEnableExtension, + disableExtension: mockDisableExtension, + installOrUpdateExtension: mockInstallExtension, + uninstallExtension: mockUninstallExtension, + getExtensions: mockGetExtensions, + loadExtensionConfig: vi.fn().mockResolvedValue({ + name: 'test-ext', + settings: [{ name: 'setting1', envVar: 'SETTING1' }], + }), + }); + + mockGetExtensions.mockReturnValue([inactiveExt, activeExt, allExt]); + vi.mocked(open).mockClear(); + mockReloadAgents = vi.fn().mockResolvedValue(undefined); + mockReloadSkills = vi.fn().mockResolvedValue(undefined); + + mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + getExtensions: mockGetExtensions, + getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader), + getWorkingDir: () => '/test/dir', + reloadSkills: mockReloadSkills, + getAgentRegistry: vi.fn().mockReturnValue({ + reload: mockReloadAgents, + }), + }, + }, + }, + ui: { + dispatchExtensionStateUpdate: mockDispatchExtensionState, + removeComponent: vi.fn(), + }, + }); + }); + + afterEach(() => { + // Restore any stubbed environment variables, similar to docsCommand.test.ts + vi.unstubAllEnvs(); + }); + + describe('list', () => { + it('should add an EXTENSIONS_LIST item to the UI', async () => { + const command = extensionsCommand(); + if (!command.action) throw new Error('Action not defined'); + await command.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), + }); + }); + + it('should show a message if no extensions are installed', async () => { + mockGetExtensions.mockReturnValue([]); + const command = extensionsCommand(); + if (!command.action) throw new Error('Action not defined'); + await command.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', + }); + }); + }); + + describe('completeExtensions', () => { + it.each([ + { + description: 'should return matching extension names', + partialArg: 'ext', + expected: ['ext-one', 'ext-two'], + }, + { + description: 'should return --all when partialArg matches', + partialArg: '--al', + expected: ['--all'], + }, + { + description: + 'should return both extension names and --all when both match', + partialArg: 'all', + expected: ['--all', 'all-ext'], + }, + { + description: 'should return an empty array if no matches', + partialArg: 'nomatch', + expected: [], + }, + { + description: + 'should suggest only disabled extension names for the enable command', + partialArg: 'ext', + expected: ['ext-one'], + command: 'enable', + }, + { + description: + 'should suggest only enabled extension names for the disable command', + partialArg: 'ext', + expected: ['ext-two'], + command: 'disable', + }, + ])('$description', async ({ partialArg, expected, command }) => { + if (command) { + mockContext.invocation!.name = command; + } + const suggestions = completeExtensions(mockContext, partialArg); + expect(suggestions).toEqual(expected); + }); + }); + + describe('completeExtensionsAndScopes', () => { + it('expands the list of suggestions with --scope args', () => { + const suggestions = completeExtensionsAndScopes(mockContext, 'ext'); + expect(suggestions).toEqual([ + 'ext-one --scope user', + 'ext-one --scope workspace', + 'ext-one --scope session', + 'ext-two --scope user', + 'ext-two --scope workspace', + 'ext-two --scope session', + ]); + }); + }); + + describe('update', () => { + const updateAction = extensionsCommand().subCommands?.find( + (cmd) => cmd.name === 'update', + )?.action; + + if (!updateAction) { + throw new Error('Update action not found'); + } + + it('should show usage if no args are provided', async () => { + await updateAction(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Usage: /extensions update |--all', + }); + }); + + it('should show a message if no extensions are installed', async () => { + mockGetExtensions.mockReturnValue([]); + await updateAction(mockContext, 'ext-one'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', + }); + }); + + it('should inform user if there are no extensions to update with --all', async () => { + mockDispatchExtensionState.mockImplementationOnce( + async (action: ExtensionUpdateAction) => { + if (action.type === 'SCHEDULE_UPDATE') { + action.payload.onComplete([]); + } + }, + ); + + await updateAction(mockContext, '--all'); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: 'No extensions to update.', + }); + }); + + it('should call setPendingItem and addItem in a finally block on success', async () => { + mockDispatchExtensionState.mockImplementationOnce( + async (action: ExtensionUpdateAction) => { + if (action.type === 'SCHEDULE_UPDATE') { + action.payload.onComplete([ + { + name: 'ext-one', + originalVersion: '1.0.0', + updatedVersion: '1.0.1', + }, + { + name: 'ext-two', + originalVersion: '2.0.0', + updatedVersion: '2.0.1', + }, + ]); + } + }, + ); + await updateAction(mockContext, '--all'); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), + }); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), + }); + }); + + it('should call setPendingItem and addItem in a finally block on failure', async () => { + mockDispatchExtensionState.mockImplementationOnce((_) => { + throw new Error('Something went wrong'); + }); + await updateAction(mockContext, '--all'); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), + }); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), + }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Something went wrong', + }); + }); + + it('should update a single extension by name', async () => { + mockDispatchExtensionState.mockImplementationOnce( + async (action: ExtensionUpdateAction) => { + if (action.type === 'SCHEDULE_UPDATE') { + action.payload.onComplete([ + { + name: 'ext-one', + originalVersion: '1.0.0', + updatedVersion: '1.0.1', + }, + ]); + } + }, + ); + await updateAction(mockContext, 'ext-one'); + expect(mockDispatchExtensionState).toHaveBeenCalledWith({ + type: 'SCHEDULE_UPDATE', + payload: { + all: false, + names: ['ext-one'], + onComplete: expect.any(Function), + }, + }); + }); + + it('should update multiple extensions by name', async () => { + mockDispatchExtensionState.mockImplementationOnce( + async (action: ExtensionUpdateAction) => { + if (action.type === 'SCHEDULE_UPDATE') { + action.payload.onComplete([ + { + name: 'ext-one', + originalVersion: '1.0.0', + updatedVersion: '1.0.1', + }, + { + name: 'ext-two', + originalVersion: '1.0.0', + updatedVersion: '1.0.1', + }, + ]); + } + }, + ); + await updateAction(mockContext, 'ext-one ext-two'); + expect(mockDispatchExtensionState).toHaveBeenCalledWith({ + type: 'SCHEDULE_UPDATE', + payload: { + all: false, + names: ['ext-one', 'ext-two'], + onComplete: expect.any(Function), + }, + }); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), + }); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), + }); + }); + }); + + describe('explore', () => { + const exploreAction = extensionsCommand().subCommands?.find( + (cmd) => cmd.name === 'explore', + )?.action; + + if (!exploreAction) { + throw new Error('Explore action not found'); + } + + it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => { + mockContext.services.settings.merged.experimental.extensionRegistry = + true; + + const result = await exploreAction(mockContext, ''); + + expect(result).toBeDefined(); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + + const component = + result.component as ReactElement; + expect(component.type).toBe(ExtensionRegistryView); + expect(component.props.extensionManager).toBe(mockExtensionLoader); + }); + + it('should handle onSelect and onClose in ExtensionRegistryView', async () => { + mockContext.services.settings.merged.experimental.extensionRegistry = + true; + + const result = await exploreAction(mockContext, ''); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + + const component = + result.component as ReactElement; + + const extension = { + extensionName: 'test-ext', + url: 'https://github.com/test/ext.git', + } as RegistryExtension; + + vi.mocked(inferInstallMetadata).mockResolvedValue({ + source: extension.url, + type: 'git', + }); + mockInstallExtension.mockResolvedValue({ name: extension.url }); + + // Call onSelect + await component.props.onSelect?.(extension); + + await waitFor(() => { + expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: extension.url, + type: 'git', + }, + undefined, + undefined, + ); + }); + expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1); + + // Call onClose + component.props.onClose?.(); + expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(2); + }); + + it("should add an info message and call 'open' in a non-sandbox environment", async () => { + // Ensure no special environment variables that would affect behavior + vi.stubEnv('NODE_ENV', ''); + vi.stubEnv('SANDBOX', ''); + + await exploreAction(mockContext, ''); + + const extensionsUrl = 'https://geminicli.com/extensions/'; + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Opening extensions page in your browser: ${extensionsUrl}`, + }); + + expect(open).toHaveBeenCalledWith(extensionsUrl); + }); + + it('should only add an info message in a sandbox environment', async () => { + // Simulate a sandbox environment + vi.stubEnv('NODE_ENV', ''); + vi.stubEnv('SANDBOX', 'gemini-sandbox'); + const extensionsUrl = 'https://geminicli.com/extensions/'; + + await exploreAction(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `View available extensions at ${extensionsUrl}`, + }); + + // Ensure 'open' was not called in the sandbox + expect(open).not.toHaveBeenCalled(); + }); + + it('should add an info message and not call open in NODE_ENV test environment', async () => { + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('SANDBOX', ''); + const extensionsUrl = 'https://geminicli.com/extensions/'; + + await exploreAction(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, + }); + + // Ensure 'open' was not called in test environment + expect(open).not.toHaveBeenCalled(); + }); + + it('should handle errors when opening the browser', async () => { + vi.stubEnv('NODE_ENV', ''); + const extensionsUrl = 'https://geminicli.com/extensions/'; + const errorMessage = 'Failed to open browser'; + vi.mocked(open).mockRejectedValue(new Error(errorMessage)); + + await exploreAction(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, + }); + }); + }); + + describe('when enableExtensionReloading is true', () => { + it('should include enable, disable, install, link, and uninstall subcommands', () => { + const command = extensionsCommand(true); + const subCommandNames = command.subCommands?.map((cmd) => cmd.name); + expect(subCommandNames).toContain('enable'); + expect(subCommandNames).toContain('disable'); + expect(subCommandNames).toContain('install'); + expect(subCommandNames).toContain('link'); + expect(subCommandNames).toContain('uninstall'); + }); + }); + + describe('when enableExtensionReloading is false', () => { + it('should not include enable, disable, install, link, and uninstall subcommands', () => { + const command = extensionsCommand(false); + const subCommandNames = command.subCommands?.map((cmd) => cmd.name); + expect(subCommandNames).not.toContain('enable'); + expect(subCommandNames).not.toContain('disable'); + expect(subCommandNames).not.toContain('install'); + expect(subCommandNames).not.toContain('link'); + expect(subCommandNames).not.toContain('uninstall'); + }); + }); + + describe('when enableExtensionReloading is not provided', () => { + it('should not include enable, disable, install, link, and uninstall subcommands by default', () => { + const command = extensionsCommand(); + const subCommandNames = command.subCommands?.map((cmd) => cmd.name); + expect(subCommandNames).not.toContain('enable'); + expect(subCommandNames).not.toContain('disable'); + expect(subCommandNames).not.toContain('install'); + expect(subCommandNames).not.toContain('link'); + expect(subCommandNames).not.toContain('uninstall'); + }); + }); + + describe('install', () => { + let installAction: SlashCommand['action']; + + beforeEach(() => { + installAction = extensionsCommand(true).subCommands?.find( + (cmd) => cmd.name === 'install', + )?.action; + + expect(installAction).not.toBeNull(); + + mockContext.invocation!.name = 'install'; + }); + + it('should show usage if no extension name is provided', async () => { + await installAction!(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Usage: /extensions install ', + }); + expect(mockInstallExtension).not.toHaveBeenCalled(); + }); + + it('should call installExtension and show success message', async () => { + const packageName = 'test-extension-package'; + vi.mocked(inferInstallMetadata).mockResolvedValue({ + source: packageName, + type: 'git', + }); + mockInstallExtension.mockResolvedValue({ name: packageName }); + await installAction!(mockContext, packageName); + expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'git', + }, + undefined, + undefined, + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Installing extension from "${packageName}"...`, + }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Extension "${packageName}" installed successfully.`, + }); + }); + + it('should show error message on installation failure', async () => { + const packageName = 'failed-extension'; + const errorMessage = 'install failed'; + vi.mocked(inferInstallMetadata).mockResolvedValue({ + source: packageName, + type: 'git', + }); + mockInstallExtension.mockRejectedValue(new Error(errorMessage)); + + await installAction!(mockContext, packageName); + expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'git', + }, + undefined, + undefined, + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: `Failed to install extension from "${packageName}": ${errorMessage}`, + }); + }); + + it('should show error message for invalid source', async () => { + const invalidSource = 'a;b'; + await installAction!(mockContext, invalidSource); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: `Invalid source: ${invalidSource}`, + }); + expect(mockInstallExtension).not.toHaveBeenCalled(); + }); + }); + + describe('link', () => { + let linkAction: SlashCommand['action']; + + beforeEach(() => { + linkAction = extensionsCommand(true).subCommands?.find( + (cmd) => cmd.name === 'link', + )?.action; + + expect(linkAction).not.toBeNull(); + mockContext.invocation!.name = 'link'; + }); + + it('should show usage if no extension is provided', async () => { + await linkAction!(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Usage: /extensions link ', + }); + expect(mockInstallExtension).not.toHaveBeenCalled(); + }); + + it('should call installExtension and show success message', async () => { + const packageName = 'test-extension-package'; + mockInstallExtension.mockResolvedValue({ name: packageName }); + vi.mocked(stat).mockResolvedValue({ + size: 100, + } as Stats); + await linkAction!(mockContext, packageName); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'link', + }, + undefined, + undefined, + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Linking extension from "${packageName}"...`, + }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Extension "${packageName}" linked successfully.`, + }); + }); + + it('should show error message on linking failure', async () => { + const packageName = 'test-extension-package'; + const errorMessage = 'link failed'; + mockInstallExtension.mockRejectedValue(new Error(errorMessage)); + vi.mocked(stat).mockResolvedValue({ + size: 100, + } as Stats); + + await linkAction!(mockContext, packageName); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'link', + }, + undefined, + undefined, + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: `Failed to link extension from "${packageName}": ${errorMessage}`, + }); + }); + + it('should show error message for invalid source', async () => { + const packageName = 'test-extension-package'; + const errorMessage = 'invalid path'; + vi.mocked(stat).mockRejectedValue(new Error(errorMessage)); + await linkAction!(mockContext, packageName); + expect(mockInstallExtension).not.toHaveBeenCalled(); + }); + }); + + describe('uninstall', () => { + let uninstallAction: SlashCommand['action']; + + beforeEach(() => { + uninstallAction = extensionsCommand(true).subCommands?.find( + (cmd) => cmd.name === 'uninstall', + )?.action; + + expect(uninstallAction).not.toBeNull(); + + mockContext.invocation!.name = 'uninstall'; + }); + + it('should show usage if no extension name is provided', async () => { + await uninstallAction!(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Usage: /extensions uninstall |--all', + }); + expect(mockUninstallExtension).not.toHaveBeenCalled(); + }); + + it('should expose "delete" as an alias', () => { + const uninstallCmd = extensionsCommand(true).subCommands?.find( + (cmd) => cmd.name === 'uninstall', + ); + expect(uninstallCmd?.altNames).toContain('delete'); + }); + + it('should call uninstallExtension and show success message', async () => { + const extensionName = 'test-extension'; + await uninstallAction!(mockContext, extensionName); + expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Uninstalling extension "${extensionName}"...`, + }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Extension "${extensionName}" uninstalled successfully.`, + }); + }); + + it('should show error message on uninstallation failure', async () => { + const extensionName = 'failed-extension'; + const errorMessage = 'uninstall failed'; + mockUninstallExtension.mockRejectedValue(new Error(errorMessage)); + + await uninstallAction!(mockContext, extensionName); + expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: `Failed to uninstall extension "${extensionName}": ${errorMessage}`, + }); + }); + }); + + describe('enable', () => { + let enableAction: SlashCommand['action']; + + beforeEach(() => { + enableAction = extensionsCommand(true).subCommands?.find( + (cmd) => cmd.name === 'enable', + )?.action; + + expect(enableAction).not.toBeNull(); + + mockContext.invocation!.name = 'enable'; + }); + + it('should show usage if no extension name is provided', async () => { + await enableAction!(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Usage: /extensions enable [--scope=]', + }); + }); + + it('should call enableExtension with the provided scope', async () => { + await enableAction!(mockContext, `${inactiveExt.name} --scope=user`); + expect(mockEnableExtension).toHaveBeenCalledWith( + inactiveExt.name, + SettingScope.User, + ); + + await enableAction!(mockContext, `${inactiveExt.name} --scope workspace`); + expect(mockEnableExtension).toHaveBeenCalledWith( + inactiveExt.name, + SettingScope.Workspace, + ); + }); + + it('should support --all', async () => { + mockGetExtensions.mockReturnValue([ + inactiveExt, + { ...inactiveExt, name: 'another-inactive-ext' }, + ]); + await enableAction!(mockContext, '--all --scope session'); + expect(mockEnableExtension).toHaveBeenCalledWith( + inactiveExt.name, + SettingScope.Session, + ); + expect(mockEnableExtension).toHaveBeenCalledWith( + 'another-inactive-ext', + SettingScope.Session, + ); + }); + }); + + describe('disable', () => { + let disableAction: SlashCommand['action']; + + beforeEach(() => { + disableAction = extensionsCommand(true).subCommands?.find( + (cmd) => cmd.name === 'disable', + )?.action; + + expect(disableAction).not.toBeNull(); + + mockContext.invocation!.name = 'disable'; + }); + + it('should show usage if no extension name is provided', async () => { + await disableAction!(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Usage: /extensions disable [--scope=]', + }); + }); + + it('should call disableExtension with the provided scope', async () => { + await disableAction!(mockContext, `${activeExt.name} --scope=user`); + expect(mockDisableExtension).toHaveBeenCalledWith( + activeExt.name, + SettingScope.User, + ); + + await disableAction!(mockContext, `${activeExt.name} --scope workspace`); + expect(mockDisableExtension).toHaveBeenCalledWith( + activeExt.name, + SettingScope.Workspace, + ); + }); + + it('should support --all', async () => { + mockGetExtensions.mockReturnValue([ + activeExt, + { ...activeExt, name: 'another-active-ext' }, + ]); + await disableAction!(mockContext, '--all --scope session'); + expect(mockDisableExtension).toHaveBeenCalledWith( + activeExt.name, + SettingScope.Session, + ); + expect(mockDisableExtension).toHaveBeenCalledWith( + 'another-active-ext', + SettingScope.Session, + ); + }); + }); + + describe('reload', () => { + let restartAction: SlashCommand['action']; + let mockRestartExtension: MockedFunction< + typeof ExtensionLoader.prototype.restartExtension + >; + + beforeEach(() => { + restartAction = extensionsCommand().subCommands?.find( + (c) => c.name === 'reload', + )?.action; + expect(restartAction).not.toBeNull(); + + mockRestartExtension = vi.fn(); + mockContext.services.agentContext!.config.getExtensionLoader = vi + .fn() + .mockImplementation(() => ({ + getExtensions: mockGetExtensions, + restartExtension: mockRestartExtension, + })); + mockContext.invocation!.name = 'reload'; + }); + + it('should show a message if no extensions are installed', async () => { + mockContext.services.agentContext!.config.getExtensionLoader = vi + .fn() + .mockImplementation(() => ({ + getExtensions: () => [], + restartExtension: mockRestartExtension, + })); + + await restartAction!(mockContext, '--all'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', + }); + }); + + it('reloads all active extensions when --all is provided', async () => { + const mockExtensions = [ + { name: 'ext1', isActive: true }, + { name: 'ext2', isActive: true }, + { name: 'ext3', isActive: false }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + + await restartAction!(mockContext, '--all'); + + expect(mockRestartExtension).toHaveBeenCalledTimes(2); + expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); + expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[1]); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Reloading 2 extensions...', + }), + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: '2 extensions reloaded successfully', + }), + ); + expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ + type: 'RESTARTED', + payload: { name: 'ext1' }, + }); + expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ + type: 'RESTARTED', + payload: { name: 'ext2' }, + }); + expect(mockReloadSkills).toHaveBeenCalled(); + expect(mockReloadAgents).toHaveBeenCalled(); + }); + + it('handles errors during skill or agent reload', async () => { + const mockExtensions = [ + { name: 'ext1', isActive: true }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + mockReloadSkills.mockRejectedValue(new Error('Failed to reload skills')); + + await restartAction!(mockContext, '--all'); + + expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); + expect(mockReloadSkills).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Failed to reload skills or agents: Failed to reload skills', + }), + ); + }); + + it('reloads only specified active extensions', async () => { + const mockExtensions = [ + { name: 'ext1', isActive: false }, + { name: 'ext2', isActive: true }, + { name: 'ext3', isActive: true }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + + await restartAction!(mockContext, 'ext1 ext3'); + + expect(mockRestartExtension).toHaveBeenCalledTimes(1); + expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[2]); + expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ + type: 'RESTARTED', + payload: { name: 'ext3' }, + }); + }); + + it('shows an error if no extension loader is available', async () => { + mockContext.services.agentContext!.config.getExtensionLoader = vi.fn(); + + await restartAction!(mockContext, '--all'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: "Extensions are not yet loaded, can't restart yet", + }), + ); + expect(mockRestartExtension).not.toHaveBeenCalled(); + }); + + it('shows usage error for no arguments', async () => { + await restartAction!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Usage: /extensions reload |--all', + }), + ); + expect(mockRestartExtension).not.toHaveBeenCalled(); + }); + + it('handles errors during extension reload', async () => { + const mockExtensions = [ + { name: 'ext1', isActive: true }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + mockRestartExtension.mockRejectedValue(new Error('Failed to restart')); + + await restartAction!(mockContext, '--all'); + + expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Failed to reload some extensions:\n ext1: Failed to restart', + }), + ); + }); + + it('shows a warning if an extension is not found', async () => { + const mockExtensions = [ + { name: 'ext1', isActive: true }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + + await restartAction!(mockContext, 'ext1 ext2'); + + expect(mockRestartExtension).toHaveBeenCalledTimes(1); + expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.WARNING, + text: 'Extension(s) not found or not active: ext2', + }), + ); + }); + + it('does not reload any extensions if none are found', async () => { + const mockExtensions = [ + { name: 'ext1', isActive: true }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + + await restartAction!(mockContext, 'ext2 ext3'); + + expect(mockRestartExtension).not.toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.WARNING, + text: 'Extension(s) not found or not active: ext2, ext3', + }), + ); + }); + + it('should suggest only enabled extension names for the reload command', async () => { + mockContext.invocation!.name = 'reload'; + const mockExtensions = [ + { name: 'ext1', isActive: true }, + { name: 'ext2', isActive: false }, + ] as GeminiCLIExtension[]; + mockGetExtensions.mockReturnValue(mockExtensions); + + const suggestions = completeExtensions(mockContext, 'ext'); + expect(suggestions).toEqual(['ext1']); + }); + }); + + describe('config', () => { + let configAction: SlashCommand['action']; + + beforeEach(async () => { + configAction = extensionsCommand(true).subCommands?.find( + (cmd) => cmd.name === 'config', + )?.action; + + expect(configAction).not.toBeNull(); + mockContext.invocation!.name = 'config'; + + const prompts = (await import('prompts')).default; + vi.mocked(prompts).mockResolvedValue({ overwrite: true }); + + const { getScopedEnvContents } = await import( + '../../config/extensions/extensionSettings.js' + ); + vi.mocked(getScopedEnvContents).mockResolvedValue({}); + }); + + it('should return dialog to configure all extensions if no args provided', async () => { + const result = await configAction!(mockContext, ''); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + const dialogResult = result; + const component = + dialogResult.component as ReactElement; + expect(component.type).toBe(ConfigExtensionDialog); + expect(component.props.configureAll).toBe(true); + expect(component.props.extensionManager).toBeDefined(); + }); + + it('should return dialog to configure specific extension', async () => { + const result = await configAction!(mockContext, 'ext-one'); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + const dialogResult = result; + const component = + dialogResult.component as ReactElement; + expect(component.type).toBe(ConfigExtensionDialog); + expect(component.props.extensionName).toBe('ext-one'); + expect(component.props.settingKey).toBeUndefined(); + expect(component.props.configureAll).toBe(false); + }); + + it('should return dialog to configure specific setting for an extension', async () => { + const result = await configAction!(mockContext, 'ext-one SETTING1'); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + const dialogResult = result; + const component = + dialogResult.component as ReactElement; + expect(component.type).toBe(ConfigExtensionDialog); + expect(component.props.extensionName).toBe('ext-one'); + expect(component.props.settingKey).toBe('SETTING1'); + expect(component.props.scope).toBe('user'); // Default scope + }); + + it('should respect scope argument passed to dialog', async () => { + const result = await configAction!( + mockContext, + 'ext-one SETTING1 --scope=workspace', + ); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + const dialogResult = result; + const component = + dialogResult.component as ReactElement; + expect(component.props.scope).toBe('workspace'); + }); + + it('should show error for invalid extension name', async () => { + await configAction!(mockContext, '../invalid'); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Invalid extension name. Names cannot contain path separators or "..".', + }); + }); + + // "should inform if extension has no settings" - This check is now inside ConfigExtensionDialog logic. + // We can test that we still return a dialog, and the dialog will handle logical checks via utils.ts + // For unit testing extensionsCommand, we just ensure delegation. + it('should return dialog even if extension has no settings (dialog handles logic)', async () => { + const result = await configAction!(mockContext, 'ext-one'); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + const dialogResult = result; + const component = + dialogResult.component as ReactElement; + expect(component.type).toBe(ConfigExtensionDialog); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..e73f9796ddd9f1b4a443599c34df67aaa25cc465 --- /dev/null +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -0,0 +1,916 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + debugLogger, + listExtensions, + getErrorMessage, + type ExtensionInstallMetadata, +} from '@google/gemini-cli-core'; +import type { ExtensionUpdateInfo } from '../../config/extension.js'; +import { + emptyIcon, + MessageType, + type HistoryItemExtensionsList, + type HistoryItemInfo, +} from '../types.js'; +import { + type CommandContext, + type SlashCommand, + type SlashCommandActionReturn, + CommandKind, +} from './types.js'; +import open from 'open'; +import process from 'node:process'; +import { + ExtensionManager, + inferInstallMetadata, +} from '../../config/extension-manager.js'; +import { SettingScope } from '../../config/settings.js'; +import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js'; +import { theme } from '../semantic-colors.js'; +import { stat } from 'node:fs/promises'; +import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js'; +import { type ConfigLogger } from '../../commands/extensions/utils.js'; +import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js'; +import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js'; +import React from 'react'; + +function showMessageIfNoExtensions( + context: CommandContext, + extensions: unknown[], +): boolean { + if (extensions.length === 0) { + context.ui.addItem({ + type: MessageType.INFO, + text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', + }); + return true; + } + return false; +} + +async function listAction(context: CommandContext) { + const extensions = context.services.agentContext?.config + ? listExtensions(context.services.agentContext.config) + : []; + + if (showMessageIfNoExtensions(context, extensions)) { + return; + } + + const historyItem: HistoryItemExtensionsList = { + type: MessageType.EXTENSIONS_LIST, + extensions, + }; + + context.ui.addItem(historyItem); +} + +function updateAction(context: CommandContext, args: string): Promise { + const updateArgs = args.split(' ').filter((value) => value.length > 0); + const all = updateArgs.length === 1 && updateArgs[0] === '--all'; + const names = all ? null : updateArgs; + + if (!all && names?.length === 0) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Usage: /extensions update |--all', + }); + return Promise.resolve(); + } + + let resolveUpdateComplete: (updateInfo: ExtensionUpdateInfo[]) => void; + const updateComplete = new Promise( + (resolve) => (resolveUpdateComplete = resolve), + ); + + const extensions = context.services.agentContext?.config + ? listExtensions(context.services.agentContext.config) + : []; + + if (showMessageIfNoExtensions(context, extensions)) { + return Promise.resolve(); + } + + const historyItem: HistoryItemExtensionsList = { + type: MessageType.EXTENSIONS_LIST, + extensions, + }; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + updateComplete.then((updateInfos) => { + if (updateInfos.length === 0) { + context.ui.addItem({ + type: MessageType.INFO, + text: 'No extensions to update.', + }); + } + + context.ui.addItem(historyItem); + context.ui.setPendingItem(null); + }); + + try { + context.ui.setPendingItem(historyItem); + + context.ui.dispatchExtensionStateUpdate({ + type: 'SCHEDULE_UPDATE', + payload: { + all, + names, + onComplete: (updateInfos) => { + resolveUpdateComplete(updateInfos); + }, + }, + }); + if (names?.length) { + const extensions = listExtensions(context.services.agentContext!.config); + for (const name of names) { + const extension = extensions.find( + (extension) => extension.name === name, + ); + if (!extension) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Extension ${name} not found.`, + }); + continue; + } + } + } + } catch (error) { + resolveUpdateComplete!([]); + context.ui.addItem({ + type: MessageType.ERROR, + text: getErrorMessage(error), + }); + } + return updateComplete.then((_) => {}); +} + +async function restartAction( + context: CommandContext, + args: string, +): Promise { + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); + if (!extensionLoader) { + context.ui.addItem({ + type: MessageType.ERROR, + text: "Extensions are not yet loaded, can't restart yet", + }); + return; + } + + const extensions = extensionLoader.getExtensions(); + if (showMessageIfNoExtensions(context, extensions)) { + return; + } + + const restartArgs = args.split(' ').filter((value) => value.length > 0); + const all = restartArgs.length === 1 && restartArgs[0] === '--all'; + const names = all ? null : restartArgs; + if (!all && names?.length === 0) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Usage: /extensions reload |--all', + }); + return Promise.resolve(); + } + + let extensionsToRestart = extensionLoader + .getExtensions() + .filter((extension) => extension.isActive); + if (names) { + extensionsToRestart = extensionsToRestart.filter((extension) => + names.includes(extension.name), + ); + if (names.length !== extensionsToRestart.length) { + const notFound = names.filter( + (name) => + !extensionsToRestart.some((extension) => extension.name === name), + ); + if (notFound.length > 0) { + context.ui.addItem({ + type: MessageType.WARNING, + text: `Extension(s) not found or not active: ${notFound.join(', ')}`, + }); + } + } + } + if (extensionsToRestart.length === 0) { + // We will have logged a different message above already. + return; + } + + const s = extensionsToRestart.length > 1 ? 's' : ''; + + const reloadingMessage = { + type: MessageType.INFO, + text: `Reloading ${extensionsToRestart.length} extension${s}...`, + color: theme.text.primary, + }; + context.ui.addItem(reloadingMessage); + + const results = await Promise.allSettled( + extensionsToRestart.map(async (extension) => { + if (extension.isActive) { + await extensionLoader.restartExtension(extension); + context.ui.dispatchExtensionStateUpdate({ + type: 'RESTARTED', + payload: { + name: extension.name, + }, + }); + } + }), + ); + + const failures = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + + if (failures.length < extensionsToRestart.length) { + try { + await context.services.agentContext?.config.reloadSkills(); + await context.services.agentContext?.config.getAgentRegistry()?.reload(); + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to reload skills or agents: ${getErrorMessage(error)}`, + }); + } + } + + if (failures.length > 0) { + const errorMessages = failures + .map((failure, index) => { + const extensionName = extensionsToRestart[index].name; + return `${extensionName}: ${getErrorMessage(failure.reason)}`; + }) + .join('\n '); + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to reload some extensions:\n ${errorMessages}`, + }); + } else { + const infoItem: HistoryItemInfo = { + type: MessageType.INFO, + text: `${extensionsToRestart.length} extension${s} reloaded successfully`, + icon: emptyIcon, + color: theme.text.primary, + }; + context.ui.addItem(infoItem); + } +} + +async function exploreAction( + context: CommandContext, +): Promise { + const settings = context.services.settings.merged; + const useRegistryUI = settings.experimental?.extensionRegistry; + + if (useRegistryUI) { + const extensionManager = + context.services.agentContext?.config.getExtensionLoader(); + if (extensionManager instanceof ExtensionManager) { + return { + type: 'custom_dialog' as const, + component: React.createElement(ExtensionRegistryView, { + onSelect: async (extension, requestConsentOverride) => { + debugLogger.log(`Selected extension: ${extension.extensionName}`); + await installAction(context, extension.url, requestConsentOverride); + context.ui.removeComponent(); + }, + onLink: async (extension, requestConsentOverride) => { + debugLogger.log(`Linking extension: ${extension.extensionName}`); + await linkAction(context, extension.url, requestConsentOverride); + context.ui.removeComponent(); + }, + onClose: () => context.ui.removeComponent(), + extensionManager, + }), + }; + } + } + + const extensionsUrl = 'https://geminicli.com/extensions/'; + + // Only check for NODE_ENV for explicit test mode, not for unit test framework + if (process.env['NODE_ENV'] === 'test') { + context.ui.addItem({ + type: MessageType.INFO, + text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, + }); + } else if ( + process.env['SANDBOX'] && + process.env['SANDBOX'] !== 'sandbox-exec' + ) { + context.ui.addItem({ + type: MessageType.INFO, + text: `View available extensions at ${extensionsUrl}`, + }); + } else { + context.ui.addItem({ + type: MessageType.INFO, + text: `Opening extensions page in your browser: ${extensionsUrl}`, + }); + try { + await open(extensionsUrl); + } catch { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, + }); + } + } +} + +function getEnableDisableContext( + context: CommandContext, + argumentsString: string, +): { + extensionManager: ExtensionManager; + names: string[]; + scope: SettingScope; +} | null { + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + debugLogger.error( + `Cannot ${context.invocation?.name} extensions in this environment`, + ); + return null; + } + const parts = argumentsString.split(' '); + const name = parts[0]; + if ( + name === '' || + !( + (parts.length === 2 && parts[1].startsWith('--scope=')) || // --scope= + (parts.length === 3 && parts[1] === '--scope') // --scope + ) + ) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Usage: /extensions ${context.invocation?.name} [--scope=]`, + }); + return null; + } + let scope: SettingScope; + // Transform `--scope=` to `--scope `. + if (parts.length === 2) { + parts.push(...parts[1].split('=')); + parts.splice(1, 1); + } + switch (parts[2].toLowerCase()) { + case 'workspace': + scope = SettingScope.Workspace; + break; + case 'user': + scope = SettingScope.User; + break; + case 'session': + scope = SettingScope.Session; + break; + default: + context.ui.addItem({ + type: MessageType.ERROR, + text: `Unsupported scope ${parts[2]}, should be one of "user", "workspace", or "session"`, + }); + debugLogger.error(); + return null; + } + let names: string[] = []; + if (name === '--all') { + let extensions = extensionLoader.getExtensions(); + if (context.invocation?.name === 'enable') { + extensions = extensions.filter((ext) => !ext.isActive); + } + if (context.invocation?.name === 'disable') { + extensions = extensions.filter((ext) => ext.isActive); + } + names = extensions.map((ext) => ext.name); + } else { + names = [name]; + } + + return { + extensionManager: extensionLoader, + names, + scope, + }; +} + +async function disableAction(context: CommandContext, args: string) { + const enableContext = getEnableDisableContext(context, args); + if (!enableContext) return; + + const { names, scope, extensionManager } = enableContext; + for (const name of names) { + await extensionManager.disableExtension(name, scope); + context.ui.addItem({ + type: MessageType.INFO, + text: `Extension "${name}" disabled for the scope "${scope}"`, + }); + } +} + +async function enableAction(context: CommandContext, args: string) { + const enableContext = getEnableDisableContext(context, args); + if (!enableContext) return; + + const { names, scope, extensionManager } = enableContext; + for (const name of names) { + await extensionManager.enableExtension(name, scope); + context.ui.addItem({ + type: MessageType.INFO, + text: `Extension "${name}" enabled for the scope "${scope}"`, + }); + + // Auto-enable any disabled MCP servers for this extension + const extension = extensionManager + .getExtensions() + .find((e) => e.name === name); + + if (extension?.mcpServers) { + const mcpEnablementManager = McpServerEnablementManager.getInstance(); + const mcpClientManager = + context.services.agentContext?.config.getMcpClientManager(); + const enabledServers = await mcpEnablementManager.autoEnableServers( + Object.keys(extension.mcpServers ?? {}), + ); + + if (mcpClientManager && enabledServers.length > 0) { + const restartPromises = enabledServers.map((serverName) => + mcpClientManager.restartServer(serverName).catch((error) => { + context.ui.addItem({ + type: MessageType.WARNING, + text: `Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`, + }); + }), + ); + await Promise.all(restartPromises); + } + + if (enabledServers.length > 0) { + context.ui.addItem({ + type: MessageType.INFO, + text: `Re-enabled MCP servers: ${enabledServers.join(', ')}`, + }); + } + } + } +} + +async function installAction( + context: CommandContext, + args: string, + requestConsentOverride?: (consent: string) => Promise, +) { + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + debugLogger.error( + `Cannot ${context.invocation?.name} extensions in this environment`, + ); + return; + } + + const source = args.trim(); + if (!source) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Usage: /extensions install `, + }); + return; + } + + // Validate that the source is either a valid URL or a valid file path. + let isValid = false; + try { + // Check if it's a valid URL. + new URL(source); + isValid = true; + } catch { + // If not a URL, check for characters that are disallowed in file paths + // and could be used for command injection. + if (!/[;&|`'"]/.test(source)) { + isValid = true; + } + } + + if (!isValid) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Invalid source: ${source}`, + }); + return; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: `Installing extension from "${source}"...`, + }); + + try { + const installMetadata = await inferInstallMetadata(source); + const extension = await extensionLoader.installOrUpdateExtension( + installMetadata, + undefined, + requestConsentOverride, + ); + context.ui.addItem({ + type: MessageType.INFO, + text: `Extension "${extension.name}" installed successfully.`, + }); + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to install extension from "${source}": ${getErrorMessage( + error, + )}`, + }); + } +} + +async function linkAction( + context: CommandContext, + args: string, + requestConsentOverride?: (consent: string) => Promise, +) { + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + debugLogger.error( + `Cannot ${context.invocation?.name} extensions in this environment`, + ); + return; + } + + const sourceFilepath = args.trim(); + if (!sourceFilepath) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Usage: /extensions link `, + }); + return; + } + if (/[;&|`'"]/.test(sourceFilepath)) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Source file path contains disallowed characters: ${sourceFilepath}`, + }); + return; + } + + try { + await stat(sourceFilepath); + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Invalid source: ${sourceFilepath}`, + }); + debugLogger.error( + `Failed to stat path "${sourceFilepath}": ${getErrorMessage(error)}`, + ); + return; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: `Linking extension from "${sourceFilepath}"...`, + }); + + try { + const installMetadata: ExtensionInstallMetadata = { + source: sourceFilepath, + type: 'link', + }; + const extension = await extensionLoader.installOrUpdateExtension( + installMetadata, + undefined, + requestConsentOverride, + ); + context.ui.addItem({ + type: MessageType.INFO, + text: `Extension "${extension.name}" linked successfully.`, + }); + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to link extension from "${sourceFilepath}": ${getErrorMessage( + error, + )}`, + }); + } +} + +async function uninstallAction(context: CommandContext, args: string) { + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + debugLogger.error( + `Cannot ${context.invocation?.name} extensions in this environment`, + ); + return; + } + + const uninstallArgs = args.split(' ').filter((value) => value.length > 0); + const all = uninstallArgs.includes('--all'); + const names = uninstallArgs.filter((a) => !a.startsWith('--')); + + if (!all && names.length === 0) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Usage: /extensions uninstall |--all`, + }); + return; + } + + let namesToUninstall: string[] = []; + if (all) { + namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name); + } else { + namesToUninstall = names; + } + + if (namesToUninstall.length === 0) { + context.ui.addItem({ + type: MessageType.INFO, + text: all ? 'No extensions installed.' : 'No extension name provided.', + }); + return; + } + + for (const extensionName of namesToUninstall) { + context.ui.addItem({ + type: MessageType.INFO, + text: `Uninstalling extension "${extensionName}"...`, + }); + + try { + await extensionLoader.uninstallExtension(extensionName, false); + context.ui.addItem({ + type: MessageType.INFO, + text: `Extension "${extensionName}" uninstalled successfully.`, + }); + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to uninstall extension "${extensionName}": ${getErrorMessage( + error, + )}`, + }); + } + } +} + +async function configAction(context: CommandContext, args: string) { + const parts = args.trim().split(/\s+/).filter(Boolean); + let scope = ExtensionSettingScope.USER; + + const scopeEqIndex = parts.findIndex((p) => p.startsWith('--scope=')); + if (scopeEqIndex > -1) { + const scopeVal = parts[scopeEqIndex].split('=')[1]; + if (scopeVal === 'workspace') { + scope = ExtensionSettingScope.WORKSPACE; + } else if (scopeVal === 'user') { + scope = ExtensionSettingScope.USER; + } + parts.splice(scopeEqIndex, 1); + } else { + const scopeIndex = parts.indexOf('--scope'); + if (scopeIndex > -1) { + const scopeVal = parts[scopeIndex + 1]; + if (scopeVal === 'workspace' || scopeVal === 'user') { + scope = + scopeVal === 'workspace' + ? ExtensionSettingScope.WORKSPACE + : ExtensionSettingScope.USER; + parts.splice(scopeIndex, 2); + } + } + } + + const otherArgs = parts; + const name = otherArgs[0]; + const setting = otherArgs[1]; + + if (name) { + if (name.includes('/') || name.includes('\\') || name.includes('..')) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Invalid extension name. Names cannot contain path separators or "..".', + }); + return; + } + } + + const extensionManager = + context.services.agentContext?.config.getExtensionLoader(); + if (!(extensionManager instanceof ExtensionManager)) { + debugLogger.error( + `Cannot ${context.invocation?.name} extensions in this environment`, + ); + return; + } + + const logger: ConfigLogger = { + log: (message: string) => { + context.ui.addItem({ type: MessageType.INFO, text: message.trim() }); + }, + error: (message: string) => + context.ui.addItem({ type: MessageType.ERROR, text: message }), + }; + + return { + type: 'custom_dialog' as const, + component: React.createElement(ConfigExtensionDialog, { + extensionManager, + onClose: () => context.ui.removeComponent(), + extensionName: name, + settingKey: setting, + scope, + configureAll: !name && !setting, + loggerAdapter: logger, + }), + }; +} + +/** + * Exported for testing. + */ +export function completeExtensions( + context: CommandContext, + partialArg: string, +) { + let extensions = context.services.agentContext?.config.getExtensions() ?? []; + + if (context.invocation?.name === 'enable') { + extensions = extensions.filter((ext) => !ext.isActive); + } + if ( + context.invocation?.name === 'disable' || + context.invocation?.name === 'restart' || + context.invocation?.name === 'reload' + ) { + extensions = extensions.filter((ext) => ext.isActive); + } + const extensionNames = extensions.map((ext) => ext.name); + const suggestions = extensionNames.filter((name) => + name.startsWith(partialArg), + ); + + if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) { + suggestions.unshift('--all'); + } + + return suggestions; +} + +export function completeExtensionsAndScopes( + context: CommandContext, + partialArg: string, +) { + return completeExtensions(context, partialArg).flatMap((s) => [ + `${s} --scope user`, + `${s} --scope workspace`, + `${s} --scope session`, + ]); +} + +const listExtensionsCommand: SlashCommand = { + name: 'list', + description: 'List active extensions', + kind: CommandKind.BUILT_IN, + autoExecute: true, + takesArgs: false, + action: listAction, +}; + +const updateExtensionsCommand: SlashCommand = { + name: 'update', + description: 'Update extensions. Usage: update |--all', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: updateAction, + completion: completeExtensions, +}; + +const disableCommand: SlashCommand = { + name: 'disable', + description: 'Disable an extension', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: disableAction, + completion: completeExtensionsAndScopes, +}; + +const enableCommand: SlashCommand = { + name: 'enable', + description: 'Enable an extension', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: enableAction, + completion: completeExtensionsAndScopes, +}; + +const installCommand: SlashCommand = { + name: 'install', + description: 'Install an extension from a git repo or local path', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: installAction, +}; + +const linkCommand: SlashCommand = { + name: 'link', + description: 'Link an extension from a local path', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: linkAction, +}; + +const uninstallCommand: SlashCommand = { + name: 'uninstall', + altNames: ['delete'], + description: 'Uninstall an extension', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: uninstallAction, + completion: completeExtensions, +}; + +const exploreExtensionsCommand: SlashCommand = { + name: 'explore', + description: 'Open extensions page in your browser', + kind: CommandKind.BUILT_IN, + autoExecute: true, + takesArgs: false, + action: exploreAction, +}; + +const reloadCommand: SlashCommand = { + name: 'reload', + altNames: ['restart'], + description: 'Reload all extensions', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: restartAction, + completion: completeExtensions, +}; + +const configCommand: SlashCommand = { + name: 'config', + description: 'Configure extension settings', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: configAction, +}; + +import { parseSlashCommand } from '../../utils/commands.js'; + +export function extensionsCommand( + enableExtensionReloading?: boolean, +): SlashCommand { + const conditionalCommands = enableExtensionReloading + ? [ + disableCommand, + enableCommand, + installCommand, + uninstallCommand, + linkCommand, + configCommand, + ] + : []; + const subCommands = [ + listExtensionsCommand, + updateExtensionsCommand, + exploreExtensionsCommand, + reloadCommand, + ...conditionalCommands, + ]; + + return { + name: 'extensions', + description: 'Manage extensions', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands, + action: async (context, args) => { + if (args) { + const parsed = parseSlashCommand(`/${args}`, subCommands); + if (parsed.commandToExecute?.action) { + return parsed.commandToExecute.action(context, parsed.args); + } + } + // Default to list if no subcommand is provided + return listExtensionsCommand.action!(context, args); + }, + }; +} diff --git a/packages/cli/src/ui/commands/footerCommand.tsx b/packages/cli/src/ui/commands/footerCommand.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4a6760e229509473ba00cca4c03b364f54916e02 --- /dev/null +++ b/packages/cli/src/ui/commands/footerCommand.tsx @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type SlashCommand, + type CommandContext, + type OpenCustomDialogActionReturn, + CommandKind, +} from './types.js'; +import { FooterConfigDialog } from '../components/FooterConfigDialog.js'; + +export const footerCommand: SlashCommand = { + name: 'footer', + altNames: ['statusline'], + description: 'Configure which items appear in the footer (statusline)', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (context: CommandContext): OpenCustomDialogActionReturn => ({ + type: 'custom_dialog', + component: , + }), +}; diff --git a/packages/cli/src/ui/commands/gemmaStatusCommand.ts b/packages/cli/src/ui/commands/gemmaStatusCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c581b31a1ec071a968ef26dac643383ea261fc8 --- /dev/null +++ b/packages/cli/src/ui/commands/gemmaStatusCommand.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from './types.js'; +import { MessageType, type HistoryItemGemmaStatus } from '../types.js'; +import { checkGemmaStatus } from '../../commands/gemma/status.js'; +import { GEMMA_MODEL_NAME } from '../../commands/gemma/constants.js'; + +export const gemmaStatusCommand: SlashCommand = { + name: 'gemma', + description: 'Check local Gemma model routing status', + kind: CommandKind.BUILT_IN, + autoExecute: true, + isSafeConcurrent: true, + action: async (context) => { + const port = + parseInt( + context.services.settings.merged.experimental?.gemmaModelRouter?.classifier?.host?.match( + /:(\d+)/, + )?.[1] ?? '', + 10, + ) || undefined; + const status = await checkGemmaStatus(port); + const item: Omit = { + type: MessageType.GEMMA_STATUS, + binaryInstalled: status.binaryInstalled, + binaryPath: status.binaryPath, + modelName: GEMMA_MODEL_NAME, + modelDownloaded: status.modelDownloaded, + serverRunning: status.serverRunning, + serverPid: status.serverPid, + serverPort: status.port, + settingsEnabled: status.settingsEnabled, + allPassing: status.allPassing, + }; + context.ui.addItem(item); + }, +}; diff --git a/packages/cli/src/ui/commands/helpCommand.test.ts b/packages/cli/src/ui/commands/helpCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..76fc1b888cecb873ea47bca3ec024e19912206ae --- /dev/null +++ b/packages/cli/src/ui/commands/helpCommand.test.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { helpCommand } from './helpCommand.js'; +import { CommandKind, type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; + +describe('helpCommand', () => { + let mockContext: CommandContext; + const originalPlatform = process.platform; + const action = helpCommand.action; + + if (!action) { + throw new Error('Help command has no action'); + } + + beforeEach(() => { + mockContext = createMockCommandContext({ + ui: { + addItem: vi.fn(), + }, + } as unknown as CommandContext); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it('should add a help message to the UI history by default', async () => { + await action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.HELP, + timestamp: expect.any(Date), + }), + ); + }); + + it('should have the correct command properties', () => { + expect(helpCommand.name).toBe('help'); + expect(helpCommand.kind).toBe(CommandKind.BUILT_IN); + expect(helpCommand.description).toBe('For help on gemini-cli'); + }); + + describe('Antigravity installer commands help', () => { + it('should output macOS installation command on darwin platform', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + await action(mockContext, 'install antigravity cli'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `To install the Antigravity CLI on macOS, run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.sh | bash'`, + }), + ); + }); + + it('should output Linux installation command on linux platform', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + + await action(mockContext, 'how do I install antigravity CLI'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `To install the Antigravity CLI on Linux, run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.sh | bash'`, + }), + ); + }); + + it('should output Windows PowerShell installation command on win32 when PSModulePath is set', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + vi.stubEnv('PSModulePath', 'C:\\some\\path'); + + await action(mockContext, 'how do I migrate to antigravity CLI'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `To install the Antigravity CLI on Windows (PowerShell), run the following command:\n\n'irm https://antigravity.google/cli/install.ps1 | iex'`, + }), + ); + }); + + it('should output Windows CMD installation command on win32 when PSModulePath is not set', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + vi.stubEnv('PSModulePath', ''); + + await action(mockContext, 'install antigravity cli'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `To install the Antigravity CLI on Windows (Command Prompt), run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd'`, + }), + ); + }); + + it('should learn more message on unsupported platform', async () => { + Object.defineProperty(process, 'platform', { value: 'freebsd' }); + + await action(mockContext, 'install antigravity cli'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Learn more about Antigravity CLI at https://antigravity.google/docs/cli-getting-started', + }), + ); + }); + + it('should fall back to default help if query does not contain install or migrate', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + await action(mockContext, 'antigravity cli'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.HELP, + }), + ); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..b10defbec2bee2cc6651d803f2866a1db76fd872 --- /dev/null +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from './types.js'; +import { MessageType, type HistoryItemHelp } from '../types.js'; +import { getAntigravityInstallInfo } from '../utils/antigravityUtils.js'; + +export const helpCommand: SlashCommand = { + name: 'help', + kind: CommandKind.BUILT_IN, + description: 'For help on gemini-cli', + autoExecute: true, + action: async (context, args) => { + const lowerArgs = args?.toLowerCase() || ''; + const hasAntigravity = lowerArgs.includes('antigravity'); + const hasInstallOrMigrate = + lowerArgs.includes('install') || lowerArgs.includes('migrate'); + + if (hasAntigravity && hasInstallOrMigrate) { + const info = getAntigravityInstallInfo(); + + if (info) { + context.ui.addItem({ + type: MessageType.INFO, + text: `To install the Antigravity CLI on ${info.platformName}, run the following command:\n\n'${info.installCmd}'`, + }); + } else { + context.ui.addItem({ + type: MessageType.INFO, + text: `Learn more about Antigravity CLI at https://antigravity.google/docs/cli-getting-started`, + }); + } + return; + } + + const helpItem: Omit = { + type: MessageType.HELP, + timestamp: new Date(), + }; + + context.ui.addItem(helpItem); + }, +}; diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0059f86105091f5bd092c5df839aeaed29a83340 --- /dev/null +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -0,0 +1,835 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { hooksCommand } from './hooksCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { + HookType, + HookEventName, + ConfigSource, + type HookRegistryEntry, +} from '@google/gemini-cli-core'; +import type { CommandContext } from './types.js'; +import { SettingScope } from '../../config/settings.js'; + +describe('hooksCommand', () => { + let mockContext: CommandContext; + let mockHookSystem: { + getAllHooks: ReturnType; + setHookEnabled: ReturnType; + getRegistry: ReturnType; + }; + let mockConfig: { + getHookSystem: ReturnType; + getEnableHooks: ReturnType; + updateDisabledHooks: ReturnType; + }; + let mockSettings: { + merged: { + hooksConfig?: { + disabled?: string[]; + }; + }; + setValue: ReturnType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workspace: { path: string; settings: any }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + user: { path: string; settings: any }; + forScope: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Create mock hook system + mockHookSystem = { + getAllHooks: vi.fn().mockReturnValue([]), + setHookEnabled: vi.fn(), + getRegistry: vi.fn().mockReturnValue({ + initialize: vi.fn().mockResolvedValue(undefined), + }), + }; + + // Create mock config + mockConfig = { + getHookSystem: vi.fn().mockReturnValue(mockHookSystem), + getEnableHooks: vi.fn().mockReturnValue(true), + updateDisabledHooks: vi.fn(), + }; + + // Create mock settings + const mockUser = { + path: '/mock/user.json', + settings: { hooksConfig: { disabled: [] } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + const mockWorkspace = { + path: '/mock/workspace.json', + settings: { hooksConfig: { disabled: [] } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + mockSettings = { + merged: { + hooksConfig: { + disabled: [], + }, + }, + setValue: vi.fn(), + workspace: mockWorkspace, + user: mockUser, + forScope: vi.fn((scope) => { + if (scope === SettingScope.User) return mockUser; + if (scope === SettingScope.Workspace) return mockWorkspace; + return mockUser; + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + // Create mock context with config and settings + mockContext = createMockCommandContext({ + services: { + agentContext: { config: mockConfig }, + settings: mockSettings, + }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('root command', () => { + it('should have the correct name and description', () => { + expect(hooksCommand.name).toBe('hooks'); + expect(hooksCommand.description).toBe('Manage hooks'); + }); + + it('should have all expected subcommands', () => { + expect(hooksCommand.subCommands).toBeDefined(); + expect(hooksCommand.subCommands).toHaveLength(5); + + const subCommandNames = hooksCommand.subCommands!.map((cmd) => cmd.name); + expect(subCommandNames).toContain('panel'); + expect(subCommandNames).toContain('enable'); + expect(subCommandNames).toContain('disable'); + expect(subCommandNames).toContain('enable-all'); + expect(subCommandNames).toContain('disable-all'); + }); + + it('should delegate to panel action when invoked without subcommand', async () => { + if (!hooksCommand.action) { + throw new Error('hooks command must have an action'); + } + + mockHookSystem.getAllHooks.mockReturnValue([ + createMockHook('test-hook', HookEventName.BeforeTool, true), + ]); + + const result = await hooksCommand.action(mockContext, ''); + + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); + }); + }); + + describe('panel subcommand', () => { + it('should return error when config is not loaded', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + agentContext: null, + }, + }); + + const panelCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'panel', + ); + if (!panelCmd?.action) { + throw new Error('panel command must have an action'); + } + + const result = await panelCmd.action(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should return custom_dialog even when hook system is not enabled', async () => { + mockConfig.getHookSystem.mockReturnValue(null); + + const panelCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'panel', + ); + if (!panelCmd?.action) { + throw new Error('panel command must have an action'); + } + + const result = await panelCmd.action(mockContext, ''); + + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); + }); + + it('should return custom_dialog when no hooks are configured', async () => { + mockHookSystem.getAllHooks.mockReturnValue([]); + (mockContext.services.settings.merged as Record)[ + 'hooksConfig' + ] = { enabled: true }; + + const panelCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'panel', + ); + if (!panelCmd?.action) { + throw new Error('panel command must have an action'); + } + + const result = await panelCmd.action(mockContext, ''); + + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); + }); + + it('should return custom_dialog when hooks are configured', async () => { + const mockHooks: HookRegistryEntry[] = [ + createMockHook('echo-test', HookEventName.BeforeTool, true), + createMockHook('notify', HookEventName.AfterAgent, false), + ]; + + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + (mockContext.services.settings.merged as Record)[ + 'hooksConfig' + ] = { enabled: true }; + + const panelCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'panel', + ); + if (!panelCmd?.action) { + throw new Error('panel command must have an action'); + } + + const result = await panelCmd.action(mockContext, ''); + + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); + }); + }); + + describe('enable subcommand', () => { + it('should return error when config is not loaded', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + agentContext: null, + }, + }); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.action) { + throw new Error('enable command must have an action'); + } + + const result = await enableCmd.action(contextWithoutConfig, 'test-hook'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should return error when hook system is not enabled', async () => { + mockConfig.getHookSystem.mockReturnValue(null); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.action) { + throw new Error('enable command must have an action'); + } + + const result = await enableCmd.action(mockContext, 'test-hook'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }); + }); + + it('should return error when hook name is not provided', async () => { + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.action) { + throw new Error('enable command must have an action'); + } + + const result = await enableCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /hooks enable ', + }); + }); + + it('should enable a hook and update settings', async () => { + // Update the user settings with disabled hooks + mockSettings.user.settings.hooksConfig.disabled = [ + 'test-hook', + 'other-hook', + ]; + mockSettings.workspace.settings.hooksConfig.disabled = []; + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.action) { + throw new Error('enable command must have an action'); + } + + const result = await enableCmd.action(mockContext, 'test-hook'); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'hooksConfig.disabled', + ['other-hook'], + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'test-hook', + true, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Hook "test-hook" enabled by removing it from the disabled list in user (/mock/user.json) and workspace (/mock/workspace.json) settings.', + }); + }); + + it('should complete hook names using friendly names', () => { + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + )!; + + const hookEntry = createMockHook( + './hooks/test.sh', + HookEventName.BeforeTool, + false, // Must be disabled for enable completion + ); + hookEntry.config.name = 'friendly-name'; + + mockHookSystem.getAllHooks.mockReturnValue([hookEntry]); + + const completions = enableCmd.completion!(mockContext, 'frie'); + expect(completions).toContain('friendly-name'); + }); + }); + + describe('disable subcommand', () => { + it('should return error when config is not loaded', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + agentContext: null, + }, + }); + + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + ); + if (!disableCmd?.action) { + throw new Error('disable command must have an action'); + } + + const result = await disableCmd.action(contextWithoutConfig, 'test-hook'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should return error when hook system is not enabled', async () => { + mockConfig.getHookSystem.mockReturnValue(null); + + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + ); + if (!disableCmd?.action) { + throw new Error('disable command must have an action'); + } + + const result = await disableCmd.action(mockContext, 'test-hook'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }); + }); + + it('should return error when hook name is not provided', async () => { + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + ); + if (!disableCmd?.action) { + throw new Error('disable command must have an action'); + } + + const result = await disableCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /hooks disable ', + }); + }); + + it('should disable a hook and update settings', async () => { + // Ensure not disabled anywhere + mockSettings.workspace.settings.hooksConfig.disabled = []; + mockSettings.user.settings.hooksConfig.disabled = []; + + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + ); + if (!disableCmd?.action) { + throw new Error('disable command must have an action'); + } + + const result = await disableCmd.action(mockContext, 'test-hook'); + + // Should default to workspace if present + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'hooksConfig.disabled', + ['test-hook'], + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'test-hook', + false, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Hook "test-hook" disabled by adding it to the disabled list in workspace (/mock/workspace.json) settings.', + }); + }); + + it('should return info when hook is already disabled', async () => { + // Update the context's settings with the hook already disabled in Workspace + mockSettings.workspace.settings.hooksConfig.disabled = ['test-hook']; + + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + ); + if (!disableCmd?.action) { + throw new Error('disable command must have an action'); + } + + const result = await disableCmd.action(mockContext, 'test-hook'); + + expect(mockContext.services.settings.setValue).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Hook "test-hook" is already disabled.', + }); + }); + + it('should complete hook names using friendly names', () => { + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + )!; + + const hookEntry = createMockHook( + './hooks/test.sh', + HookEventName.BeforeTool, + true, // Must be enabled for disable completion + ); + hookEntry.config.name = 'friendly-name'; + + mockHookSystem.getAllHooks.mockReturnValue([hookEntry]); + + const completions = disableCmd.completion!(mockContext, 'frie'); + expect(completions).toContain('friendly-name'); + }); + }); + + describe('completion', () => { + it('should return empty array when config is not available', () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + agentContext: null, + }, + }); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.completion) { + throw new Error('enable command must have completion'); + } + + const result = enableCmd.completion(contextWithoutConfig, 'test'); + expect(result).toEqual([]); + }); + + it('should return empty array when hook system is not enabled', () => { + mockConfig.getHookSystem.mockReturnValue(null); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.completion) { + throw new Error('enable command must have completion'); + } + + const result = enableCmd.completion(mockContext, 'test'); + expect(result).toEqual([]); + }); + + it('should return matching hook names based on status', () => { + const mockHooks: HookRegistryEntry[] = [ + createMockHook('test-hook-enabled', HookEventName.BeforeTool, true), + createMockHook('test-hook-disabled', HookEventName.AfterTool, false), + ]; + + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + )!; + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + )!; + + const enableResult = enableCmd.completion!(mockContext, 'test'); + expect(enableResult).toEqual(['test-hook-disabled']); + + const disableResult = disableCmd.completion!(mockContext, 'test'); + expect(disableResult).toEqual(['test-hook-enabled']); + }); + + it('should return all relevant hook names when partial is empty', () => { + const mockHooks: HookRegistryEntry[] = [ + createMockHook('hook-enabled', HookEventName.BeforeTool, true), + createMockHook('hook-disabled', HookEventName.AfterTool, false), + ]; + + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + )!; + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + )!; + + expect(enableCmd.completion!(mockContext, '')).toEqual(['hook-disabled']); + expect(disableCmd.completion!(mockContext, '')).toEqual(['hook-enabled']); + }); + + it('should handle hooks without command name gracefully', () => { + const mockHooks: HookRegistryEntry[] = [ + createMockHook('test-hook', HookEventName.BeforeTool, false), + { + ...createMockHook('', HookEventName.AfterTool, false), + config: { command: '', type: HookType.Command, timeout: 30 }, + }, + ]; + + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const enableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable', + ); + if (!enableCmd?.completion) { + throw new Error('enable command must have completion'); + } + + const result = enableCmd.completion(mockContext, 'test'); + expect(result).toEqual(['test-hook']); + }); + }); + + describe('enable-all subcommand', () => { + it('should return error when config is not loaded', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + agentContext: null, + }, + }); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should return error when hook system is not enabled', async () => { + mockConfig.getHookSystem.mockReturnValue(null); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }); + }); + + it('should enable all disabled hooks', async () => { + const mockHooks = [ + createMockHook('hook-1', HookEventName.BeforeTool, false), + createMockHook('hook-2', HookEventName.AfterTool, false), + createMockHook('hook-3', HookEventName.BeforeAgent, true), // already enabled + ]; + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(mockContext, ''); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + expect.any(String), // enableAll uses legacy logic so it might return 'Workspace' or 'User' depending on ternary + 'hooksConfig.disabled', + [], + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'hook-1', + true, + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'hook-2', + true, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Enabled 2 hook(s) successfully.', + }); + }); + + it('should return info when no hooks are configured', async () => { + mockHookSystem.getAllHooks.mockReturnValue([]); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No hooks configured.', + }); + }); + + it('should return info when all hooks are already enabled', async () => { + const mockHooks = [ + createMockHook('hook-1', HookEventName.BeforeTool, true), + createMockHook('hook-2', HookEventName.AfterTool, true), + ]; + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(mockContext, ''); + + expect(mockContext.services.settings.setValue).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'All hooks are already enabled.', + }); + }); + }); + + describe('disable-all subcommand', () => { + it('should return error when config is not loaded', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + agentContext: null, + }, + }); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should return error when hook system is not enabled', async () => { + mockConfig.getHookSystem.mockReturnValue(null); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }); + }); + + it('should disable all enabled hooks', async () => { + const mockHooks = [ + createMockHook('hook-1', HookEventName.BeforeTool, true), + createMockHook('hook-2', HookEventName.AfterTool, true), + createMockHook('hook-3', HookEventName.BeforeAgent, false), // already disabled + ]; + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(mockContext, ''); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + expect.any(String), + 'hooksConfig.disabled', + ['hook-1', 'hook-2', 'hook-3'], + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'hook-1', + false, + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'hook-2', + false, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Disabled 2 hook(s) successfully.', + }); + }); + + it('should return info when no hooks are configured', async () => { + mockHookSystem.getAllHooks.mockReturnValue([]); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No hooks configured.', + }); + }); + + it('should return info when all hooks are already disabled', async () => { + const mockHooks = [ + createMockHook('hook-1', HookEventName.BeforeTool, false), + createMockHook('hook-2', HookEventName.AfterTool, false), + ]; + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(mockContext, ''); + + expect(mockContext.services.settings.setValue).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'All hooks are already disabled.', + }); + }); + }); +}); + +/** + * Helper function to create a mock HookRegistryEntry + */ +function createMockHook( + command: string, + eventName: HookEventName, + enabled: boolean, +): HookRegistryEntry { + return { + config: { + command, + type: HookType.Command, + timeout: 30, + }, + source: ConfigSource.Project, + eventName, + matcher: undefined, + sequential: false, + enabled, + }; +} diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bdc9ead54ebcbabb8f6ce3ae04ad215bf570480 --- /dev/null +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -0,0 +1,411 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createElement } from 'react'; +import type { + SlashCommand, + CommandContext, + OpenCustomDialogActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import type { + HookRegistryEntry, + MessageActionReturn, +} from '@google/gemini-cli-core'; +import { getErrorMessage } from '@google/gemini-cli-core'; +import { SettingScope, isLoadableSettingScope } from '../../config/settings.js'; +import { enableHook, disableHook } from '../../utils/hookSettings.js'; +import { renderHookActionFeedback } from '../../utils/hookUtils.js'; +import { HooksDialog } from '../components/HooksDialog.js'; + +/** + * Display a formatted list of hooks with their status in a dialog + */ +function panelAction( + context: CommandContext, +): MessageActionReturn | OpenCustomDialogActionReturn { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const hookSystem = config.getHookSystem(); + const allHooks = hookSystem?.getAllHooks() || []; + + return { + type: 'custom_dialog', + component: createElement(HooksDialog, { + hooks: allHooks, + onClose: () => context.ui.removeComponent(), + }), + }; +} + +/** + * Enable a hook by name + */ +async function enableAction( + context: CommandContext, + args: string, +): Promise { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }; + } + + const hookName = args.trim(); + if (!hookName) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /hooks enable ', + }; + } + + const settings = context.services.settings; + const result = enableHook(settings, hookName); + + if (result.status === 'success') { + hookSystem.setHookEnabled(hookName, true); + } + + const feedback = renderHookActionFeedback( + result, + (label, path) => `${label} (${path})`, + ); + + return { + type: 'message', + messageType: result.status === 'error' ? 'error' : 'info', + content: feedback, + }; +} + +/** + * Disable a hook by name + */ +async function disableAction( + context: CommandContext, + args: string, +): Promise { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }; + } + + const hookName = args.trim(); + if (!hookName) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /hooks disable ', + }; + } + + const settings = context.services.settings; + const scope = settings.workspace ? SettingScope.Workspace : SettingScope.User; + + const result = disableHook(settings, hookName, scope); + + if (result.status === 'success') { + hookSystem.setHookEnabled(hookName, false); + } + + const feedback = renderHookActionFeedback( + result, + (label, path) => `${label} (${path})`, + ); + + return { + type: 'message', + messageType: result.status === 'error' ? 'error' : 'info', + content: feedback, + }; +} + +/** + * Completion function for enabled hook names (to be disabled) + */ +function completeEnabledHookNames( + context: CommandContext, + partialArg: string, +): string[] { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) return []; + + const hookSystem = config.getHookSystem(); + if (!hookSystem) return []; + + const allHooks = hookSystem.getAllHooks(); + return allHooks + .filter((hook) => hook.enabled) + .map((hook) => getHookDisplayName(hook)) + .filter((name) => name.startsWith(partialArg)); +} + +/** + * Completion function for disabled hook names (to be enabled) + */ +function completeDisabledHookNames( + context: CommandContext, + partialArg: string, +): string[] { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) return []; + + const hookSystem = config.getHookSystem(); + if (!hookSystem) return []; + + const allHooks = hookSystem.getAllHooks(); + return allHooks + .filter((hook) => !hook.enabled) + .map((hook) => getHookDisplayName(hook)) + .filter((name) => name.startsWith(partialArg)); +} + +/** + * Get a display name for a hook + */ +function getHookDisplayName(hook: HookRegistryEntry): string { + return hook.config.name || hook.config.command || 'unknown-hook'; +} + +/** + * Enable all hooks by clearing the disabled list + */ +async function enableAllAction( + context: CommandContext, +): Promise { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }; + } + + const settings = context.services.settings; + const allHooks = hookSystem.getAllHooks(); + + if (allHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'No hooks configured.', + }; + } + + const disabledHooks = allHooks.filter((hook) => !hook.enabled); + if (disabledHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'All hooks are already enabled.', + }; + } + + try { + const scopes = [SettingScope.Workspace, SettingScope.User]; + for (const scope of scopes) { + if (isLoadableSettingScope(scope)) { + settings.setValue(scope, 'hooksConfig.disabled', []); + } + } + + for (const hook of disabledHooks) { + const hookName = getHookDisplayName(hook); + hookSystem.setHookEnabled(hookName, true); + } + + return { + type: 'message', + messageType: 'info', + content: `Enabled ${disabledHooks.length} hook(s) successfully.`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to enable hooks: ${getErrorMessage(error)}`, + }; + } +} + +/** + * Disable all hooks by adding all hooks to the disabled list + */ +async function disableAllAction( + context: CommandContext, +): Promise { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }; + } + + const settings = context.services.settings; + const allHooks = hookSystem.getAllHooks(); + + if (allHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'No hooks configured.', + }; + } + + const enabledHooks = allHooks.filter((hook) => hook.enabled); + if (enabledHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'All hooks are already disabled.', + }; + } + + try { + const allHookNames = allHooks.map((hook) => getHookDisplayName(hook)); + const scope = settings.workspace + ? SettingScope.Workspace + : SettingScope.User; + settings.setValue(scope, 'hooksConfig.disabled', allHookNames); + + for (const hook of enabledHooks) { + const hookName = getHookDisplayName(hook); + hookSystem.setHookEnabled(hookName, false); + } + + return { + type: 'message', + messageType: 'info', + content: `Disabled ${enabledHooks.length} hook(s) successfully.`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to disable hooks: ${getErrorMessage(error)}`, + }; + } +} + +const panelCommand: SlashCommand = { + name: 'panel', + altNames: ['list', 'show'], + description: 'Display all registered hooks with their status', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: panelAction, +}; + +const enableCommand: SlashCommand = { + name: 'enable', + description: 'Enable a hook by name', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: enableAction, + completion: completeDisabledHookNames, +}; + +const disableCommand: SlashCommand = { + name: 'disable', + description: 'Disable a hook by name', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: disableAction, + completion: completeEnabledHookNames, +}; + +const enableAllCommand: SlashCommand = { + name: 'enable-all', + altNames: ['enableall'], + description: 'Enable all disabled hooks', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: enableAllAction, +}; + +const disableAllCommand: SlashCommand = { + name: 'disable-all', + altNames: ['disableall'], + description: 'Disable all enabled hooks', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: disableAllAction, +}; + +export const hooksCommand: SlashCommand = { + name: 'hooks', + description: 'Manage hooks', + kind: CommandKind.BUILT_IN, + subCommands: [ + panelCommand, + enableCommand, + disableCommand, + enableAllCommand, + disableAllCommand, + ], + action: (context: CommandContext) => panelCommand.action!(context, ''), +}; diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2cb880feaad4107df2652145b26ff835eaac6ed5 --- /dev/null +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -0,0 +1,274 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; +import { ideCommand } from './ideCommand.js'; +import { type CommandContext } from './types.js'; +import { IDE_DEFINITIONS } from '@google/gemini-cli-core'; +import * as core from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getOauthClient: vi.fn(original.getOauthClient), + getIdeInstaller: vi.fn(original.getIdeInstaller), + IdeClient: { + getInstance: vi.fn(), + }, + }; +}); + +describe('ideCommand', () => { + let mockContext: CommandContext; + let mockIdeClient: core.IdeClient; + let platformSpy: MockInstance; + + beforeEach(() => { + vi.resetAllMocks(); + + mockIdeClient = { + reconnect: vi.fn(), + disconnect: vi.fn(), + connect: vi.fn(), + getCurrentIde: vi.fn(), + getConnectionStatus: vi.fn(), + getDetectedIdeDisplayName: vi.fn(), + } as unknown as core.IdeClient; + + vi.mocked(core.IdeClient.getInstance).mockResolvedValue(mockIdeClient); + vi.mocked(mockIdeClient.getDetectedIdeDisplayName).mockReturnValue( + 'VS Code', + ); + + mockContext = { + ui: { + addItem: vi.fn(), + }, + services: { + settings: { + setValue: vi.fn(), + }, + agentContext: { + config: { + getIdeMode: vi.fn(), + setIdeMode: vi.fn(), + getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), + }, + }, + }, + } as unknown as CommandContext; + + platformSpy = vi.spyOn(process, 'platform', 'get'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return the ide command', async () => { + vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue( + IDE_DEFINITIONS.vscode, + ); + vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ + status: core.IDEConnectionStatus.Disconnected, + }); + const command = await ideCommand(); + expect(command).not.toBeNull(); + expect(command.name).toBe('ide'); + expect(command.subCommands).toHaveLength(3); + expect(command.subCommands?.[0].name).toBe('enable'); + expect(command.subCommands?.[1].name).toBe('status'); + expect(command.subCommands?.[2].name).toBe('install'); + }); + + it('should show disable command when connected', async () => { + vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue( + IDE_DEFINITIONS.vscode, + ); + vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ + status: core.IDEConnectionStatus.Connected, + }); + const command = await ideCommand(); + expect(command).not.toBeNull(); + const subCommandNames = command.subCommands?.map((cmd) => cmd.name); + expect(subCommandNames).toContain('disable'); + expect(subCommandNames).not.toContain('enable'); + }); + + describe('status subcommand', () => { + beforeEach(() => { + vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue( + IDE_DEFINITIONS.vscode, + ); + }); + + it('should show connected status', async () => { + vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ + status: core.IDEConnectionStatus.Connected, + }); + const command = await ideCommand(); + const result = await command.subCommands!.find( + (c) => c.name === 'status', + )!.action!(mockContext, ''); + expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: '🟢 Connected to VS Code', + }); + }); + + it('should show connecting status', async () => { + vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ + status: core.IDEConnectionStatus.Connecting, + }); + const command = await ideCommand(); + const result = await command.subCommands!.find( + (c) => c.name === 'status', + )!.action!(mockContext, ''); + expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `🟡 Connecting...`, + }); + }); + it('should show disconnected status', async () => { + vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ + status: core.IDEConnectionStatus.Disconnected, + }); + const command = await ideCommand(); + const result = await command.subCommands!.find( + (c) => c.name === 'status', + )!.action!(mockContext, ''); + expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: `🔴 Disconnected`, + }); + }); + + it('should show disconnected status with details', async () => { + const details = 'Something went wrong'; + vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ + status: core.IDEConnectionStatus.Disconnected, + details, + }); + const command = await ideCommand(); + const result = await command.subCommands!.find( + (c) => c.name === 'status', + )!.action!(mockContext, ''); + expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: `🔴 Disconnected: ${details}`, + }); + }); + }); + + describe('install subcommand', () => { + const mockInstall = vi.fn(); + beforeEach(() => { + vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue( + IDE_DEFINITIONS.vscode, + ); + vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ + status: core.IDEConnectionStatus.Disconnected, + }); + vi.mocked(core.getIdeInstaller).mockReturnValue({ + install: mockInstall, + }); + platformSpy.mockReturnValue('linux'); + }); + + it('should install the extension', async () => { + vi.useFakeTimers(); + mockInstall.mockResolvedValue({ + success: true, + message: 'Successfully installed.', + }); + + const command = await ideCommand(); + + // For the polling loop inside the action. + vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ + status: core.IDEConnectionStatus.Connected, + }); + + const actionPromise = command.subCommands!.find( + (c) => c.name === 'install', + )!.action!(mockContext, ''); + await vi.runAllTimersAsync(); + await actionPromise; + + expect(core.getIdeInstaller).toHaveBeenCalledWith(IDE_DEFINITIONS.vscode); + expect(mockInstall).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: `Installing IDE companion...`, + }), + expect.any(Number), + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: 'Successfully installed.', + }), + expect.any(Number), + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: '🟢 Connected to VS Code', + }), + expect.any(Number), + ); + vi.useRealTimers(); + }, 10000); + + it('should show an error if installation fails', async () => { + mockInstall.mockResolvedValue({ + success: false, + message: 'Installation failed.', + }); + + const command = await ideCommand(); + await command.subCommands!.find((c) => c.name === 'install')!.action!( + mockContext, + '', + ); + + expect(core.getIdeInstaller).toHaveBeenCalledWith(IDE_DEFINITIONS.vscode); + expect(mockInstall).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: `Installing IDE companion...`, + }), + expect.any(Number), + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: 'Installation failed.', + }), + expect.any(Number), + ); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..df26fdf471076f08ec43ef10aab85871a8066472 --- /dev/null +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -0,0 +1,324 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type Config, + IdeClient, + type File, + logIdeConnection, + IdeConnectionEvent, + IdeConnectionType, +} from '@google/gemini-cli-core'; +import { + getIdeInstaller, + IDEConnectionStatus, + ideContextStore, + GEMINI_CLI_COMPANION_EXTENSION_NAME, +} from '@google/gemini-cli-core'; +import path from 'node:path'; +import type { + CommandContext, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { SettingScope } from '../../config/settings.js'; + +function getIdeStatusMessage(ideClient: IdeClient): { + messageType: 'info' | 'error'; + content: string; +} { + const connection = ideClient.getConnectionStatus(); + switch (connection.status) { + case IDEConnectionStatus.Connected: + return { + messageType: 'info', + content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`, + }; + case IDEConnectionStatus.Connecting: + return { + messageType: 'info', + content: `🟡 Connecting...`, + }; + default: { + let content = `🔴 Disconnected`; + if (connection?.details) { + content += `: ${connection.details}`; + } + return { + messageType: 'error', + content, + }; + } + } +} + +function formatFileList(openFiles: File[]): string { + const basenameCounts = new Map(); + for (const file of openFiles) { + const basename = path.basename(file.path); + basenameCounts.set(basename, (basenameCounts.get(basename) || 0) + 1); + } + + const fileList = openFiles + .map((file: File) => { + const basename = path.basename(file.path); + const isDuplicate = (basenameCounts.get(basename) || 0) > 1; + const parentDir = path.basename(path.dirname(file.path)); + const displayName = isDuplicate + ? `${basename} (/${parentDir})` + : basename; + + return ` - ${displayName}${file.isActive ? ' (active)' : ''}`; + }) + .join('\n'); + + const infoMessage = ` +(Note: The file list is limited to a number of recently accessed files within your workspace and only includes local files on disk)`; + + return `\n\nOpen files:\n${fileList}\n${infoMessage}`; +} + +async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{ + messageType: 'info' | 'error'; + content: string; +}> { + const connection = ideClient.getConnectionStatus(); + switch (connection.status) { + case IDEConnectionStatus.Connected: { + let content = `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`; + const context = ideContextStore.get(); + const openFiles = context?.workspaceState?.openFiles; + if (openFiles && openFiles.length > 0) { + content += formatFileList(openFiles); + } + return { + messageType: 'info', + content, + }; + } + case IDEConnectionStatus.Connecting: + return { + messageType: 'info', + content: `🟡 Connecting...`, + }; + default: { + let content = `🔴 Disconnected`; + if (connection?.details) { + content += `: ${connection.details}`; + } + return { + messageType: 'error', + content, + }; + } + } +} + +async function setIdeModeAndSyncConnection( + config: Config, + value: boolean, + options: { logToConsole?: boolean } = {}, +): Promise { + config.setIdeMode(value); + const ideClient = await IdeClient.getInstance(); + if (value) { + await ideClient.connect(options); + logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.SESSION)); + } else { + await ideClient.disconnect(); + } +} + +export const ideCommand = async (): Promise => { + const ideClient = await IdeClient.getInstance(); + const currentIDE = ideClient.getCurrentIde(); + if (!currentIDE) { + return { + name: 'ide', + description: 'Manage IDE integration', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: (): SlashCommandActionReturn => + ({ + type: 'message', + messageType: 'error', + content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: Antigravity, VS Code, or VS Code forks.`, + }) as const, + }; + } + + const ideSlashCommand: SlashCommand = { + name: 'ide', + description: 'Manage IDE integration', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [], + }; + + const statusCommand: SlashCommand = { + name: 'status', + description: 'Check status of IDE integration', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (): Promise => { + const { messageType, content } = + await getIdeStatusMessageWithFiles(ideClient); + return { + type: 'message', + messageType, + content, + } as const; + }, + }; + + const installCommand: SlashCommand = { + name: 'install', + description: `Install required IDE companion for ${ideClient.getDetectedIdeDisplayName()}`, + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context) => { + const installer = getIdeInstaller(currentIDE); + if (!installer) { + context.ui.addItem( + { + type: 'error', + text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${GEMINI_CLI_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`, + }, + Date.now(), + ); + return; + } + + context.ui.addItem( + { + type: 'info', + text: `Installing IDE companion...`, + }, + Date.now(), + ); + + const result = await installer.install(); + context.ui.addItem( + { + type: result.success ? 'info' : 'error', + text: result.message, + }, + Date.now(), + ); + if (result.success) { + context.services.settings.setValue( + SettingScope.User, + 'ide.enabled', + true, + ); + // Poll for up to 5 seconds for the extension to activate. + for (let i = 0; i < 10; i++) { + await setIdeModeAndSyncConnection( + context.services.agentContext!.config, + true, + { + logToConsole: false, + }, + ); + if ( + ideClient.getConnectionStatus().status === + IDEConnectionStatus.Connected + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + const { messageType, content } = getIdeStatusMessage(ideClient); + if (messageType === 'error') { + context.ui.addItem( + { + type: messageType, + text: `Failed to automatically enable IDE integration. To fix this, run the CLI in a new terminal window.`, + }, + Date.now(), + ); + } else { + context.ui.addItem( + { + type: messageType, + text: content, + }, + Date.now(), + ); + } + } + }, + }; + + const enableCommand: SlashCommand = { + name: 'enable', + description: 'Enable IDE integration', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext) => { + context.services.settings.setValue( + SettingScope.User, + 'ide.enabled', + true, + ); + await setIdeModeAndSyncConnection( + context.services.agentContext!.config, + true, + ); + const { messageType, content } = getIdeStatusMessage(ideClient); + context.ui.addItem( + { + type: messageType, + text: content, + }, + Date.now(), + ); + }, + }; + + const disableCommand: SlashCommand = { + name: 'disable', + description: 'Disable IDE integration', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext) => { + context.services.settings.setValue( + SettingScope.User, + 'ide.enabled', + false, + ); + await setIdeModeAndSyncConnection( + context.services.agentContext!.config, + false, + ); + const { messageType, content } = getIdeStatusMessage(ideClient); + context.ui.addItem( + { + type: messageType, + text: content, + }, + Date.now(), + ); + }, + }; + + const { status } = ideClient.getConnectionStatus(); + const isConnected = status === IDEConnectionStatus.Connected; + + if (isConnected) { + ideSlashCommand.subCommands = [statusCommand, disableCommand]; + } else { + ideSlashCommand.subCommands = [ + enableCommand, + statusCommand, + installCommand, + ]; + } + + return ideSlashCommand; +}; diff --git a/packages/cli/src/ui/commands/initCommand.test.ts b/packages/cli/src/ui/commands/initCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e4f24a1fe4479b1dc08a8e1eb675c69ddd56144 --- /dev/null +++ b/packages/cli/src/ui/commands/initCommand.test.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { initCommand } from './initCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { CommandContext } from './types.js'; +import type { SubmitPromptActionReturn } from '@google/gemini-cli-core'; + +// Mock the 'fs' module +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + writeFileSync: vi.fn(), + }; +}); + +describe('initCommand', () => { + let mockContext: CommandContext; + const targetDir = '/test/dir'; + const geminiMdPath = path.join(targetDir, 'GEMINI.md'); + + beforeEach(() => { + // Create a fresh mock context for each test + mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + getTargetDir: () => targetDir, + }, + }, + }, + }); + }); + + afterEach(() => { + // Clear all mocks after each test + vi.clearAllMocks(); + }); + + it('should inform the user if GEMINI.md already exists', async () => { + // Arrange: Simulate that the file exists + vi.mocked(fs.existsSync).mockReturnValue(true); + + // Act: Run the command's action + const result = await initCommand.action!(mockContext, ''); + + // Assert: Check for the correct informational message + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'A GEMINI.md file already exists in this directory. No changes were made.', + }); + // Assert: Ensure no file was written + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('should create GEMINI.md and submit a prompt if it does not exist', async () => { + // Arrange: Simulate that the file does not exist + vi.mocked(fs.existsSync).mockReturnValue(false); + + // Act: Run the command's action + const result = (await initCommand.action!( + mockContext, + '', + )) as SubmitPromptActionReturn; + + // Assert: Check that writeFileSync was called correctly + expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8'); + + // Assert: Check that an informational message was added to the UI + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: 'info', + text: 'Empty GEMINI.md created. Now analyzing the project to populate it.', + }, + expect.any(Number), + ); + + // Assert: Check that the correct prompt is submitted + expect(result.type).toBe('submit_prompt'); + expect(result.content).toContain( + 'You are an AI agent that brings the power of Gemini', + ); + }); + + it('should return an error if config is not available', async () => { + // Arrange: Create a context without config + const noConfigContext = createMockCommandContext(); + if (noConfigContext.services) { + noConfigContext.services.agentContext = null; + } + + // Act: Run the command's action + const result = await initCommand.action!(noConfigContext, ''); + + // Assert: Check for the correct error message + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4d8040622a52989e93439a90f46fbb974eaf4a1 --- /dev/null +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { + CommandContext, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { performInit } from '@google/gemini-cli-core'; + +export const initCommand: SlashCommand = { + name: 'init', + description: 'Analyzes the project and creates a tailored GEMINI.md file', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async ( + context: CommandContext, + _args: string, + ): Promise => { + if (!context.services.agentContext?.config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + const targetDir = context.services.agentContext.config.getTargetDir(); + const geminiMdPath = path.join(targetDir, 'GEMINI.md'); + + const result = performInit(fs.existsSync(geminiMdPath)); + + if (result.type === 'submit_prompt') { + // Create an empty GEMINI.md file + fs.writeFileSync(geminiMdPath, '', 'utf8'); + + context.ui.addItem( + { + type: 'info', + text: 'Empty GEMINI.md created. Now analyzing the project to populate it.', + }, + Date.now(), + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return result as SlashCommandActionReturn; + }, +}; diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d082c4ed09c985dde82d942d6a0517e820851ecb --- /dev/null +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -0,0 +1,320 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mcpCommand } from './mcpCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { + MCPServerStatus, + MCPDiscoveryState, + getMCPServerStatus, + getMCPDiscoveryState, + DiscoveredMCPTool, + type MessageBus, +} from '@google/gemini-cli-core'; + +import type { CallableTool } from '@google/genai'; +import { MessageType, type HistoryItemMcpStatus } from '../types.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + const mockAuthenticate = vi.fn(); + return { + ...actual, + getMCPServerStatus: vi.fn(), + getMCPDiscoveryState: vi.fn(), + MCPOAuthProvider: vi.fn(() => ({ + authenticate: mockAuthenticate, + })), + MCPOAuthTokenStorage: vi.fn(() => ({ + getToken: vi.fn(), + isTokenExpired: vi.fn(), + })), + }; +}); + +const mockMessageBus = { + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), +} as unknown as MessageBus; + +// Helper function to create a mock DiscoveredMCPTool +const createMockMCPTool = ( + name: string, + serverName: string, + description?: string, +) => + new DiscoveredMCPTool( + { + callTool: vi.fn(), + tool: vi.fn(), + } as unknown as CallableTool, + serverName, + name, + description || 'Mock tool description', + { type: 'object', properties: {} }, + mockMessageBus, + undefined, // trust + undefined, // isReadOnly + undefined, // nameOverride + undefined, // cliConfig + undefined, // extensionName + undefined, // extensionId + ); + +describe('mcpCommand', () => { + let mockContext: ReturnType; + let mockConfig: { + getToolRegistry: ReturnType; + getMcpServers: ReturnType; + getBlockedMcpServers: ReturnType; + getPromptRegistry: ReturnType; + getGeminiClient: ReturnType; + getMcpClientManager: ReturnType; + getResourceRegistry: ReturnType; + setUserInteractedWithMcp: ReturnType; + getLastMcpError: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Set up default mock environment + vi.unstubAllEnvs(); + + // Default mock implementations + vi.mocked(getMCPServerStatus).mockReturnValue(MCPServerStatus.CONNECTED); + vi.mocked(getMCPDiscoveryState).mockReturnValue( + MCPDiscoveryState.COMPLETED, + ); + + // Create mock config with all necessary methods + mockConfig = { + getToolRegistry: vi.fn().mockReturnValue({ + getAllTools: vi.fn().mockReturnValue([]), + }), + getMcpServers: vi.fn().mockReturnValue({}), + getBlockedMcpServers: vi.fn().mockReturnValue([]), + getPromptRegistry: vi.fn().mockReturnValue({ + getAllPrompts: vi.fn().mockReturnValue([]), + getPromptsByServer: vi.fn().mockReturnValue([]), + }), + getGeminiClient: vi.fn(), + getMcpClientManager: vi.fn().mockImplementation(() => ({ + getBlockedMcpServers: vi.fn().mockReturnValue([]), + getMcpServers: vi.fn().mockReturnValue({}), + getLastError: vi.fn().mockReturnValue(undefined), + })), + getResourceRegistry: vi.fn().mockReturnValue({ + getAllResources: vi.fn().mockReturnValue([]), + }), + setUserInteractedWithMcp: vi.fn(), + getLastMcpError: vi.fn().mockReturnValue(undefined), + }; + + mockContext = createMockCommandContext({ + services: { + agentContext: { + config: mockConfig, + toolRegistry: mockConfig.getToolRegistry(), + }, + }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('basic functionality', () => { + it('should show an error if config is not available', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + agentContext: null, + }, + }); + + const result = await mcpCommand.action!(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should show an error if tool registry is not available', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockContext.services.agentContext as any).toolRegistry = undefined; + + const result = await mcpCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not retrieve tool registry.', + }); + }); + }); + + describe('with configured MCP servers', () => { + beforeEach(() => { + const mockMcpServers = { + server1: { command: 'cmd1' }, + server2: { command: 'cmd2' }, + server3: { command: 'cmd3' }, + }; + + mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); + mockConfig.getMcpClientManager = vi.fn().mockReturnValue({ + getMcpServers: vi.fn().mockReturnValue(mockMcpServers), + getBlockedMcpServers: vi.fn().mockReturnValue([]), + getLastError: vi.fn().mockReturnValue(undefined), + }); + }); + + it('should display configured MCP servers with status indicators and their tools', async () => { + // Setup getMCPServerStatus mock implementation + vi.mocked(getMCPServerStatus).mockImplementation((serverName) => { + if (serverName === 'server1') return MCPServerStatus.CONNECTED; + if (serverName === 'server2') return MCPServerStatus.CONNECTED; + return MCPServerStatus.DISCONNECTED; // server3 + }); + + // Mock tools from each server using actual DiscoveredMCPTool instances + const mockServer1Tools = [ + createMockMCPTool('server1_tool1', 'server1'), + createMockMCPTool('server1_tool2', 'server1'), + ]; + const mockServer2Tools = [createMockMCPTool('server2_tool1', 'server2')]; + const mockServer3Tools = [createMockMCPTool('server3_tool1', 'server3')]; + + const allTools = [ + ...mockServer1Tools, + ...mockServer2Tools, + ...mockServer3Tools, + ]; + + const mockToolRegistry = { + getAllTools: vi.fn().mockReturnValue(allTools), + }; + mockConfig.getToolRegistry = vi.fn().mockReturnValue(mockToolRegistry); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockContext.services.agentContext as any).toolRegistry = + mockToolRegistry; + + const resourcesByServer: Record< + string, + Array<{ name: string; uri: string }> + > = { + server1: [ + { + name: 'Server1 Resource', + uri: 'file:///server1/resource1.txt', + }, + ], + server2: [], + server3: [], + }; + mockConfig.getResourceRegistry = vi.fn().mockReturnValue({ + getAllResources: vi.fn().mockReturnValue( + Object.entries(resourcesByServer).flatMap(([serverName, resources]) => + resources.map((entry) => ({ + serverName, + ...entry, + })), + ), + ), + }); + + await mcpCommand.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.MCP_STATUS, + tools: allTools.map((tool) => ({ + serverName: tool.serverName, + name: tool.name, + description: tool.description, + schema: tool.schema, + })), + resources: expect.arrayContaining([ + expect.objectContaining({ + serverName: 'server1', + uri: 'file:///server1/resource1.txt', + }), + ]), + }), + ); + }); + + it('should display tool descriptions when desc argument is used', async () => { + const descSubCommand = mcpCommand.subCommands!.find( + (c) => c.name === 'desc', + ); + await descSubCommand!.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.MCP_STATUS, + showDescriptions: true, + }), + ); + }); + + it('should not display descriptions when nodesc argument is used', async () => { + const listSubCommand = mcpCommand.subCommands!.find( + (c) => c.name === 'list', + ); + await listSubCommand!.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.MCP_STATUS, + showDescriptions: false, + }), + ); + }); + + it('should filter servers by name when an argument is provided to list', async () => { + await mcpCommand.action!(mockContext, 'list server1'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.MCP_STATUS, + servers: expect.objectContaining({ + server1: expect.any(Object), + }), + }), + ); + + // Should NOT contain server2 or server3 + const call = vi.mocked(mockContext.ui.addItem).mock + .calls[0][0] as HistoryItemMcpStatus; + expect(Object.keys(call.servers)).toEqual(['server1']); + }); + + it('should filter servers by name and show descriptions when an argument is provided to desc', async () => { + await mcpCommand.action!(mockContext, 'desc server2'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.MCP_STATUS, + showDescriptions: true, + servers: expect.objectContaining({ + server2: expect.any(Object), + }), + }), + ); + + const call = vi.mocked(mockContext.ui.addItem).mock + .calls[0][0] as HistoryItemMcpStatus; + expect(Object.keys(call.servers)).toEqual(['server2']); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcc7fa9e61b745abc111081558035c29aa5e511c --- /dev/null +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -0,0 +1,565 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + SlashCommand, + SlashCommandActionReturn, + CommandContext, +} from './types.js'; +import { CommandKind } from './types.js'; +import type { MessageActionReturn } from '@google/gemini-cli-core'; +import { + DiscoveredMCPTool, + getMCPDiscoveryState, + getMCPServerStatus, + MCPDiscoveryState, + MCPServerStatus, + getErrorMessage, + MCPOAuthTokenStorage, + mcpServerRequiresOAuth, + CoreEvent, + coreEvents, +} from '@google/gemini-cli-core'; + +import { MessageType, type HistoryItemMcpStatus } from '../types.js'; +import { + McpServerEnablementManager, + normalizeServerId, + canLoadServer, +} from '../../config/mcp/mcpServerEnablement.js'; +import { loadSettings } from '../../config/settings.js'; +import { parseSlashCommand } from '../../utils/commands.js'; + +const authCommand: SlashCommand = { + name: 'auth', + description: 'Authenticate with an OAuth-enabled MCP server', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const serverName = args.trim(); + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + config.setUserInteractedWithMcp(); + + const mcpServers = config.getMcpClientManager()?.getMcpServers() ?? {}; + + if (!serverName) { + // List servers that support OAuth from two sources: + // 1. Servers with oauth.enabled in config + // 2. Servers detected as requiring OAuth (returned 401) + const configuredOAuthServers = Object.entries(mcpServers) + .filter(([_, server]) => server.oauth?.enabled) + .map(([name, _]) => name); + + const detectedOAuthServers = Array.from( + mcpServerRequiresOAuth.keys(), + ).filter((name) => mcpServers[name]); // Only include configured servers + + // Combine and deduplicate + const allOAuthServers = [ + ...new Set([...configuredOAuthServers, ...detectedOAuthServers]), + ]; + + if (allOAuthServers.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'No MCP servers configured with OAuth authentication.', + }; + } + + return { + type: 'message', + messageType: 'info', + content: `MCP servers with OAuth authentication:\n${allOAuthServers.map((s) => ` - ${s}`).join('\n')}\n\nUse /mcp auth to authenticate.`, + }; + } + + const server = mcpServers[serverName]; + if (!server) { + return { + type: 'message', + messageType: 'error', + content: `MCP server '${serverName}' not found.`, + }; + } + + // Always attempt OAuth authentication, even if not explicitly configured + // The authentication process will discover OAuth requirements automatically + + const displayListener = (message: string) => { + context.ui.addItem({ type: 'info', text: message }); + }; + + coreEvents.on(CoreEvent.OauthDisplayMessage, displayListener); + try { + context.ui.addItem({ + type: 'info', + text: `Starting OAuth authentication for MCP server '${serverName}'...`, + }); + + // Import dynamically to avoid circular dependencies + const { MCPOAuthProvider } = await import('@google/gemini-cli-core'); + + let oauthConfig = server.oauth; + if (!oauthConfig) { + oauthConfig = { enabled: false }; + } + + const mcpServerUrl = server.httpUrl || server.url; + const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); + await authProvider.authenticate(serverName, oauthConfig, mcpServerUrl); + + context.ui.addItem({ + type: 'info', + text: `✅ Successfully authenticated with MCP server '${serverName}'!`, + }); + + // Trigger tool re-discovery to pick up authenticated server + const mcpClientManager = config.getMcpClientManager(); + if (mcpClientManager) { + context.ui.addItem({ + type: 'info', + text: `Restarting MCP server '${serverName}'...`, + }); + await mcpClientManager.restartServer(serverName); + } + // Update the client with the new tools + const geminiClient = context.services.agentContext?.geminiClient; + if (geminiClient?.isInitialized()) { + await geminiClient.setTools(); + } + + // Reload the slash commands to reflect the changes. + context.ui.reloadCommands(); + + return { + type: 'message', + messageType: 'info', + content: `Successfully authenticated and reloaded tools for '${serverName}'`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`, + }; + } finally { + coreEvents.removeListener(CoreEvent.OauthDisplayMessage, displayListener); + } + }, + completion: async (context: CommandContext, partialArg: string) => { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) return []; + + const mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; + return Object.keys(mcpServers).filter((name) => + name.startsWith(partialArg), + ); + }, +}; + +const listAction = async ( + context: CommandContext, + showDescriptions = false, + showSchema = false, + serverNameFilter?: string, +): Promise => { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + config.setUserInteractedWithMcp(); + + const toolRegistry = agentContext.toolRegistry; + if (!toolRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Could not retrieve tool registry.', + }; + } + + let mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; + const blockedMcpServers = + config.getMcpClientManager()?.getBlockedMcpServers() || []; + + if (serverNameFilter) { + const filter = serverNameFilter.trim().toLowerCase(); + if (filter) { + mcpServers = Object.fromEntries( + Object.entries(mcpServers).filter( + ([name]) => + name.toLowerCase().includes(filter) || + normalizeServerId(name).includes(filter), + ), + ); + } + } + + const serverNames = Object.keys(mcpServers); + + const connectingServers = serverNames.filter( + (name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING, + ); + const discoveryState = getMCPDiscoveryState(); + const discoveryInProgress = + discoveryState === MCPDiscoveryState.IN_PROGRESS || + connectingServers.length > 0; + + const allTools = toolRegistry.getAllTools(); + const mcpTools = allTools.filter((tool) => tool instanceof DiscoveredMCPTool); + + const promptRegistry = config.getPromptRegistry(); + const mcpPrompts = promptRegistry + .getAllPrompts() + .filter( + (prompt) => + 'serverName' in prompt && serverNames.includes(prompt.serverName), + ); + + const resourceRegistry = config.getResourceRegistry(); + const mcpResources = resourceRegistry + .getAllResources() + .filter((entry) => serverNames.includes(entry.serverName)); + + const authStatus: HistoryItemMcpStatus['authStatus'] = {}; + const tokenStorage = new MCPOAuthTokenStorage(); + for (const serverName of serverNames) { + const server = mcpServers[serverName]; + // Check auth status for servers with oauth.enabled OR detected as requiring OAuth + if (server.oauth?.enabled || mcpServerRequiresOAuth.has(serverName)) { + const creds = await tokenStorage.getCredentials(serverName); + if (creds) { + if (creds.token.expiresAt && creds.token.expiresAt < Date.now()) { + authStatus[serverName] = 'expired'; + } else { + authStatus[serverName] = 'authenticated'; + } + } else { + authStatus[serverName] = 'unauthenticated'; + } + } else { + authStatus[serverName] = 'not-configured'; + } + } + + // Get enablement state for all servers + const enablementManager = McpServerEnablementManager.getInstance(); + const enablementState: HistoryItemMcpStatus['enablementState'] = {}; + for (const serverName of serverNames) { + enablementState[serverName] = + await enablementManager.getDisplayState(serverName); + } + const errors: Record = {}; + for (const serverName of serverNames) { + const error = config.getMcpClientManager()?.getLastError(serverName); + if (error) { + errors[serverName] = error; + } + } + + const mcpStatusItem: HistoryItemMcpStatus = { + type: MessageType.MCP_STATUS, + servers: mcpServers, + tools: mcpTools.map((tool) => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + serverName: (tool as unknown as { serverName: string }).serverName, + name: tool.name, + description: tool.description, + schema: tool.schema, + })), + prompts: mcpPrompts.map((prompt) => ({ + serverName: prompt.serverName, + name: prompt.name, + description: prompt.description, + })), + resources: mcpResources.map((resource) => ({ + serverName: resource.serverName, + name: resource.name, + uri: resource.uri, + mimeType: resource.mimeType, + description: resource.description, + })), + authStatus, + enablementState, + errors, + blockedServers: blockedMcpServers.map((s) => ({ + name: s.name, + extensionName: s.extensionName, + })), + discoveryInProgress, + connectingServers, + showDescriptions: Boolean(showDescriptions), + showSchema: Boolean(showSchema), + }; + + context.ui.addItem(mcpStatusItem); +}; +const listCommand: SlashCommand = { + name: 'list', + altNames: ['ls', 'nodesc', 'nodescription'], + description: 'List configured MCP servers and tools', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (context, args) => listAction(context, false, false, args), +}; + +const descCommand: SlashCommand = { + name: 'desc', + altNames: ['description'], + description: 'List configured MCP servers and tools with descriptions', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (context, args) => listAction(context, true, false, args), +}; + +const schemaCommand: SlashCommand = { + name: 'schema', + description: + 'List configured MCP servers and tools with descriptions and schemas', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (context, args) => listAction(context, true, true, args), +}; + +const reloadCommand: SlashCommand = { + name: 'reload', + altNames: ['refresh'], + description: 'Reloads MCP servers', + kind: CommandKind.BUILT_IN, + autoExecute: true, + takesArgs: false, + action: async ( + context: CommandContext, + ): Promise => { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const mcpClientManager = config.getMcpClientManager(); + if (!mcpClientManager) { + return { + type: 'message', + messageType: 'error', + content: 'Could not retrieve mcp client manager.', + }; + } + + context.ui.addItem({ + type: 'info', + text: 'Reloading MCP servers...', + }); + + await mcpClientManager.restart(); + + // Update the client with the new tools + const geminiClient = agentContext.geminiClient; + if (geminiClient?.isInitialized()) { + await geminiClient.setTools(); + } + + // Reload the slash commands to reflect the changes. + context.ui.reloadCommands(); + + return listCommand.action!(context, ''); + }, +}; + +async function handleEnableDisable( + context: CommandContext, + args: string, + enable: boolean, +): Promise { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + config.setUserInteractedWithMcp(); + + const parts = args.trim().split(/\s+/); + const isSession = parts.includes('--session'); + const serverName = parts.filter((p) => p !== '--session')[0]; + const action = enable ? 'enable' : 'disable'; + + if (!serverName) { + return { + type: 'message', + messageType: 'error', + content: `Server name required. Usage: /mcp ${action} [--session]`, + }; + } + + const name = normalizeServerId(serverName); + + // Validate server exists + const servers = config.getMcpClientManager()?.getMcpServers() || {}; + const normalizedServerNames = Object.keys(servers).map(normalizeServerId); + if (!normalizedServerNames.includes(name)) { + return { + type: 'message', + messageType: 'error', + content: `Server '${serverName}' not found. Use /mcp list to see available servers.`, + }; + } + + const manager = McpServerEnablementManager.getInstance(); + + if (enable) { + const settings = loadSettings(); + const result = await canLoadServer(name, { + adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true, + allowedList: settings.merged.mcp?.allowed, + excludedList: settings.merged.mcp?.excluded, + }); + if ( + !result.allowed && + (result.blockType === 'allowlist' || result.blockType === 'excludelist') + ) { + return { + type: 'message', + messageType: 'error', + content: result.reason ?? 'Blocked by settings.', + }; + } + if (isSession) { + manager.clearSessionDisable(name); + } else { + await manager.enable(name); + } + if (result.blockType === 'admin') { + context.ui.addItem( + { + type: 'warning', + text: 'MCP disabled by admin. Will load when enabled.', + }, + Date.now(), + ); + } + } else { + if (isSession) { + manager.disableForSession(name); + } else { + await manager.disable(name); + } + } + + const msg = `MCP server '${name}' ${enable ? 'enabled' : 'disabled'}${isSession ? ' for this session' : ''}.`; + + const mcpClientManager = config.getMcpClientManager(); + if (mcpClientManager) { + context.ui.addItem( + { type: 'info', text: 'Reloading MCP servers...' }, + Date.now(), + ); + await mcpClientManager.restart(); + } + if (agentContext.geminiClient?.isInitialized()) + await agentContext.geminiClient.setTools(); + context.ui.reloadCommands(); + + return { type: 'message', messageType: 'info', content: msg }; +} + +async function getEnablementCompletion( + context: CommandContext, + partialArg: string, + showEnabled: boolean, +): Promise { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) return []; + const servers = Object.keys( + config.getMcpClientManager()?.getMcpServers() || {}, + ); + const manager = McpServerEnablementManager.getInstance(); + const results: string[] = []; + for (const n of servers) { + const state = await manager.getDisplayState(n); + if (state.enabled === showEnabled && n.startsWith(partialArg)) { + results.push(n); + } + } + return results; +} + +const enableCommand: SlashCommand = { + name: 'enable', + description: 'Enable a disabled MCP server', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (ctx, args) => handleEnableDisable(ctx, args, true), + completion: (ctx, arg) => getEnablementCompletion(ctx, arg, false), +}; + +const disableCommand: SlashCommand = { + name: 'disable', + description: 'Disable an MCP server', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (ctx, args) => handleEnableDisable(ctx, args, false), + completion: (ctx, arg) => getEnablementCompletion(ctx, arg, true), +}; + +export const mcpCommand: SlashCommand = { + name: 'mcp', + description: 'Manage configured Model Context Protocol (MCP) servers', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [ + listCommand, + descCommand, + schemaCommand, + authCommand, + reloadCommand, + enableCommand, + disableCommand, + ], + action: async ( + context: CommandContext, + args: string, + ): Promise => { + if (args) { + const parsed = parseSlashCommand(`/${args}`, mcpCommand.subCommands!); + if (parsed.commandToExecute?.action) { + return parsed.commandToExecute.action(context, parsed.args); + } + // If no subcommand matches, treat the whole args as a filter for list + return listAction(context, false, false, args); + } + return listAction(context); + }, +}; diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1daee2f6d7f0f9c5a7775b597569c01aeccf5b1d --- /dev/null +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -0,0 +1,468 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; +import { memoryCommand } from './memoryCommand.js'; +import type { SlashCommand, CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import { + refreshMemory, + SimpleExtensionLoader, + type FileDiscoveryService, + showMemory, + listMemoryFiles, + flattenMemory, +} from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + getErrorMessage: vi.fn((error: unknown) => { + if (error instanceof Error) return error.message; + return String(error); + }), + refreshMemory: vi.fn(async (config) => { + await config.getMemoryContextManager()?.refresh(); + const memoryContent = original.flattenMemory(config.getUserMemory()); + const fileCount = config.getGeminiMdFileCount() || 0; + return { + type: 'message', + messageType: 'info', + content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`, + }; + }), + showMemory: vi.fn(), + listMemoryFiles: vi.fn(), + }; +}); + +const mockRefreshMemory = refreshMemory as Mock; + +describe('memoryCommand', () => { + let mockContext: CommandContext; + + const buildMemoryCommand = (): SlashCommand => memoryCommand(null); + + const getSubCommand = (name: 'show' | 'reload' | 'list'): SlashCommand => { + const subCommand = buildMemoryCommand().subCommands?.find( + (cmd) => cmd.name === name, + ); + if (!subCommand) { + throw new Error(`/memory ${name} command not found.`); + } + return subCommand; + }; + + describe('subcommands', () => { + it('does not include the legacy add subcommand', () => { + const command = buildMemoryCommand(); + const names = command.subCommands?.map((cmd) => cmd.name) ?? []; + expect(names).toEqual(['show', 'reload', 'list', 'inbox']); + }); + }); + + describe('/memory show', () => { + let showCommand: SlashCommand; + let mockGetUserMemory: Mock; + let mockGetGeminiMdFileCount: Mock; + + beforeEach(() => { + showCommand = getSubCommand('show'); + + mockGetUserMemory = vi.fn(); + mockGetGeminiMdFileCount = vi.fn(); + + vi.mocked(showMemory).mockImplementation((config) => { + const memoryContent = flattenMemory(config.getUserMemory()); + const fileCount = config.getGeminiMdFileCount() || 0; + let content; + if (memoryContent.length > 0) { + content = `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`; + } else { + content = 'Memory is currently empty.'; + } + return { + type: 'message', + messageType: 'info', + content, + }; + }); + + mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + getUserMemory: mockGetUserMemory, + getGeminiMdFileCount: mockGetGeminiMdFileCount, + getExtensionLoader: () => new SimpleExtensionLoader([]), + }, + }, + }, + }); + }); + + it('should display a message if memory is empty', async () => { + if (!showCommand.action) throw new Error('Command has no action'); + + mockGetUserMemory.mockReturnValue(''); + mockGetGeminiMdFileCount.mockReturnValue(0); + + await showCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Memory is currently empty.', + }, + expect.any(Number), + ); + }); + + it('should display the memory content and file count if it exists', async () => { + if (!showCommand.action) throw new Error('Command has no action'); + + const memoryContent = 'This is a test memory.'; + + mockGetUserMemory.mockReturnValue(memoryContent); + mockGetGeminiMdFileCount.mockReturnValue(1); + + await showCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Current memory content from 1 file(s):\n\n---\n${memoryContent}\n---`, + }, + expect.any(Number), + ); + }); + }); + + describe('/memory reload', () => { + let reloadCommand: SlashCommand; + let mockSetUserMemory: Mock; + let mockSetGeminiMdFileCount: Mock; + let mockSetGeminiMdFilePaths: Mock; + let mockContextManagerRefresh: Mock; + + beforeEach(() => { + reloadCommand = getSubCommand('reload'); + mockSetUserMemory = vi.fn(); + mockSetGeminiMdFileCount = vi.fn(); + mockSetGeminiMdFilePaths = vi.fn(); + mockContextManagerRefresh = vi.fn().mockResolvedValue(undefined); + + const mockConfig = { + setUserMemory: mockSetUserMemory, + setGeminiMdFileCount: mockSetGeminiMdFileCount, + setGeminiMdFilePaths: mockSetGeminiMdFilePaths, + getWorkingDir: () => '/test/dir', + getDebugMode: () => false, + getFileService: () => ({}) as FileDiscoveryService, + getExtensionLoader: () => new SimpleExtensionLoader([]), + getExtensions: () => [], + shouldLoadMemoryFromIncludeDirectories: () => false, + getWorkspaceContext: () => ({ + getDirectories: () => [], + }), + getFileFilteringOptions: () => ({ + ignore: [], + include: [], + }), + isTrustedFolder: () => false, + updateSystemInstructionIfInitialized: vi + .fn() + .mockResolvedValue(undefined), + getMemoryContextManager: vi.fn().mockReturnValue({ + refresh: mockContextManagerRefresh, + }), + getUserMemory: vi.fn().mockReturnValue(''), + getGeminiMdFileCount: vi.fn().mockReturnValue(0), + }; + + mockContext = createMockCommandContext({ + services: { + agentContext: { config: mockConfig }, + settings: { + merged: { + memoryDiscoveryMaxDirs: 1000, + context: { + importFormat: 'tree', + }, + }, + } as unknown as LoadedSettings, + }, + }); + mockRefreshMemory.mockClear(); + }); + + it('should use MemoryContextManager.refresh', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); + + const config = mockContext.services.agentContext?.config; + if (!config) throw new Error('Config is undefined'); + + vi.mocked(config.getUserMemory).mockReturnValue('JIT Memory Content'); + vi.mocked(config.getGeminiMdFileCount).mockReturnValue(3); + + await reloadCommand.action(mockContext, ''); + + expect(mockContextManagerRefresh).toHaveBeenCalledOnce(); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Memory reloaded successfully. Loaded 18 characters from 3 file(s).', + }, + expect.any(Number), + ); + }); + + it('should display success message when memory is reloaded with content', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); + + const successMessage = { + type: 'message', + messageType: MessageType.INFO, + content: + 'Memory reloaded successfully. Loaded 18 characters from 2 file(s).', + }; + mockRefreshMemory.mockResolvedValue(successMessage); + + await reloadCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Reloading memory from source files...', + }, + expect.any(Number), + ); + + expect(mockRefreshMemory).toHaveBeenCalledOnce(); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Memory reloaded successfully. Loaded 18 characters from 2 file(s).', + }, + expect.any(Number), + ); + }); + + it('should display success message when memory is reloaded with no content', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); + + const successMessage = { + type: 'message', + messageType: MessageType.INFO, + content: 'Memory reloaded successfully. No memory content found.', + }; + mockRefreshMemory.mockResolvedValue(successMessage); + + await reloadCommand.action(mockContext, ''); + + expect(mockRefreshMemory).toHaveBeenCalledOnce(); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Memory reloaded successfully. No memory content found.', + }, + expect.any(Number), + ); + }); + + it('should display an error message if reloading fails', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); + + const error = new Error('Failed to read memory files.'); + mockRefreshMemory.mockRejectedValue(error); + + await reloadCommand.action(mockContext, ''); + + expect(mockRefreshMemory).toHaveBeenCalledOnce(); + expect(mockSetUserMemory).not.toHaveBeenCalled(); + expect(mockSetGeminiMdFileCount).not.toHaveBeenCalled(); + expect(mockSetGeminiMdFilePaths).not.toHaveBeenCalled(); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: `Error reloading memory: ${error.message}`, + }, + expect.any(Number), + ); + }); + + it('should not throw if config service is unavailable', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); + + const nullConfigContext = createMockCommandContext({ + services: { agentContext: null }, + }); + + await expect( + reloadCommand.action(nullConfigContext, ''), + ).resolves.toBeUndefined(); + + expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Reloading memory from source files...', + }, + expect.any(Number), + ); + + expect(mockRefreshMemory).not.toHaveBeenCalled(); + }); + }); + + describe('/memory list', () => { + let listCommand: SlashCommand; + let mockGetGeminiMdfilePaths: Mock; + + beforeEach(() => { + listCommand = getSubCommand('list'); + mockGetGeminiMdfilePaths = vi.fn(); + vi.mocked(listMemoryFiles).mockImplementation((config) => { + const filePaths = config.getGeminiMdFilePaths() || []; + const fileCount = filePaths.length; + let content; + if (fileCount > 0) { + content = `There are ${fileCount} GEMINI.md file(s) in use:\n\n${filePaths.join('\n')}`; + } else { + content = 'No GEMINI.md files in use.'; + } + return { + type: 'message', + messageType: 'info', + content, + }; + }); + mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + getGeminiMdFilePaths: mockGetGeminiMdfilePaths, + }, + }, + }, + }); + }); + + it('should display a message if no GEMINI.md files are found', async () => { + if (!listCommand.action) throw new Error('Command has no action'); + + mockGetGeminiMdfilePaths.mockReturnValue([]); + + await listCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'No GEMINI.md files in use.', + }, + expect.any(Number), + ); + }); + + it('should display the file count and paths if they exist', async () => { + if (!listCommand.action) throw new Error('Command has no action'); + + const filePaths = ['/path/one/GEMINI.md', '/path/two/GEMINI.md']; + mockGetGeminiMdfilePaths.mockReturnValue(filePaths); + + await listCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `There are 2 GEMINI.md file(s) in use:\n\n${filePaths.join('\n')}`, + }, + expect.any(Number), + ); + }); + }); + + describe('/memory inbox', () => { + let inboxCommand: SlashCommand; + + beforeEach(() => { + inboxCommand = buildMemoryCommand().subCommands!.find( + (cmd) => cmd.name === 'inbox', + )!; + expect(inboxCommand).toBeDefined(); + }); + + it('should return custom_dialog when config is available and flag is enabled', () => { + if (!inboxCommand.action) throw new Error('Command has no action'); + + const mockConfig = { + reloadSkills: vi.fn(), + isAutoMemoryEnabled: vi.fn().mockReturnValue(true), + }; + const context = createMockCommandContext({ + services: { + agentContext: { config: mockConfig }, + }, + ui: { + removeComponent: vi.fn(), + reloadCommands: vi.fn(), + }, + }); + + const result = inboxCommand.action(context, ''); + + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); + }); + + it('should return info message when auto memory is disabled', () => { + if (!inboxCommand.action) throw new Error('Command has no action'); + + const mockConfig = { + isAutoMemoryEnabled: vi.fn().mockReturnValue(false), + }; + const context = createMockCommandContext({ + services: { + agentContext: { config: mockConfig }, + }, + }); + + const result = inboxCommand.action(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'The memory inbox requires Auto Memory. Enable it with: experimental.autoMemory = true in settings.', + }); + }); + + it('should return error when config is not loaded', () => { + if (!inboxCommand.action) throw new Error('Command has no action'); + + const context = createMockCommandContext({ + services: { + agentContext: null, + }, + }); + + const result = inboxCommand.action(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ca74681a67c37b26db7d82b5a7958747af36465 --- /dev/null +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + type Config, + listMemoryFiles, + refreshMemory, + showMemory, +} from '@google/gemini-cli-core'; +import { MessageType } from '../types.js'; +import { + CommandKind, + type OpenCustomDialogActionReturn, + type SlashCommand, + type SlashCommandActionReturn, +} from './types.js'; +import { InboxDialog } from '../components/InboxDialog.js'; + +const showSubCommand: SlashCommand = { + name: 'show', + description: 'Show the current memory contents', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context) => { + const config = context.services.agentContext?.config; + if (!config) return; + const result = showMemory(config); + + context.ui.addItem( + { + type: MessageType.INFO, + text: result.content, + }, + Date.now(), + ); + }, +}; + +const reloadSubCommand: SlashCommand = { + name: 'reload', + altNames: ['refresh'], + description: 'Reload the memory from the source', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context) => { + context.ui.addItem( + { + type: MessageType.INFO, + text: 'Reloading memory from source files...', + }, + Date.now(), + ); + + try { + const config = context.services.agentContext?.config; + if (config) { + const result = await refreshMemory(config); + + context.ui.addItem( + { + type: MessageType.INFO, + text: result.content, + }, + Date.now(), + ); + } + } catch (error) { + context.ui.addItem( + { + type: MessageType.ERROR, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + text: `Error reloading memory: ${(error as Error).message}`, + }, + Date.now(), + ); + } + }, +}; + +const listSubCommand: SlashCommand = { + name: 'list', + description: 'Lists the paths of the GEMINI.md files in use', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context) => { + const config = context.services.agentContext?.config; + if (!config) return; + const result = listMemoryFiles(config); + + context.ui.addItem( + { + type: MessageType.INFO, + text: result.content, + }, + Date.now(), + ); + }, +}; + +const inboxSubCommand: SlashCommand = { + name: 'inbox', + description: + 'Review skills extracted from past sessions and move them to global or project skills', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: ( + context, + ): OpenCustomDialogActionReturn | SlashCommandActionReturn | void => { + const config = context.services.agentContext?.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + if (!config.isAutoMemoryEnabled()) { + return { + type: 'message', + messageType: 'info', + content: + 'The memory inbox requires Auto Memory. Enable it with: experimental.autoMemory = true in settings.', + }; + } + + return { + type: 'custom_dialog', + component: React.createElement(InboxDialog, { + config, + onClose: () => context.ui.removeComponent(), + onReloadSkills: async () => { + await config.reloadSkills(); + context.ui.reloadCommands(); + }, + onReloadMemory: async () => { + await refreshMemory(config); + }, + }), + }; + }, +}; + +export const memoryCommand = (_config: Config | null): SlashCommand => { + const subCommands: SlashCommand[] = [ + showSubCommand, + reloadSubCommand, + listSubCommand, + inboxSubCommand, + ]; + + return { + name: 'memory', + description: 'Commands for interacting with memory', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands, + }; +}; diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa2359d8faa785ec8736e1bc00b80bbb8313a65f --- /dev/null +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { modelCommand } from './modelCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { Config } from '@google/gemini-cli-core'; +import { MessageType } from '../types.js'; + +describe('modelCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should return a dialog action to open the model dialog when no args', async () => { + if (!modelCommand.action) { + throw new Error('The model command must have an action.'); + } + + const result = await modelCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'model', + }); + }); + + it('should call refreshUserQuota if config is available when opening dialog', async () => { + if (!modelCommand.action) { + throw new Error('The model command must have an action.'); + } + + const mockRefreshUserQuota = vi.fn(); + mockContext.services.agentContext = { + refreshUserQuota: mockRefreshUserQuota, + get config() { + return this; + }, + } as unknown as Config; + + await modelCommand.action(mockContext, ''); + + expect(mockRefreshUserQuota).toHaveBeenCalled(); + }); + + describe('manage subcommand', () => { + it('should return a dialog action to open the model dialog', async () => { + const manageCommand = modelCommand.subCommands?.find( + (c) => c.name === 'manage', + ); + expect(manageCommand).toBeDefined(); + + const result = await manageCommand!.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'model', + }); + }); + + it('should call refreshUserQuota if config is available', async () => { + const manageCommand = modelCommand.subCommands?.find( + (c) => c.name === 'manage', + ); + const mockRefreshUserQuota = vi.fn(); + mockContext.services.agentContext = { + refreshUserQuota: mockRefreshUserQuota, + get config() { + return this; + }, + } as unknown as Config; + + await manageCommand!.action!(mockContext, ''); + + expect(mockRefreshUserQuota).toHaveBeenCalled(); + }); + }); + + describe('set subcommand', () => { + it('should set the model and log the command', async () => { + const setCommand = modelCommand.subCommands?.find( + (c) => c.name === 'set', + ); + expect(setCommand).toBeDefined(); + + const mockSetModel = vi.fn(); + mockContext.services.agentContext = { + setModel: mockSetModel, + getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), + getUserId: vi.fn().mockReturnValue('test-user'), + getUsageStatisticsEnabled: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('test-session'), + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: 'test-auth' }), + isInteractive: vi.fn().mockReturnValue(true), + getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }), + getPolicyEngine: vi.fn().mockReturnValue({ + getApprovalMode: vi.fn().mockReturnValue('auto'), + }), + get config() { + return this; + }, + } as unknown as Config; + + await setCommand!.action!(mockContext, 'gemini-pro'); + + expect(mockSetModel).toHaveBeenCalledWith('gemini-pro', true); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('Model set to gemini-pro'), + }), + ); + }); + + it('should set the model with persistence when --persist is used', async () => { + const setCommand = modelCommand.subCommands?.find( + (c) => c.name === 'set', + ); + const mockSetModel = vi.fn(); + mockContext.services.agentContext = { + setModel: mockSetModel, + getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), + getUserId: vi.fn().mockReturnValue('test-user'), + getUsageStatisticsEnabled: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('test-session'), + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: 'test-auth' }), + isInteractive: vi.fn().mockReturnValue(true), + getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }), + getPolicyEngine: vi.fn().mockReturnValue({ + getApprovalMode: vi.fn().mockReturnValue('auto'), + }), + get config() { + return this; + }, + } as unknown as Config; + + await setCommand!.action!(mockContext, 'gemini-pro --persist'); + + expect(mockSetModel).toHaveBeenCalledWith('gemini-pro', false); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('Model set to gemini-pro (persisted)'), + }), + ); + }); + + it('should show error if no model name is provided', async () => { + const setCommand = modelCommand.subCommands?.find( + (c) => c.name === 'set', + ); + await setCommand!.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: expect.stringContaining('Usage: /model set '), + }), + ); + }); + }); + + it('should have the correct name and description', () => { + expect(modelCommand.name).toBe('model'); + expect(modelCommand.description).toBe('Manage model configuration'); + }); +}); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..facaba81ba852635bc197085e230c26893cb1702 --- /dev/null +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ModelSlashCommandEvent, + logModelSlashCommand, +} from '@google/gemini-cli-core'; +import { + type CommandContext, + CommandKind, + type SlashCommand, +} from './types.js'; +import { MessageType } from '../types.js'; + +const setModelCommand: SlashCommand = { + name: 'set', + description: + 'Set the model to use. Usage: /model set [--persist]', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: async (context: CommandContext, args: string) => { + const parts = args.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Usage: /model set [--persist]', + }); + return; + } + + const modelName = parts[0]; + const persist = parts.includes('--persist'); + + if (context.services.agentContext?.config) { + context.services.agentContext.config.setModel(modelName, !persist); + const event = new ModelSlashCommandEvent(modelName); + logModelSlashCommand(context.services.agentContext.config, event); + + context.ui.addItem({ + type: MessageType.INFO, + text: `Model set to ${modelName}${persist ? ' (persisted)' : ''}`, + }); + } + }, +}; + +const manageModelCommand: SlashCommand = { + name: 'manage', + description: 'Opens a dialog to configure the model', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext) => { + if (context.services.agentContext?.config) { + await context.services.agentContext.config.refreshUserQuota(); + } + return { + type: 'dialog', + dialog: 'model', + }; + }, +}; + +export const modelCommand: SlashCommand = { + name: 'model', + description: 'Manage model configuration', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [manageModelCommand, setModelCommand], + action: async (context: CommandContext, args: string) => + manageModelCommand.action!(context, args), +}; diff --git a/packages/cli/src/ui/commands/oncallCommand.tsx b/packages/cli/src/ui/commands/oncallCommand.tsx new file mode 100644 index 0000000000000000000000000000000000000000..23236ea49ce0a9dddc95f9740568bcad0d3d7def --- /dev/null +++ b/packages/cli/src/ui/commands/oncallCommand.tsx @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type SlashCommand, + type OpenCustomDialogActionReturn, +} from './types.js'; +import { TriageDuplicates } from '../components/triage/TriageDuplicates.js'; +import { TriageIssues } from '../components/triage/TriageIssues.js'; + +export const oncallCommand: SlashCommand = { + name: 'oncall', + description: 'Oncall related commands', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [ + { + name: 'dedup', + description: 'Triage issues labeled as status/possible-duplicate', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context, args): Promise => { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) { + throw new Error('Config not available'); + } + + let limit = 50; + if (args && args.trim().length > 0) { + const argArray = args.trim().split(/\s+/); + const parsedLimit = parseInt(argArray[0], 10); + if (!isNaN(parsedLimit) && parsedLimit > 0) { + limit = parsedLimit; + } + } + + return { + type: 'custom_dialog', + component: ( + context.ui.removeComponent()} + /> + ), + }; + }, + }, + { + name: 'audit', + description: 'Triage issues labeled as status/need-triage', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context, args): Promise => { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) { + throw new Error('Config not available'); + } + + let limit = 100; + let until: string | undefined; + + if (args && args.trim().length > 0) { + const argArray = args.trim().split(/\s+/); + for (let i = 0; i < argArray.length; i++) { + const arg = argArray[i]; + if (arg === '--until') { + if (i + 1 >= argArray.length) { + throw new Error('Flag --until requires a value (YYYY-MM-DD).'); + } + const val = argArray[i + 1]; + if (!/^\d{4}-\d{2}-\d{2}$/.test(val)) { + throw new Error( + `Invalid date format for --until: "${val}". Expected YYYY-MM-DD.`, + ); + } + until = val; + i++; + } else if (arg.startsWith('--')) { + throw new Error(`Unknown flag: ${arg}`); + } else { + const parsedLimit = parseInt(arg, 10); + if (!isNaN(parsedLimit) && parsedLimit > 0) { + limit = parsedLimit; + } else { + throw new Error( + `Invalid argument: "${arg}". Expected a positive number or --until flag.`, + ); + } + } + } + } + + return { + type: 'custom_dialog', + component: ( + context.ui.removeComponent()} + /> + ), + }; + }, + }, + ], +}; diff --git a/packages/cli/src/ui/commands/permissionsCommand.test.ts b/packages/cli/src/ui/commands/permissionsCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..48b08888b99eb1c627c97526379376ae7618da1a --- /dev/null +++ b/packages/cli/src/ui/commands/permissionsCommand.test.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import * as process from 'node:process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { permissionsCommand } from './permissionsCommand.js'; +import { type CommandContext, CommandKind } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +vi.mock('node:fs'); + +describe('permissionsCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + vi.mocked(fs).statSync.mockReturnValue({ + isDirectory: vi.fn(() => true), + } as unknown as fs.Stats); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should have the correct name and description', () => { + expect(permissionsCommand.name).toBe('permissions'); + expect(permissionsCommand.description).toBe( + 'Manage folder trust settings and other permissions', + ); + }); + + it('should be a built-in command', () => { + expect(permissionsCommand.kind).toBe(CommandKind.BUILT_IN); + }); + + it('should have a trust subcommand', () => { + const trustCommand = permissionsCommand.subCommands?.find( + (cmd) => cmd.name === 'trust', + ); + expect(trustCommand).toBeDefined(); + expect(trustCommand?.name).toBe('trust'); + expect(trustCommand?.description).toBe( + 'Manage folder trust settings. Usage: /permissions trust []', + ); + expect(trustCommand?.kind).toBe(CommandKind.BUILT_IN); + }); + + it('should return an action to open the permissions dialog with a specified directory', () => { + const trustCommand = permissionsCommand.subCommands?.find( + (cmd) => cmd.name === 'trust', + ); + const actionResult = trustCommand?.action?.(mockContext, '/test/dir'); + expect(actionResult).toEqual({ + type: 'dialog', + dialog: 'permissions', + props: { + targetDirectory: path.resolve('/test/dir'), + }, + }); + }); + + it('should return an action to open the permissions dialog with the current directory if no path is provided', () => { + const trustCommand = permissionsCommand.subCommands?.find( + (cmd) => cmd.name === 'trust', + ); + const actionResult = trustCommand?.action?.(mockContext, ''); + expect(actionResult).toEqual({ + type: 'dialog', + dialog: 'permissions', + props: { + targetDirectory: process.cwd(), + }, + }); + }); + + it('should return an error message if the provided path does not exist', () => { + const trustCommand = permissionsCommand.subCommands?.find( + (cmd) => cmd.name === 'trust', + ); + vi.mocked(fs).statSync.mockImplementation(() => { + throw new Error('ENOENT: no such file or directory'); + }); + const actionResult = trustCommand?.action?.( + mockContext, + '/nonexistent/dir', + ); + expect(actionResult).toEqual({ + type: 'message', + messageType: 'error', + content: `Error accessing path: ${path.resolve( + '/nonexistent/dir', + )}. ENOENT: no such file or directory`, + }); + }); + + it('should return an error message if the provided path is not a directory', () => { + const trustCommand = permissionsCommand.subCommands?.find( + (cmd) => cmd.name === 'trust', + ); + vi.mocked(fs).statSync.mockReturnValue({ + isDirectory: vi.fn(() => false), + } as unknown as fs.Stats); + const actionResult = trustCommand?.action?.(mockContext, '/file/not/dir'); + expect(actionResult).toEqual({ + type: 'message', + messageType: 'error', + content: `Path is not a directory: ${path.resolve('/file/not/dir')}`, + }); + }); +}); diff --git a/packages/cli/src/ui/commands/permissionsCommand.ts b/packages/cli/src/ui/commands/permissionsCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..7aecbcf2a2a36592852027e052298db4f9e7a6b1 --- /dev/null +++ b/packages/cli/src/ui/commands/permissionsCommand.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + OpenDialogActionReturn, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import * as process from 'node:process'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { expandHomeDir } from '../utils/directoryUtils.js'; + +export const permissionsCommand: SlashCommand = { + name: 'permissions', + description: 'Manage folder trust settings and other permissions', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [ + { + name: 'trust', + description: + 'Manage folder trust settings. Usage: /permissions trust []', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: (context, input): SlashCommandActionReturn => { + const dirPath = input.trim(); + let targetDirectory: string; + + if (!dirPath) { + targetDirectory = process.cwd(); + } else { + targetDirectory = path.resolve(expandHomeDir(dirPath)); + } + + try { + if (!fs.statSync(targetDirectory).isDirectory()) { + return { + type: 'message', + messageType: 'error', + content: `Path is not a directory: ${targetDirectory}`, + }; + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { + type: 'message', + messageType: 'error', + content: `Error accessing path: ${targetDirectory}. ${message}`, + }; + } + + return { + type: 'dialog', + dialog: 'permissions', + props: { + targetDirectory, + }, + } as OpenDialogActionReturn; + }, + }, + ], + action: (context, input): SlashCommandActionReturn => { + const parts = input.trim().split(' '); + const subcommand = parts[0]; + + if (!subcommand) { + return { + type: 'message', + messageType: 'error', + content: `Please provide a subcommand for /permissions. Usage: /permissions trust []`, + }; + } + + return { + type: 'message', + messageType: 'error', + content: `Invalid subcommand for /permissions: ${subcommand}. Usage: /permissions trust []`, + }; + }, +}; diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..56b694975080672a36d068a46c8e4d6f5bb694ac --- /dev/null +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -0,0 +1,215 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { planCommand } from './planCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; +import { + ApprovalMode, + coreEvents, + processSingleFileContent, + type ProcessedFileReadResult, + readFileWithEncoding, +} from '@google/gemini-cli-core'; +import { copyToClipboard } from '../utils/commandUtils.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: { + emitFeedback: vi.fn(), + }, + processSingleFileContent: vi.fn(), + readFileWithEncoding: vi.fn(), + partToString: vi.fn((val) => val), + }; +}); + +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual }, + join: vi.fn((...args) => args.join('/')), + basename: vi.fn((p) => p.split('/').pop()), + }; +}); + +vi.mock('../utils/commandUtils.js', () => ({ + copyToClipboard: vi.fn(), +})); + +describe('planCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + isPlanEnabled: vi.fn(), + setApprovalMode: vi.fn(), + getApprovedPlanPath: vi.fn(), + getApprovalMode: vi.fn(), + getFileSystemService: vi.fn(), + storage: { + getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + }, + }, + }, + }, + ui: { + addItem: vi.fn(), + }, + } as unknown as CommandContext); + + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should have the correct name and description', () => { + expect(planCommand.name).toBe('plan'); + expect(planCommand.description).toBe( + 'Switch to Plan Mode and view current plan', + ); + }); + + it('should switch to plan mode if enabled', async () => { + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + vi.mocked( + mockContext.services.agentContext!.config.getApprovedPlanPath, + ).mockReturnValue(undefined); + + if (!planCommand.action) throw new Error('Action missing'); + await planCommand.action(mockContext, ''); + + expect( + mockContext.services.agentContext!.config.setApprovalMode, + ).toHaveBeenCalledWith(ApprovalMode.PLAN); + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + 'Switched to Plan Mode.', + ); + }); + + it('should not return a submit_prompt action if arguments are empty', async () => { + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + mockContext.invocation = { + raw: '/plan', + name: 'plan', + args: '', + }; + + if (!planCommand.action) throw new Error('Action missing'); + const result = await planCommand.action(mockContext, ''); + + expect(result).toBeUndefined(); + expect( + mockContext.services.agentContext!.config.setApprovalMode, + ).toHaveBeenCalledWith(ApprovalMode.PLAN); + }); + + it('should return a submit_prompt action if arguments are provided', async () => { + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + mockContext.invocation = { + raw: '/plan implement auth', + name: 'plan', + args: 'implement auth', + }; + + if (!planCommand.action) throw new Error('Action missing'); + const result = await planCommand.action(mockContext, 'implement auth'); + + expect(result).toEqual({ + type: 'submit_prompt', + content: 'implement auth', + }); + expect( + mockContext.services.agentContext!.config.setApprovalMode, + ).toHaveBeenCalledWith(ApprovalMode.PLAN); + }); + + it('should display the approved plan from config', async () => { + const mockPlanPath = '/mock/plans/dir/approved-plan.md'; + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + vi.mocked( + mockContext.services.agentContext!.config.getApprovedPlanPath, + ).mockReturnValue(mockPlanPath); + vi.mocked(processSingleFileContent).mockResolvedValue({ + llmContent: '# Approved Plan Content', + returnDisplay: '# Approved Plan Content', + } as ProcessedFileReadResult); + + if (!planCommand.action) throw new Error('Action missing'); + await planCommand.action(mockContext, ''); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + 'Approved Plan: approved-plan.md', + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.GEMINI, + text: '# Approved Plan Content', + }); + }); + + describe('copy subcommand', () => { + it('should copy the approved plan to clipboard', async () => { + const mockPlanPath = '/mock/plans/dir/approved-plan.md'; + vi.mocked( + mockContext.services.agentContext!.config.getApprovedPlanPath, + ).mockReturnValue(mockPlanPath); + vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content'); + + const copySubCommand = planCommand.subCommands?.find( + (sc) => sc.name === 'copy', + ); + if (!copySubCommand?.action) throw new Error('Copy action missing'); + + await copySubCommand.action(mockContext, ''); + + expect(readFileWithEncoding).toHaveBeenCalledWith(mockPlanPath); + expect(copyToClipboard).toHaveBeenCalledWith('# Plan Content'); + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + 'Plan copied to clipboard (approved-plan.md).', + ); + }); + + it('should warn if no approved plan is found', async () => { + vi.mocked( + mockContext.services.agentContext!.config.getApprovedPlanPath, + ).mockReturnValue(undefined); + + const copySubCommand = planCommand.subCommands?.find( + (sc) => sc.name === 'copy', + ); + if (!copySubCommand?.action) throw new Error('Copy action missing'); + + await copySubCommand.action(mockContext, ''); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'warning', + 'No approved plan found to copy.', + ); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..b90c74323ce7fecfbaf7587f7848add5f9ef2dcf --- /dev/null +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type CommandContext, + CommandKind, + type SlashCommand, +} from './types.js'; +import { + ApprovalMode, + coreEvents, + debugLogger, + processSingleFileContent, + partToString, + readFileWithEncoding, +} from '@google/gemini-cli-core'; +import { MessageType } from '../types.js'; +import * as path from 'node:path'; +import { copyToClipboard } from '../utils/commandUtils.js'; + +async function copyAction(context: CommandContext) { + const config = context.services.agentContext?.config; + if (!config) { + debugLogger.debug('Plan copy command: config is not available in context'); + return; + } + + const planPath = config.getApprovedPlanPath(); + + if (!planPath) { + coreEvents.emitFeedback('warning', 'No approved plan found to copy.'); + return; + } + + try { + const content = await readFileWithEncoding(planPath); + await copyToClipboard(content); + coreEvents.emitFeedback( + 'info', + `Plan copied to clipboard (${path.basename(planPath)}).`, + ); + } catch (error) { + coreEvents.emitFeedback('error', `Failed to copy plan: ${error}`, error); + } +} + +export const planCommand: SlashCommand = { + name: 'plan', + description: 'Switch to Plan Mode and view current plan', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: async (context) => { + const config = context.services.agentContext?.config; + if (!config) { + debugLogger.debug('Plan command: config is not available in context'); + return; + } + + const previousApprovalMode = config.getApprovalMode(); + config.setApprovalMode(ApprovalMode.PLAN); + + if (previousApprovalMode !== ApprovalMode.PLAN) { + coreEvents.emitFeedback('info', 'Switched to Plan Mode.'); + } + + if (context.invocation?.args) { + return { + type: 'submit_prompt', + content: context.invocation.args, + }; + } + + const approvedPlanPath = config.getApprovedPlanPath(); + + if (!approvedPlanPath) { + return; + } + + try { + const content = await processSingleFileContent( + approvedPlanPath, + config.storage.getPlansDir(), + config.getFileSystemService(), + ); + const fileName = path.basename(approvedPlanPath); + + coreEvents.emitFeedback('info', `Approved Plan: ${fileName}`); + + context.ui.addItem({ + type: MessageType.GEMINI, + text: partToString(content.llmContent), + }); + return; + } catch (error) { + coreEvents.emitFeedback( + 'error', + `Failed to read approved plan at ${approvedPlanPath}: ${error}`, + error, + ); + return; + } + }, + subCommands: [ + { + name: 'copy', + description: 'Copy the currently approved plan to your clipboard', + kind: CommandKind.BUILT_IN, + autoExecute: true, + takesArgs: false, + action: copyAction, + }, + ], +}; diff --git a/packages/cli/src/ui/commands/policiesCommand.test.ts b/packages/cli/src/ui/commands/policiesCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..929b52829013d587782b1f0b1bd22f2bd19b7eb6 --- /dev/null +++ b/packages/cli/src/ui/commands/policiesCommand.test.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { policiesCommand } from './policiesCommand.js'; +import { CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { + type Config, + PolicyDecision, + ApprovalMode, +} from '@google/gemini-cli-core'; + +describe('policiesCommand', () => { + let mockContext: ReturnType; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should have correct command definition', () => { + expect(policiesCommand.name).toBe('policies'); + expect(policiesCommand.description).toBe('Manage policies'); + expect(policiesCommand.kind).toBe(CommandKind.BUILT_IN); + expect(policiesCommand.subCommands).toHaveLength(1); + expect(policiesCommand.subCommands![0].name).toBe('list'); + }); + + describe('list subcommand', () => { + it('should show error if config is missing', async () => { + mockContext.services.agentContext = null; + const listCommand = policiesCommand.subCommands![0]; + + await listCommand.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Error: Config not available.', + }), + expect.any(Number), + ); + }); + + it('should show message when no policies are active', async () => { + const mockPolicyEngine = { + getRules: vi.fn().mockReturnValue([]), + }; + mockContext.services.agentContext = { + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + get config() { + return this; + }, + } as unknown as Config; + + const listCommand = policiesCommand.subCommands![0]; + await listCommand.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'No active policies.', + }), + expect.any(Number), + ); + }); + + it('should list policies grouped by mode', async () => { + const mockRules = [ + { + decision: PolicyDecision.DENY, + toolName: 'dangerousTool', + priority: 10, + }, + { + decision: PolicyDecision.ALLOW, + argsPattern: /safe/, + source: 'test.toml', + }, + { + decision: PolicyDecision.ASK_USER, + }, + ]; + const mockPolicyEngine = { + getRules: vi.fn().mockReturnValue(mockRules), + }; + mockContext.services.agentContext = { + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + get config() { + return this; + }, + } as unknown as Config; + + const listCommand = policiesCommand.subCommands![0]; + await listCommand.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('**Active Policies**'), + }), + expect.any(Number), + ); + + const call = vi.mocked(mockContext.ui.addItem).mock.calls[0]; + const content = (call[0] as { text: string }).text; + + expect(content).toContain('### Normal Mode Policies'); + expect(content).toContain( + '### Auto Edit Mode Policies (combined with normal mode policies)', + ); + expect(content).toContain( + '### Yolo Mode Policies (combined with normal mode policies)', + ); + expect(content).toContain( + '### Plan Mode Policies (combined with normal mode policies)', + ); + expect(content).toContain( + '**DENY** tool: `dangerousTool` [Priority: 10]', + ); + expect(content).toContain( + '**ALLOW** all tools (args match: `safe`) [Source: test.toml]', + ); + expect(content).toContain('**ASK_USER** all tools'); + }); + + it('should show plan-only rules in plan mode section', async () => { + const mockRules = [ + { + decision: PolicyDecision.ALLOW, + toolName: 'glob', + priority: 70, + modes: [ApprovalMode.PLAN], + }, + { + decision: PolicyDecision.DENY, + priority: 60, + modes: [ApprovalMode.PLAN], + }, + { + decision: PolicyDecision.ALLOW, + toolName: 'shell', + priority: 50, + }, + ]; + const mockPolicyEngine = { + getRules: vi.fn().mockReturnValue(mockRules), + }; + mockContext.services.agentContext = { + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + get config() { + return this; + }, + } as unknown as Config; + + const listCommand = policiesCommand.subCommands![0]; + await listCommand.action!(mockContext, ''); + + const call = vi.mocked(mockContext.ui.addItem).mock.calls[0]; + const content = (call[0] as { text: string }).text; + + // Plan-only rules appear under Plan Mode section + expect(content).toContain( + '### Plan Mode Policies (combined with normal mode policies)', + ); + // glob ALLOW is plan-only, should appear in plan section + expect(content).toContain('**ALLOW** tool: `glob` [Priority: 70]'); + // shell ALLOW has no modes (applies to all), appears in normal section + expect(content).toContain('**ALLOW** tool: `shell` [Priority: 50]'); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/policiesCommand.ts b/packages/cli/src/ui/commands/policiesCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6f3b1e1e189bbaf3975e37d1bbe1bda6985a28e --- /dev/null +++ b/packages/cli/src/ui/commands/policiesCommand.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApprovalMode, type PolicyRule } from '@google/gemini-cli-core'; +import { CommandKind, type SlashCommand } from './types.js'; +import { MessageType } from '../types.js'; + +interface CategorizedRules { + normal: PolicyRule[]; + autoEdit: PolicyRule[]; + yolo: PolicyRule[]; + plan: PolicyRule[]; +} + +const categorizeRulesByMode = ( + rules: readonly PolicyRule[], +): CategorizedRules => { + const result: CategorizedRules = { + normal: [], + autoEdit: [], + yolo: [], + plan: [], + }; + const ALL_MODES = Object.values(ApprovalMode); + rules.forEach((rule) => { + const modes = rule.modes?.length ? rule.modes : ALL_MODES; + const modeSet = new Set(modes); + if (modeSet.has(ApprovalMode.DEFAULT)) result.normal.push(rule); + if (modeSet.has(ApprovalMode.AUTO_EDIT)) result.autoEdit.push(rule); + if (modeSet.has(ApprovalMode.YOLO)) result.yolo.push(rule); + if (modeSet.has(ApprovalMode.PLAN)) result.plan.push(rule); + }); + return result; +}; + +const formatRule = (rule: PolicyRule, i: number) => + `${i + 1}. **${rule.decision.toUpperCase()}** ${rule.toolName ? `tool: \`${rule.toolName}\`` : 'all tools'}` + + (rule.argsPattern ? ` (args match: \`${rule.argsPattern.source}\`)` : '') + + (rule.priority !== undefined ? ` [Priority: ${rule.priority}]` : '') + + (rule.source ? ` [Source: ${rule.source}]` : ''); + +const formatSection = (title: string, rules: PolicyRule[]) => + `### ${title}\n${rules.length ? rules.map(formatRule).join('\n') : '_No policies._'}\n\n`; + +const listPoliciesCommand: SlashCommand = { + name: 'list', + description: 'List all active policies grouped by mode', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context) => { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: 'Error: Config not available.', + }, + Date.now(), + ); + return; + } + + const policyEngine = config.getPolicyEngine(); + const rules = policyEngine.getRules(); + + if (rules.length === 0) { + context.ui.addItem( + { + type: MessageType.INFO, + text: 'No active policies.', + }, + Date.now(), + ); + return; + } + + const categorized = categorizeRulesByMode(rules); + const normalRulesSet = new Set(categorized.normal); + const uniqueAutoEdit = categorized.autoEdit.filter( + (rule) => !normalRulesSet.has(rule), + ); + const uniqueYolo = categorized.yolo.filter( + (rule) => !normalRulesSet.has(rule), + ); + const uniquePlan = categorized.plan.filter( + (rule) => !normalRulesSet.has(rule), + ); + + let content = '**Active Policies**\n\n'; + content += formatSection('Normal Mode Policies', categorized.normal); + content += formatSection( + 'Auto Edit Mode Policies (combined with normal mode policies)', + uniqueAutoEdit, + ); + content += formatSection( + 'Yolo Mode Policies (combined with normal mode policies)', + uniqueYolo, + ); + content += formatSection( + 'Plan Mode Policies (combined with normal mode policies)', + uniquePlan, + ); + + context.ui.addItem( + { + type: MessageType.INFO, + text: content, + }, + Date.now(), + ); + }, +}; + +export const policiesCommand: SlashCommand = { + name: 'policies', + description: 'Manage policies', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [listPoliciesCommand], +}; diff --git a/packages/cli/src/ui/commands/privacyCommand.test.ts b/packages/cli/src/ui/commands/privacyCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..841ab88fd28b7ccba8cba55edc7ed77c3be11624 --- /dev/null +++ b/packages/cli/src/ui/commands/privacyCommand.test.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { privacyCommand } from './privacyCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('privacyCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should return a dialog action to open the privacy dialog', () => { + // Ensure the command has an action to test. + if (!privacyCommand.action) { + throw new Error('The privacy command must have an action.'); + } + + const result = privacyCommand.action(mockContext, ''); + + // Assert that the action returns the correct object to trigger the privacy dialog. + expect(result).toEqual({ + type: 'dialog', + dialog: 'privacy', + }); + }); + + it('should have the correct name and description', () => { + expect(privacyCommand.name).toBe('privacy'); + expect(privacyCommand.description).toBe('Display the privacy notice'); + }); +}); diff --git a/packages/cli/src/ui/commands/privacyCommand.ts b/packages/cli/src/ui/commands/privacyCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb56b84109c6fac208c22a0741507aa57b49d487 --- /dev/null +++ b/packages/cli/src/ui/commands/privacyCommand.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type OpenDialogActionReturn, + type SlashCommand, +} from './types.js'; + +export const privacyCommand: SlashCommand = { + name: 'privacy', + description: 'Display the privacy notice', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (): OpenDialogActionReturn => ({ + type: 'dialog', + dialog: 'privacy', + }), +}; diff --git a/packages/cli/src/ui/commands/profileCommand.ts b/packages/cli/src/ui/commands/profileCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..22ef6f37ba5a10031c319c1e1f3b5ae99a333892 --- /dev/null +++ b/packages/cli/src/ui/commands/profileCommand.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isDevelopment } from '../../utils/installationInfo.js'; +import { CommandKind, type SlashCommand } from './types.js'; + +export const profileCommand: SlashCommand | null = isDevelopment + ? { + name: 'profile', + kind: CommandKind.BUILT_IN, + description: 'Toggle the debug profile display', + autoExecute: true, + action: async (context) => { + context.ui.toggleDebugProfiler(); + return { + type: 'message', + messageType: 'info', + content: 'Toggled profile display.', + }; + }, + } + : null; diff --git a/packages/cli/src/ui/commands/quitCommand.test.ts b/packages/cli/src/ui/commands/quitCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..24a4a682d6a44e5db7429d7d0014e063ca2fce57 --- /dev/null +++ b/packages/cli/src/ui/commands/quitCommand.test.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { quitCommand } from './quitCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { formatDuration } from '../utils/formatters.js'; + +vi.mock('../utils/formatters.js'); + +describe('quitCommand', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T01:00:00Z')); + vi.mocked(formatDuration).mockReturnValue('1h 0m 0s'); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('returns a QuitActionReturn object with the correct messages', () => { + const mockContext = createMockCommandContext({ + session: { + stats: { + sessionStartTime: new Date('2025-01-01T00:00:00Z'), + }, + }, + }); + + if (!quitCommand.action) throw new Error('Action is not defined'); + const result = quitCommand.action(mockContext, ''); + + expect(formatDuration).toHaveBeenCalledWith(3600000); // 1 hour in ms + expect(result).toEqual({ + type: 'quit', + deleteSession: false, + messages: [ + { + type: 'user', + text: '/quit', + id: expect.any(Number), + }, + { + type: 'quit', + duration: '1h 0m 0s', + id: expect.any(Number), + }, + ], + }); + }); + + it('sets deleteSession to true when --delete flag is provided', () => { + const mockContext = createMockCommandContext({ + session: { + stats: { + sessionStartTime: new Date('2025-01-01T00:00:00Z'), + }, + }, + }); + + if (!quitCommand.action) throw new Error('Action is not defined'); + const result = quitCommand.action(mockContext, '--delete'); + + expect(result).toEqual({ + type: 'quit', + deleteSession: true, + messages: [ + { + type: 'user', + text: '/quit', + id: expect.any(Number), + }, + { + type: 'quit', + duration: '1h 0m 0s', + id: expect.any(Number), + }, + ], + }); + }); + + it('does not set deleteSession for unrecognized args', () => { + const mockContext = createMockCommandContext({ + session: { + stats: { + sessionStartTime: new Date('2025-01-01T00:00:00Z'), + }, + }, + }); + + if (!quitCommand.action) throw new Error('Action is not defined'); + const result = quitCommand.action(mockContext, 'some-random-arg'); + + expect(result).toEqual( + expect.objectContaining({ + type: 'quit', + deleteSession: false, + }), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebd17e2d2143e16cc5fc7939ed7b5111b1c7cda0 --- /dev/null +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { formatDuration } from '../utils/formatters.js'; +import { CommandKind, type SlashCommand } from './types.js'; + +export const quitCommand: SlashCommand = { + name: 'quit', + altNames: ['exit'], + description: 'Exit the cli', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (context, args) => { + const now = Date.now(); + const { sessionStartTime } = context.session.stats; + const wallDuration = now - sessionStartTime.getTime(); + + const deleteSession = args.trim() === '--delete'; + + return { + type: 'quit', + deleteSession, + messages: [ + { + type: 'user', + text: `/quit`, // Keep it consistent, even if /exit was used + id: now - 1, + }, + { + type: 'quit', + duration: formatDuration(wallDuration), + id: now, + }, + ], + }; + }, +}; diff --git a/packages/cli/src/ui/commands/restoreCommand.test.ts b/packages/cli/src/ui/commands/restoreCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2f29ca5b9c8213d4c14574ce9cdab0042fade7d --- /dev/null +++ b/packages/cli/src/ui/commands/restoreCommand.test.ts @@ -0,0 +1,262 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { restoreCommand } from './restoreCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { + GEMINI_DIR, + type Config, + type GitService, +} from '@google/gemini-cli-core'; + +describe('restoreCommand', () => { + let mockContext: CommandContext; + let mockConfig: Config; + let mockGitService: GitService; + let mockSetHistory: ReturnType; + let testRootDir: string; + let geminiTempDir: string; + let checkpointsDir: string; + + beforeEach(async () => { + testRootDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'restore-command-test-'), + ); + geminiTempDir = path.join(testRootDir, GEMINI_DIR); + checkpointsDir = path.join(geminiTempDir, 'checkpoints'); + // The command itself creates this, but for tests it's easier to have it ready. + // Some tests might remove it to test error paths. + await fs.mkdir(checkpointsDir, { recursive: true }); + + mockSetHistory = vi.fn().mockResolvedValue(undefined); + mockGitService = { + restoreProjectFromSnapshot: vi.fn().mockResolvedValue(undefined), + } as unknown as GitService; + + mockConfig = { + getCheckpointingEnabled: vi.fn().mockReturnValue(true), + storage: { + getProjectTempCheckpointsDir: vi.fn().mockReturnValue(checkpointsDir), + getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir), + }, + geminiClient: { + setHistory: mockSetHistory, + }, + get config() { + return this; + }, + } as unknown as Config; + + mockContext = createMockCommandContext({ + services: { + agentContext: mockConfig, + git: mockGitService, + }, + }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(testRootDir, { recursive: true, force: true }); + }); + + it('should return null if checkpointing is not enabled', () => { + vi.mocked(mockConfig.getCheckpointingEnabled).mockReturnValue(false); + + expect(restoreCommand(mockConfig)).toBeNull(); + }); + + it('should return the command if checkpointing is enabled', () => { + expect(restoreCommand(mockConfig)).toEqual( + expect.objectContaining({ + name: 'restore', + description: expect.any(String), + action: expect.any(Function), + completion: expect.any(Function), + }), + ); + }); + + describe('action', () => { + it('should return an error if temp dir is not found', async () => { + vi.mocked( + mockConfig.storage.getProjectTempCheckpointsDir, + ).mockReturnValue(''); + + expect( + await restoreCommand(mockConfig)?.action?.(mockContext, ''), + ).toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not determine the .gemini directory path.', + }); + }); + + it('should inform when no checkpoints are found if no args are passed', async () => { + // Remove the directory to ensure the command creates it. + await fs.rm(checkpointsDir, { recursive: true, force: true }); + const command = restoreCommand(mockConfig); + + expect(await command?.action?.(mockContext, '')).toEqual({ + type: 'message', + messageType: 'info', + content: 'No restorable tool calls found.', + }); + // Verify the directory was created by the command. + await expect(fs.stat(checkpointsDir)).resolves.toBeDefined(); + }); + + it('should list available checkpoints if no args are passed', async () => { + await fs.writeFile(path.join(checkpointsDir, 'test1.json'), '{}'); + await fs.writeFile(path.join(checkpointsDir, 'test2.json'), '{}'); + const command = restoreCommand(mockConfig); + + expect(await command?.action?.(mockContext, '')).toEqual({ + type: 'message', + messageType: 'info', + content: 'Available tool calls to restore:\n\ntest1\ntest2', + }); + }); + + it('should return an error if the specified file is not found', async () => { + await fs.writeFile(path.join(checkpointsDir, 'test1.json'), '{}'); + const command = restoreCommand(mockConfig); + + expect(await command?.action?.(mockContext, 'test2')).toEqual({ + type: 'message', + messageType: 'error', + content: 'File not found: test2.json', + }); + }); + + it('should handle file read errors gracefully', async () => { + const checkpointName = 'test1'; + const checkpointPath = path.join( + checkpointsDir, + `${checkpointName}.json`, + ); + // Create a directory instead of a file to cause a read error. + await fs.mkdir(checkpointPath); + const command = restoreCommand(mockConfig); + + expect(await command?.action?.(mockContext, checkpointName)).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining( + 'Could not read restorable tool calls.', + ), + }); + }); + + it('should restore a tool call and project state', async () => { + const toolCallData = { + history: [{ type: 'user', text: 'do a thing', id: 123 }], + clientHistory: [{ role: 'user', parts: [{ text: 'do a thing' }] }], + commitHash: 'abcdef123', + toolCall: { name: 'run_shell_command', args: { command: 'ls' } }, + }; + await fs.writeFile( + path.join(checkpointsDir, 'my-checkpoint.json'), + JSON.stringify(toolCallData), + ); + const command = restoreCommand(mockConfig); + + expect(await command?.action?.(mockContext, 'my-checkpoint')).toEqual({ + type: 'tool', + toolName: 'run_shell_command', + toolArgs: { command: 'ls' }, + }); + expect(mockContext.ui.loadHistory).toHaveBeenCalledWith( + toolCallData.history, + ); + expect(mockSetHistory).toHaveBeenCalledWith(toolCallData.clientHistory); + expect(mockGitService.restoreProjectFromSnapshot).toHaveBeenCalledWith( + toolCallData.commitHash, + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: 'info', + text: 'Restored project to the state before the tool call.', + }, + expect.any(Number), + ); + }); + + it('should restore even if only toolCall is present', async () => { + const toolCallData = { + toolCall: { name: 'run_shell_command', args: { command: 'ls' } }, + }; + await fs.writeFile( + path.join(checkpointsDir, 'my-checkpoint.json'), + JSON.stringify(toolCallData), + ); + + const command = restoreCommand(mockConfig); + + expect(await command?.action?.(mockContext, 'my-checkpoint')).toEqual({ + type: 'tool', + toolName: 'run_shell_command', + toolArgs: { command: 'ls' }, + }); + + expect(mockContext.ui.loadHistory).not.toHaveBeenCalled(); + expect(mockSetHistory).not.toHaveBeenCalled(); + expect(mockGitService.restoreProjectFromSnapshot).not.toHaveBeenCalled(); + }); + }); + + it('should return an error for a checkpoint file missing the toolCall property', async () => { + const checkpointName = 'missing-toolcall'; + await fs.writeFile( + path.join(checkpointsDir, `${checkpointName}.json`), + JSON.stringify({ history: [] }), // An object that is valid JSON but missing the 'toolCall' property + ); + const command = restoreCommand(mockConfig); + + expect(await command?.action?.(mockContext, checkpointName)).toEqual({ + type: 'message', + messageType: 'error', + // A more specific error message would be ideal, but for now, we can assert the current behavior. + content: expect.stringContaining('Checkpoint file is invalid'), + }); + }); + + describe('completion', () => { + it('should return an empty array if temp dir is not found', async () => { + vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(''); + const command = restoreCommand(mockConfig); + + expect(await command?.completion?.(mockContext, '')).toEqual([]); + }); + + it('should return an empty array on readdir error', async () => { + await fs.rm(checkpointsDir, { recursive: true, force: true }); + const command = restoreCommand(mockConfig); + + expect(await command?.completion?.(mockContext, '')).toEqual([]); + }); + + it('should return a list of checkpoint names', async () => { + await fs.writeFile(path.join(checkpointsDir, 'test1.json'), '{}'); + await fs.writeFile(path.join(checkpointsDir, 'test2.json'), '{}'); + await fs.writeFile( + path.join(checkpointsDir, 'not-a-checkpoint.txt'), + '{}', + ); + const command = restoreCommand(mockConfig); + + expect(await command?.completion?.(mockContext, '')).toEqual([ + 'test1', + 'test2', + ]); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..9120c0b1641b82fca129444569451209b895ce04 --- /dev/null +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; +import { + type Config, + formatCheckpointDisplayList, + getToolCallDataSchema, + getTruncatedCheckpointNames, + performRestore, + type ToolCallData, +} from '@google/gemini-cli-core'; +import { + type CommandContext, + type SlashCommand, + type SlashCommandActionReturn, + CommandKind, +} from './types.js'; +import type { HistoryItem } from '../types.js'; + +const HistoryItemSchema = z + .object({ + type: z.string(), + id: z.number(), + }) + .passthrough(); + +/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ +const ToolCallDataSchema = getToolCallDataSchema( + HistoryItemSchema as unknown as Parameters[0], +); +/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ + +async function restoreAction( + context: CommandContext, + args: string, +): Promise { + const { services, ui } = context; + const { agentContext, git: gitService } = services; + const { addItem, loadHistory } = ui; + + const checkpointDir = + agentContext?.config.storage.getProjectTempCheckpointsDir(); + + if (!checkpointDir) { + return { + type: 'message', + messageType: 'error', + content: 'Could not determine the .gemini directory path.', + }; + } + + try { + // Ensure the directory exists before trying to read it. + await fs.mkdir(checkpointDir, { recursive: true }); + const files = await fs.readdir(checkpointDir); + const jsonFiles = files.filter((file) => file.endsWith('.json')); + + if (!args) { + if (jsonFiles.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'No restorable tool calls found.', + }; + } + const fileList = formatCheckpointDisplayList(jsonFiles); + return { + type: 'message', + messageType: 'info', + content: `Available tool calls to restore:\n\n${fileList}`, + }; + } + + const selectedFile = args.endsWith('.json') ? args : `${args}.json`; + + if (!jsonFiles.includes(selectedFile)) { + return { + type: 'message', + messageType: 'error', + content: `File not found: ${selectedFile}`, + }; + } + + const filePath = path.join(checkpointDir, selectedFile); + const data = await fs.readFile(filePath, 'utf-8'); + const parseResult = ToolCallDataSchema.safeParse(JSON.parse(data)); + + if (!parseResult.success) { + return { + type: 'message', + messageType: 'error', + content: `Checkpoint file is invalid: ${parseResult.error.message}`, + }; + } + + // We safely cast here because: + // 1. ToolCallDataSchema strictly validates the existence of 'history' as an array and 'id'/'type' on each item. + // 2. We trust that files valid according to this schema (written by useGeminiStream) contain the full HistoryItem structure. + const toolCallData = parseResult.data as ToolCallData< + HistoryItem[], + Record + >; + + const actionStream = performRestore(toolCallData, gitService); + + for await (const action of actionStream) { + if (action.type === 'message') { + addItem( + { + type: action.messageType, + text: action.content, + }, + Date.now(), + ); + } else if (action.type === 'load_history' && loadHistory) { + loadHistory(action.history); + if (action.clientHistory) { + agentContext!.geminiClient?.setHistory(action.clientHistory); + } + } + } + + return { + type: 'tool', + toolName: toolCallData.toolCall.name, + toolArgs: toolCallData.toolCall.args, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Could not read restorable tool calls. This is the error: ${error}`, + }; + } +} + +async function completion( + context: CommandContext, + _partialArg: string, +): Promise { + const { services } = context; + const { agentContext } = services; + const checkpointDir = + agentContext?.config.storage.getProjectTempCheckpointsDir(); + if (!checkpointDir) { + return []; + } + try { + const files = await fs.readdir(checkpointDir); + const jsonFiles = files.filter((file) => file.endsWith('.json')); + return getTruncatedCheckpointNames(jsonFiles); + } catch { + return []; + } +} + +export const restoreCommand = (config: Config | null): SlashCommand | null => { + if (!config?.getCheckpointingEnabled()) { + return null; + } + + return { + name: 'restore', + description: + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: restoreAction, + completion, + }; +}; diff --git a/packages/cli/src/ui/commands/resumeCommand.test.ts b/packages/cli/src/ui/commands/resumeCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..89097e68333509f0ab43e14530a22de2063d17c3 --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.test.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { resumeCommand } from './resumeCommand.js'; +import type { CommandContext } from './types.js'; + +describe('resumeCommand', () => { + it('should open the session browser for bare /resume', async () => { + const result = await resumeCommand.action?.({} as CommandContext, ''); + expect(result).toEqual({ + type: 'dialog', + dialog: 'sessionBrowser', + }); + }); + + it('should expose unified chat subcommands directly under /resume', () => { + const visibleSubCommandNames = (resumeCommand.subCommands ?? []) + .filter((subCommand) => !subCommand.hidden) + .map((subCommand) => subCommand.name); + + expect(visibleSubCommandNames).toEqual( + expect.arrayContaining(['list', 'save', 'resume', 'delete', 'share']), + ); + }); + + it('should keep a hidden /resume checkpoints compatibility alias', () => { + const checkpoints = resumeCommand.subCommands?.find( + (subCommand) => subCommand.name === 'checkpoints', + ); + expect(checkpoints?.hidden).toBe(true); + expect( + checkpoints?.subCommands?.map((subCommand) => subCommand.name), + ).toEqual( + expect.arrayContaining(['list', 'save', 'resume', 'delete', 'share']), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbb35a898c55c8ccd69991adfb15e6da796aca61 --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + OpenDialogActionReturn, + CommandContext, + SlashCommand, +} from './types.js'; +import { CommandKind } from './types.js'; +import { chatResumeSubCommands } from './chatCommand.js'; + +export const resumeCommand: SlashCommand = { + name: 'resume', + description: 'Browse auto-saved conversations and manage chat checkpoints', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async ( + _context: CommandContext, + _args: string, + ): Promise => ({ + type: 'dialog', + dialog: 'sessionBrowser', + }), + subCommands: chatResumeSubCommands, +}; diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aa5e6cfa6fe846bb4ccecfea063ee10935c67bfc --- /dev/null +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -0,0 +1,364 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { rewindCommand } from './rewindCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { waitFor } from '../../test-utils/async.js'; +import { RewindOutcome } from '../components/RewindConfirmation.js'; +import { + type OpenCustomDialogActionReturn, + type CommandContext, +} from './types.js'; +import type { ReactElement } from 'react'; +import { coreEvents } from '@google/gemini-cli-core'; + +// Mock dependencies +const mockRewindTo = vi.fn(); +const mockRecordMessage = vi.fn(); +const mockSetHistory = vi.fn(); +const mockSendMessageStream = vi.fn(); +const mockGetChatRecordingService = vi.fn(); +const mockGetConversation = vi.fn(); +const mockRemoveComponent = vi.fn(); +const mockLoadHistory = vi.fn(); +const mockAddItem = vi.fn(); +const mockSetPendingItem = vi.fn(); +const mockResetContext = vi.fn(); +const mockSetInput = vi.fn(); +const mockRevertFileChanges = vi.fn(); +const mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root'); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread + ...actual.coreEvents, + emitFeedback: vi.fn(), + }, + logRewind: vi.fn(), + RewindEvent: class {}, + }; +}); + +vi.mock('../components/RewindViewer.js', () => ({ + RewindViewer: () => null, +})); + +vi.mock('../hooks/useSessionBrowser.js', () => ({ + convertSessionToHistoryFormats: vi.fn().mockReturnValue({ + uiHistory: [ + { type: 'user', text: 'old user' }, + { type: 'gemini', text: 'old gemini' }, + ], + clientHistory: [{ role: 'user', parts: [{ text: 'old user' }] }], + }), +})); + +vi.mock('../utils/rewindFileOps.js', () => ({ + revertFileChanges: (...args: unknown[]) => mockRevertFileChanges(...args), +})); + +interface RewindViewerProps { + onRewind: ( + messageId: string, + newText: string, + outcome: RewindOutcome, + ) => Promise; + conversation: unknown; + onExit: () => void; +} + +describe('rewindCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + + mockGetConversation.mockReturnValue({ + messages: [{ id: 'msg-1', type: 'user', content: 'hello' }], + sessionId: 'test-session', + }); + + mockRewindTo.mockReturnValue({ + messages: [], // Mocked rewound messages + }); + + mockGetChatRecordingService.mockReturnValue({ + getConversation: mockGetConversation, + rewindTo: mockRewindTo, + recordMessage: mockRecordMessage, + }); + + mockContext = createMockCommandContext({ + services: { + agentContext: { + geminiClient: { + getChatRecordingService: mockGetChatRecordingService, + setHistory: mockSetHistory, + sendMessageStream: mockSendMessageStream, + }, + config: { + getSessionId: () => 'test-session-id', + getMemoryContextManager: () => ({ refresh: mockResetContext }), + getProjectRoot: mockGetProjectRoot, + }, + }, + }, + ui: { + removeComponent: mockRemoveComponent, + loadHistory: mockLoadHistory, + addItem: mockAddItem, + setPendingItem: mockSetPendingItem, + }, + }) as unknown as CommandContext; + }); + + it('should initialize successfully', async () => { + const result = await rewindCommand.action!(mockContext, ''); + expect(result).toHaveProperty('type', 'custom_dialog'); + }); + + it('should handle RewindOnly correctly', async () => { + // 1. Run the command to get the component + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + + // Access onRewind from props + const onRewind = component.props.onRewind; + expect(onRewind).toBeDefined(); + + await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindOnly); + + await waitFor(() => { + expect(mockRevertFileChanges).not.toHaveBeenCalled(); + expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123'); + expect(mockSetHistory).toHaveBeenCalled(); + expect(mockResetContext).toHaveBeenCalled(); + expect(mockLoadHistory).toHaveBeenCalledWith( + [ + expect.objectContaining({ text: 'old user', id: 1 }), + expect.objectContaining({ text: 'old gemini', id: 2 }), + ], + 'New Prompt', + ); + expect(mockRemoveComponent).toHaveBeenCalled(); + }); + + // Verify setInput was NOT called directly (it's handled via loadHistory now) + expect(mockSetInput).not.toHaveBeenCalled(); + }); + + it('should handle RewindAndRevert correctly', async () => { + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + const onRewind = component.props.onRewind; + + await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindAndRevert); + + await waitFor(() => { + expect(mockRevertFileChanges).toHaveBeenCalledWith( + mockGetConversation(), + 'msg-id-123', + ); + expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123'); + expect(mockLoadHistory).toHaveBeenCalledWith( + expect.any(Array), + 'New Prompt', + ); + }); + expect(mockSetInput).not.toHaveBeenCalled(); + }); + + it('should handle RevertOnly correctly', async () => { + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + const onRewind = component.props.onRewind; + + await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RevertOnly); + + await waitFor(() => { + expect(mockRevertFileChanges).toHaveBeenCalledWith( + mockGetConversation(), + 'msg-id-123', + ); + expect(mockRewindTo).not.toHaveBeenCalled(); + expect(mockRemoveComponent).toHaveBeenCalled(); + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + 'File changes reverted.', + ); + }); + expect(mockSetInput).not.toHaveBeenCalled(); + }); + + it('should handle Cancel correctly', async () => { + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + const onRewind = component.props.onRewind; + + await onRewind('msg-id-123', 'New Prompt', RewindOutcome.Cancel); + + await waitFor(() => { + expect(mockRevertFileChanges).not.toHaveBeenCalled(); + expect(mockRewindTo).not.toHaveBeenCalled(); + expect(mockRemoveComponent).toHaveBeenCalled(); + }); + expect(mockSetInput).not.toHaveBeenCalled(); + }); + + it('should handle onExit correctly', async () => { + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + const onExit = component.props.onExit; + + onExit(); + + expect(mockRemoveComponent).toHaveBeenCalled(); + }); + + it('should handle rewind error correctly', async () => { + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + const onRewind = component.props.onRewind; + + mockRewindTo.mockImplementation(() => { + throw new Error('Rewind Failed'); + }); + + await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly); + + await waitFor(() => { + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'Rewind Failed', + ); + }); + }); + + it('should handle null conversation from rewindTo', async () => { + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + const onRewind = component.props.onRewind; + + mockRewindTo.mockReturnValue(null); + + await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly); + + await waitFor(() => { + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'Could not fetch conversation file', + ); + expect(mockRemoveComponent).toHaveBeenCalled(); + }); + }); + + it('should fail if config is missing', () => { + const context = { services: {} } as CommandContext; + + const result = rewindCommand.action!(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not found', + }); + }); + + it('should fail if client is not initialized', () => { + const context = createMockCommandContext({ + services: { + agentContext: { + geminiClient: undefined, + get config() { + return this; + }, + }, + }, + }) as unknown as CommandContext; + + const result = rewindCommand.action!(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Client not initialized', + }); + }); + + it('should fail if recording service is unavailable', () => { + const context = createMockCommandContext({ + services: { + agentContext: { + geminiClient: { getChatRecordingService: () => undefined }, + get config() { + return this; + }, + }, + }, + }) as unknown as CommandContext; + + const result = rewindCommand.action!(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Recording service unavailable', + }); + }); + + it('should return info if no conversation found', () => { + mockGetConversation.mockReturnValue(null); + + const result = rewindCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No conversation found.', + }); + }); + + it('should return info if no user interactions found', () => { + mockGetConversation.mockReturnValue({ + messages: [{ id: 'msg-1', type: 'gemini', content: 'hello' }], + sessionId: 'test-session', + }); + + const result = rewindCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Nothing to rewind to.', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a7b0feee4a54daa2cf02db8df9174fd1dd6c899c --- /dev/null +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -0,0 +1,198 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type CommandContext, + type SlashCommand, +} from './types.js'; +import { RewindViewer } from '../components/RewindViewer.js'; +import { type HistoryItem } from '../types.js'; +import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js'; +import { revertFileChanges } from '../utils/rewindFileOps.js'; +import { RewindOutcome } from '../components/RewindConfirmation.js'; +import { + checkExhaustive, + coreEvents, + debugLogger, + logRewind, + RewindEvent, + type ChatRecordingService, + type GeminiClient, + convertSessionToClientHistory, +} from '@google/gemini-cli-core'; + +/** + * Helper function to handle the core logic of rewinding a conversation. + * This function encapsulates the steps needed to rewind the conversation, + * update the client and UI history, and clear the component. + * + * @param context The command context. + * @param client Gemini client + * @param recordingService The chat recording service. + * @param messageId The ID of the message to rewind to. + * @param newText The new text for the input field after rewinding. + */ +async function rewindConversation( + context: CommandContext, + client: GeminiClient, + recordingService: ChatRecordingService, + messageId: string, + newText: string, +) { + try { + const conversation = recordingService.rewindTo(messageId); + if (!conversation) { + const errorMsg = 'Could not fetch conversation file'; + debugLogger.error(errorMsg); + context.ui.removeComponent(); + coreEvents.emitFeedback('error', errorMsg); + return; + } + + // Convert to UI and Client formats + const { uiHistory } = convertSessionToHistoryFormats(conversation.messages); + const clientHistory = convertSessionToClientHistory(conversation.messages); + + client.setHistory(clientHistory); + + // Reset context manager as we are rewinding history + await context.services.agentContext?.config + .getMemoryContextManager() + ?.refresh(); + + // Update UI History + // We generate IDs based on index for the rewind history + const startId = 1; + const historyWithIds = uiHistory.map( + (item, idx) => + ({ + ...item, + id: startId + idx, + }) as HistoryItem, + ); + + // 1. Remove component FIRST to avoid flicker and clear the stage + context.ui.removeComponent(); + + // 2. Load the rewound history and set the input + context.ui.loadHistory(historyWithIds, newText); + } catch (error) { + // If an error occurs, we still want to remove the component if possible + context.ui.removeComponent(); + coreEvents.emitFeedback( + 'error', + error instanceof Error ? error.message : 'Unknown error during rewind', + ); + } +} + +export const rewindCommand: SlashCommand = { + name: 'rewind', + description: 'Jump back to a specific message and restart the conversation', + kind: CommandKind.BUILT_IN, + action: (context) => { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) + return { + type: 'message', + messageType: 'error', + content: 'Config not found', + }; + + const client = agentContext.geminiClient; + if (!client) + return { + type: 'message', + messageType: 'error', + content: 'Client not initialized', + }; + + const recordingService = client.getChatRecordingService(); + if (!recordingService) + return { + type: 'message', + messageType: 'error', + content: 'Recording service unavailable', + }; + + const conversation = recordingService.getConversation(); + if (!conversation) + return { + type: 'message', + messageType: 'info', + content: 'No conversation found.', + }; + + const hasUserInteractions = conversation.messages.some( + (msg) => msg.type === 'user', + ); + if (!hasUserInteractions) { + return { + type: 'message', + messageType: 'info', + content: 'Nothing to rewind to.', + }; + } + + return { + type: 'custom_dialog', + component: ( + { + context.ui.removeComponent(); + }} + onRewind={async (messageId, newText, outcome) => { + if (outcome !== RewindOutcome.Cancel) { + logRewind(config, new RewindEvent(outcome)); + } + switch (outcome) { + case RewindOutcome.Cancel: + context.ui.removeComponent(); + return; + + case RewindOutcome.RevertOnly: + if (conversation) { + await revertFileChanges(conversation, messageId); + } + context.ui.removeComponent(); + coreEvents.emitFeedback('info', 'File changes reverted.'); + return; + + case RewindOutcome.RewindAndRevert: + if (conversation) { + await revertFileChanges(conversation, messageId); + } + await rewindConversation( + context, + client, + recordingService, + messageId, + newText, + ); + return; + + case RewindOutcome.RewindOnly: + await rewindConversation( + context, + client, + recordingService, + messageId, + newText, + ); + return; + + default: + checkExhaustive(outcome); + } + }} + /> + ), + }; + }, +}; diff --git a/packages/cli/src/ui/commands/settingsCommand.test.ts b/packages/cli/src/ui/commands/settingsCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..96d0d5114b0b9be5fb2404e933ada2c02ac35c7f --- /dev/null +++ b/packages/cli/src/ui/commands/settingsCommand.test.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { settingsCommand } from './settingsCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('settingsCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should return a dialog action to open the settings dialog', () => { + if (!settingsCommand.action) { + throw new Error('The settings command must have an action.'); + } + const result = settingsCommand.action(mockContext, ''); + expect(result).toEqual({ + type: 'dialog', + dialog: 'settings', + }); + }); + + it('should have the correct name and description', () => { + expect(settingsCommand.name).toBe('settings'); + expect(settingsCommand.description).toBe( + 'View and edit Gemini CLI settings', + ); + }); +}); diff --git a/packages/cli/src/ui/commands/settingsCommand.ts b/packages/cli/src/ui/commands/settingsCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe3ac3f3229613ee8087b13a1d18df8657c11d5d --- /dev/null +++ b/packages/cli/src/ui/commands/settingsCommand.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type OpenDialogActionReturn, + type SlashCommand, +} from './types.js'; + +export const settingsCommand: SlashCommand = { + name: 'settings', + description: 'View and edit Gemini CLI settings', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (_context, _args): OpenDialogActionReturn => ({ + type: 'dialog', + dialog: 'settings', + }), +}; diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a5b6a8ec1b888b3a85a2e84a44d24d7504841ed --- /dev/null +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -0,0 +1,340 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; + +import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest'; +import * as gitUtils from '../../utils/gitUtils.js'; +import { + setupGithubCommand, + updateGitignore, + GITHUB_WORKFLOW_PATHS, +} from './setupGithubCommand.js'; +import type { CommandContext } from './types.js'; +import * as commandUtils from '../utils/commandUtils.js'; +import { debugLogger, type ToolActionReturn } from '@google/gemini-cli-core'; + +vi.mock('child_process'); + +// Mock fetch globally +global.fetch = vi.fn(); + +vi.mock('../../utils/gitUtils.js', () => ({ + isGitHubRepository: vi.fn(), + getGitRepoRoot: vi.fn(), + getLatestGitHubRelease: vi.fn(), + getGitHubRepoInfo: vi.fn(), +})); + +vi.mock('../utils/commandUtils.js', () => ({ + getUrlOpenCommand: vi.fn(), +})); + +describe('setupGithubCommand', async () => { + let scratchDir = ''; + + beforeEach(async () => { + vi.resetAllMocks(); + scratchDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'setup-github-command-'), + ); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (scratchDir) await fs.rm(scratchDir, { recursive: true }); + }); + + it('downloads workflows, updates gitignore, and includes pipefail on non-windows', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + const fakeRepoOwner = 'fake'; + const fakeRepoName = 'repo'; + const fakeRepoRoot = scratchDir; + const fakeReleaseVersion = 'v1.2.3'; + + const workflows = GITHUB_WORKFLOW_PATHS.map((p) => path.basename(p)); + + vi.mocked(global.fetch).mockImplementation(async (url) => { + const filename = path.basename(url.toString()); + return new Response(filename, { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'text/plain' }, + }); + }); + + vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true); + vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot); + vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce( + fakeReleaseVersion, + ); + vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({ + owner: fakeRepoOwner, + repo: fakeRepoName, + }); + vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValueOnce( + 'fakeOpenCommand', + ); + + const result = (await setupGithubCommand.action?.( + {} as CommandContext, + '', + )) as ToolActionReturn; + + const { command } = result.toolArgs; + + // Check for pipefail + expect(command).toContain('set -eEuo pipefail'); + + // Check that the other commands are still present + expect(command).toContain('fakeOpenCommand'); + + // Verify that the workflows were downloaded + for (const workflow of workflows) { + const workflowFile = path.join( + scratchDir, + '.github', + 'workflows', + workflow, + ); + const contents = await fs.readFile(workflowFile, 'utf8'); + expect(contents).toContain(workflow); + } + + // Verify that .gitignore was created with the expected entries + const gitignorePath = path.join(scratchDir, '.gitignore'); + const gitignoreExists = await fs + .access(gitignorePath) + .then(() => true) + .catch(() => false); + expect(gitignoreExists).toBe(true); + + if (gitignoreExists) { + const gitignoreContent = await fs.readFile(gitignorePath, 'utf8'); + expect(gitignoreContent).toContain('.gemini/'); + expect(gitignoreContent).toContain('gha-creds-*.json'); + } + }); + + it('downloads workflows, updates gitignore, and does not include pipefail on windows', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + const fakeRepoOwner = 'fake'; + const fakeRepoName = 'repo'; + const fakeRepoRoot = scratchDir; + const fakeReleaseVersion = 'v1.2.3'; + + const workflows = GITHUB_WORKFLOW_PATHS.map((p) => path.basename(p)); + vi.mocked(global.fetch).mockImplementation(async (url) => { + const filename = path.basename(url.toString()); + return new Response(filename, { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'text/plain' }, + }); + }); + + vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true); + vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot); + vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce( + fakeReleaseVersion, + ); + vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({ + owner: fakeRepoOwner, + repo: fakeRepoName, + }); + vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValueOnce( + 'fakeOpenCommand', + ); + + const result = (await setupGithubCommand.action?.( + {} as CommandContext, + '', + )) as ToolActionReturn; + + const { command } = result.toolArgs; + + // Check for pipefail + expect(command).not.toContain('set -eEuo pipefail'); + + // Check that the other commands are still present + expect(command).toContain('fakeOpenCommand'); + + // Verify that the workflows were downloaded + for (const workflow of workflows) { + const workflowFile = path.join( + scratchDir, + '.github', + 'workflows', + workflow, + ); + const contents = await fs.readFile(workflowFile, 'utf8'); + expect(contents).toContain(workflow); + } + + // Verify that .gitignore was created with the expected entries + const gitignorePath = path.join(scratchDir, '.gitignore'); + const gitignoreExists = await fs + .access(gitignorePath) + .then(() => true) + .catch(() => false); + expect(gitignoreExists).toBe(true); + + if (gitignoreExists) { + const gitignoreContent = await fs.readFile(gitignorePath, 'utf8'); + expect(gitignoreContent).toContain('.gemini/'); + expect(gitignoreContent).toContain('gha-creds-*.json'); + } + }); + + it('throws an error when download fails', async () => { + const fakeRepoRoot = scratchDir; + const fakeReleaseVersion = 'v1.2.3'; + + vi.mocked(global.fetch).mockResolvedValue( + new Response('Not Found', { + status: 404, + statusText: 'Not Found', + }), + ); + + vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true); + vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot); + vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce( + fakeReleaseVersion, + ); + vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({ + owner: 'fake', + repo: 'repo', + }); + + await expect( + setupGithubCommand.action?.({} as CommandContext, ''), + ).rejects.toThrow(/Invalid response code downloading.*404 - Not Found/); + }); +}); + +describe('updateGitignore', () => { + let scratchDir = ''; + + beforeEach(async () => { + scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), 'update-gitignore-')); + }); + + afterEach(async () => { + if (scratchDir) await fs.rm(scratchDir, { recursive: true }); + }); + + it('creates a new .gitignore file when none exists', async () => { + await updateGitignore(scratchDir); + + const gitignorePath = path.join(scratchDir, '.gitignore'); + const content = await fs.readFile(gitignorePath, 'utf8'); + + expect(content).toBe('.gemini/\ngha-creds-*.json\n'); + }); + + it('appends entries to existing .gitignore file', async () => { + const gitignorePath = path.join(scratchDir, '.gitignore'); + const existingContent = '# Existing content\nnode_modules/\n'; + await fs.writeFile(gitignorePath, existingContent); + + await updateGitignore(scratchDir); + + const content = await fs.readFile(gitignorePath, 'utf8'); + + expect(content).toBe( + '# Existing content\nnode_modules/\n\n.gemini/\ngha-creds-*.json\n', + ); + }); + + it('does not add duplicate entries', async () => { + const gitignorePath = path.join(scratchDir, '.gitignore'); + const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n'; + await fs.writeFile(gitignorePath, existingContent); + + await updateGitignore(scratchDir); + + const content = await fs.readFile(gitignorePath, 'utf8'); + + expect(content).toBe(existingContent); + }); + + it('adds only missing entries when some already exist', async () => { + const gitignorePath = path.join(scratchDir, '.gitignore'); + const existingContent = '.gemini/\nsome-other-file\n'; + await fs.writeFile(gitignorePath, existingContent); + + await updateGitignore(scratchDir); + + const content = await fs.readFile(gitignorePath, 'utf8'); + + // Should add only the missing gha-creds-*.json entry + expect(content).toBe('.gemini/\nsome-other-file\n\ngha-creds-*.json\n'); + expect(content).toContain('gha-creds-*.json'); + // Should not duplicate .gemini/ entry + expect((content.match(/\.gemini\//g) || []).length).toBe(1); + }); + + it('does not get confused by entries in comments or as substrings', async () => { + const gitignorePath = path.join(scratchDir, '.gitignore'); + const existingContent = [ + '# This is a comment mentioning .gemini/ folder', + 'my-app.gemini/config', + '# Another comment with gha-creds-*.json pattern', + 'some-other-gha-creds-file.json', + '', + ].join('\n'); + await fs.writeFile(gitignorePath, existingContent); + + await updateGitignore(scratchDir); + + const content = await fs.readFile(gitignorePath, 'utf8'); + + // Should add both entries since they don't actually exist as gitignore rules + expect(content).toContain('.gemini/'); + expect(content).toContain('gha-creds-*.json'); + + // Verify the entries were added (not just mentioned in comments) + const lines = content + .split('\n') + .map((line) => line.split('#')[0].trim()) + .filter((line) => line); + expect(lines).toContain('.gemini/'); + expect(lines).toContain('gha-creds-*.json'); + expect(lines).toContain('my-app.gemini/config'); + expect(lines).toContain('some-other-gha-creds-file.json'); + }); + + it('handles file system errors gracefully', async () => { + // Try to update gitignore in a non-existent directory + const nonExistentDir = path.join(scratchDir, 'non-existent'); + + // This should not throw an error + await expect(updateGitignore(nonExistentDir)).resolves.toBeUndefined(); + }); + + it('handles permission errors gracefully', async () => { + const consoleSpy = vi + .spyOn(debugLogger, 'debug') + .mockImplementation(() => {}); + + const fsModule = await import('node:fs'); + const writeFileSpy = vi + .spyOn(fsModule.promises, 'writeFile') + .mockRejectedValue(new Error('Permission denied')); + + await expect(updateGitignore(scratchDir)).resolves.toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to update .gitignore:', + expect.any(Error), + ); + + writeFileSpy.mockRestore(); + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..f34e349e7e452223fa7840911a663db62f428a6f --- /dev/null +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -0,0 +1,281 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import * as fs from 'node:fs'; +import { Writable } from 'node:stream'; +import { ProxyAgent } from 'undici'; + +import type { CommandContext } from '../../ui/commands/types.js'; +import { + getGitRepoRoot, + getLatestGitHubRelease, + isGitHubRepository, + getGitHubRepoInfo, +} from '../../utils/gitUtils.js'; + +import { + CommandKind, + type SlashCommand, + type SlashCommandActionReturn, +} from './types.js'; +import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js'; +import { debugLogger } from '@google/gemini-cli-core'; + +export const GITHUB_WORKFLOW_PATHS = [ + 'gemini-dispatch/gemini-dispatch.yml', + 'gemini-assistant/gemini-invoke.yml', + 'gemini-assistant/gemini-plan-execute.yml', + 'issue-triage/gemini-triage.yml', + 'issue-triage/gemini-scheduled-triage.yml', + 'pr-review/gemini-review.yml', +]; + +export const GITHUB_COMMANDS_PATHS = [ + 'gemini-assistant/gemini-invoke.toml', + 'gemini-assistant/gemini-plan-execute.toml', + 'issue-triage/gemini-scheduled-triage.toml', + 'issue-triage/gemini-triage.toml', + 'pr-review/gemini-review.toml', +]; + +const REPO_DOWNLOAD_URL = + 'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli'; +const SOURCE_DIR = 'examples/workflows'; +// Generate OS-specific commands to open the GitHub pages needed for setup. +function getOpenUrlsCommands(readmeUrl: string): string[] { + // Determine the OS-specific command to open URLs, ex: 'open', 'xdg-open', etc + const openCmd = getUrlOpenCommand(); + + // Build a list of URLs to open + const urlsToOpen = [readmeUrl]; + + const repoInfo = getGitHubRepoInfo(); + if (repoInfo) { + urlsToOpen.push( + `https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`, + ); + } + + // Create and join the individual commands + const commands = urlsToOpen.map((url) => `${openCmd} "${url}"`); + return commands; +} + +// Add Gemini CLI specific entries to .gitignore file +export async function updateGitignore(gitRepoRoot: string): Promise { + const gitignoreEntries = ['.gemini/', 'gha-creds-*.json']; + + const gitignorePath = path.join(gitRepoRoot, '.gitignore'); + try { + // Check if .gitignore exists and read its content + let existingContent = ''; + let fileExists = true; + try { + existingContent = await fs.promises.readFile(gitignorePath, 'utf8'); + } catch { + // File doesn't exist + fileExists = false; + } + + if (!fileExists) { + // Create new .gitignore file with the entries + const contentToWrite = gitignoreEntries.join('\n') + '\n'; + await fs.promises.writeFile(gitignorePath, contentToWrite); + } else { + // Check which entries are missing + const missingEntries = gitignoreEntries.filter( + (entry) => + !existingContent + .split(/\r?\n/) + .some((line) => line.split('#')[0].trim() === entry), + ); + + if (missingEntries.length > 0) { + const contentToAdd = '\n' + missingEntries.join('\n') + '\n'; + await fs.promises.appendFile(gitignorePath, contentToAdd); + } + } + } catch (error) { + debugLogger.debug('Failed to update .gitignore:', error); + // Continue without failing the whole command + } +} + +async function downloadFiles({ + paths, + releaseTag, + targetDir, + proxy, + abortController, +}: { + paths: string[]; + releaseTag: string; + targetDir: string; + proxy: string | undefined; + abortController: AbortController; +}): Promise { + const downloads = []; + for (const fileBasename of paths) { + downloads.push( + (async () => { + const endpoint = `${REPO_DOWNLOAD_URL}/refs/tags/${releaseTag}/${SOURCE_DIR}/${fileBasename}`; + const response = await fetch(endpoint, { + method: 'GET', + dispatcher: proxy ? new ProxyAgent(proxy) : undefined, + /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ + signal: ( + AbortSignal as unknown as { + any: (signals: AbortSignal[]) => AbortSignal; + } + ).any([AbortSignal.timeout(30_000), abortController.signal]), + /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ + } as RequestInit); + + if (!response.ok) { + throw new Error( + `Invalid response code downloading ${endpoint}: ${response.status} - ${response.statusText}`, + ); + } + const body = response.body; + if (!body) { + throw new Error( + `Empty body while downloading ${endpoint}: ${response.status} - ${response.statusText}`, + ); + } + + const destination = path.resolve( + targetDir, + path.basename(fileBasename), + ); + + const fileStream = fs.createWriteStream(destination, { + mode: 0o644, // -rw-r--r--, user(rw), group(r), other(r) + flags: 'w', // write and overwrite + flush: true, + }); + + await body.pipeTo(Writable.toWeb(fileStream)); + })(), + ); + } + + await Promise.all(downloads).finally(() => { + abortController.abort(); + }); +} + +async function createDirectory(dirPath: string): Promise { + try { + await fs.promises.mkdir(dirPath, { recursive: true }); + } catch (error) { + debugLogger.debug(`Failed to create ${dirPath} directory:`, error); + throw new Error( + `Unable to create ${dirPath} directory. Do you have file permissions in the current directory?`, + ); + } +} + +async function downloadSetupFiles({ + configs, + releaseTag, + proxy, +}: { + configs: Array<{ paths: string[]; targetDir: string }>; + releaseTag: string; + proxy: string | undefined; +}): Promise { + try { + await Promise.all( + configs.map(({ paths, targetDir }) => { + const abortController = new AbortController(); + return downloadFiles({ + paths, + releaseTag, + targetDir, + proxy, + abortController, + }); + }), + ); + } catch (error) { + debugLogger.debug('Failed to download required setup files: ', error); + throw error; + } +} + +export const setupGithubCommand: SlashCommand = { + name: 'setup-github', + description: 'Set up GitHub Actions', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async ( + context: CommandContext, + ): Promise => { + if (!isGitHubRepository()) { + throw new Error( + 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', + ); + } + + // Find the root directory of the repo + let gitRepoRoot: string; + try { + gitRepoRoot = getGitRepoRoot(); + } catch (error) { + debugLogger.debug(`Failed to get git repo root:`, error); + throw new Error( + 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', + ); + } + + // Get the latest release tag from GitHub + const proxy = context?.services?.agentContext?.config.getProxy(); + const releaseTag = await getLatestGitHubRelease(proxy); + const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`; + + // Create workflows directory + const workflowsDir = path.join(gitRepoRoot, '.github', 'workflows'); + await createDirectory(workflowsDir); + + // Create commands directory + const commandsDir = path.join(gitRepoRoot, '.github', 'commands'); + await createDirectory(commandsDir); + + await downloadSetupFiles({ + configs: [ + { paths: GITHUB_WORKFLOW_PATHS, targetDir: workflowsDir }, + { paths: GITHUB_COMMANDS_PATHS, targetDir: commandsDir }, + ], + releaseTag, + proxy, + }); + + // Add entries to .gitignore file + await updateGitignore(gitRepoRoot); + + // Print out a message + const commands = []; + if (process.platform !== 'win32') { + commands.push('set -eEuo pipefail'); + } + commands.push( + `echo "Successfully downloaded ${GITHUB_WORKFLOW_PATHS.length} workflows , ${GITHUB_COMMANDS_PATHS.length} commands and updated .gitignore. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`, + ); + commands.push(...getOpenUrlsCommands(readmeUrl)); + + const command = `(${commands.join(' && ')})`; + return { + type: 'tool', + toolName: 'run_shell_command', + toolArgs: { + description: + 'Setting up GitHub Actions to triage issues and review PRs with Gemini.', + command, + }, + }; + }, +}; diff --git a/packages/cli/src/ui/commands/shortcutsCommand.ts b/packages/cli/src/ui/commands/shortcutsCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e1f44442680e3ccb85e4ee89d1846de8e07e850 --- /dev/null +++ b/packages/cli/src/ui/commands/shortcutsCommand.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from './types.js'; + +export const shortcutsCommand: SlashCommand = { + name: 'shortcuts', + altNames: [], + kind: CommandKind.BUILT_IN, + description: 'Toggle the shortcuts panel above the input', + autoExecute: true, + action: (context) => { + context.ui.toggleShortcutsHelp(); + }, +}; diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7cc0629f2ea1362dd1a8ea05a2ab4f0c60e5a721 --- /dev/null +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -0,0 +1,748 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { skillsCommand } from './skillsCommand.js'; +import { MessageType, type HistoryItemSkillsList } from '../types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { CommandContext } from './types.js'; +import type { Config, SkillDefinition } from '@google/gemini-cli-core'; +import { + SettingScope, + type LoadedSettings, + createTestMergedSettings, + type MergedSettings, +} from '../../config/settings.js'; + +vi.mock('../../utils/skillUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + linkSkill: vi.fn(), + }; +}); + +vi.mock('../../config/extensions/consent.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + requestConsentInteractive: vi.fn().mockResolvedValue(true), + skillsConsentString: vi.fn().mockResolvedValue('Mock Consent'), + }; +}); + +import { linkSkill } from '../../utils/skillUtils.js'; +import { requestConsentInteractive } from '../../config/extensions/consent.js'; + +vi.mock('../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isLoadableSettingScope: vi.fn((s) => s === 'User' || s === 'Workspace'), + }; +}); + +describe('skillsCommand', () => { + let context: CommandContext; + + beforeEach(() => { + vi.useFakeTimers(); + const skills = [ + { + name: 'skill1', + description: 'desc1', + location: '/loc1', + body: 'body1', + }, + { + name: 'skill2', + description: 'desc2', + location: '/loc2', + body: 'body2', + }, + ]; + context = createMockCommandContext({ + services: { + agentContext: { + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue(skills), + getSkills: vi.fn().mockReturnValue(skills), + isAdminEnabled: vi.fn().mockReturnValue(true), + getSkill: vi + .fn() + .mockImplementation( + (name: string) => skills.find((s) => s.name === name) ?? null, + ), + }), + getContentGenerator: vi.fn(), + get config() { + return this; + }, + } as unknown as Config, + settings: { + merged: createTestMergedSettings({ skills: { disabled: [] } }), + workspace: { path: '/workspace' }, + setValue: vi.fn(), + } as unknown as LoadedSettings, + }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('should add a SKILLS_LIST item to UI with descriptions by default', async () => { + await skillsCommand.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.SKILLS_LIST, + skills: [ + { + name: 'skill1', + description: 'desc1', + disabled: undefined, + location: '/loc1', + body: 'body1', + }, + { + name: 'skill2', + description: 'desc2', + disabled: undefined, + location: '/loc2', + body: 'body2', + }, + ], + showDescriptions: true, + }), + ); + }); + + it('should list skills when "list" subcommand is used', async () => { + const listCmd = skillsCommand.subCommands!.find((s) => s.name === 'list')!; + await listCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.SKILLS_LIST, + skills: [ + { + name: 'skill1', + description: 'desc1', + disabled: undefined, + location: '/loc1', + body: 'body1', + }, + { + name: 'skill2', + description: 'desc2', + disabled: undefined, + location: '/loc2', + body: 'body2', + }, + ], + showDescriptions: true, + }), + ); + }); + + it('should disable descriptions if "nodesc" arg is provided to list', async () => { + const listCmd = skillsCommand.subCommands!.find((s) => s.name === 'list')!; + await listCmd.action!(context, 'nodesc'); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + showDescriptions: false, + }), + ); + }); + + it('should filter built-in skills by default and show them with "all"', async () => { + const skillManager = + context.services.agentContext!.config.getSkillManager(); + const mockSkills = [ + { + name: 'regular', + description: 'desc1', + location: '/loc1', + body: 'body1', + }, + { + name: 'builtin', + description: 'desc2', + location: '/loc2', + body: 'body2', + isBuiltin: true, + }, + ]; + vi.mocked(skillManager.getAllSkills).mockReturnValue(mockSkills); + + const listCmd = skillsCommand.subCommands!.find((s) => s.name === 'list')!; + + // By default, only regular skills + await listCmd.action!(context, ''); + let lastCall = vi + .mocked(context.ui.addItem) + .mock.calls.at(-1)![0] as HistoryItemSkillsList; + expect(lastCall.skills).toHaveLength(1); + expect(lastCall.skills[0].name).toBe('regular'); + + // With "all", show both + await listCmd.action!(context, 'all'); + lastCall = vi + .mocked(context.ui.addItem) + .mock.calls.at(-1)![0] as HistoryItemSkillsList; + expect(lastCall.skills).toHaveLength(2); + expect(lastCall.skills.map((s) => s.name)).toContain('builtin'); + + // With "--all", show both + await listCmd.action!(context, '--all'); + lastCall = vi + .mocked(context.ui.addItem) + .mock.calls.at(-1)![0] as HistoryItemSkillsList; + expect(lastCall.skills).toHaveLength(2); + }); + + describe('link', () => { + it('should link a skill successfully', async () => { + const linkCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'link', + )!; + vi.mocked(linkSkill).mockResolvedValue([ + { name: 'test-skill', location: '/path' }, + ]); + + await linkCmd.action!(context, '/some/path'); + + expect(linkSkill).toHaveBeenCalledWith( + '/some/path', + 'user', + expect.any(Function), + expect.any(Function), + ); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Successfully linked skills from "/some/path" (user).', + }), + ); + }); + + it('should link a skill with workspace scope', async () => { + const linkCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'link', + )!; + vi.mocked(linkSkill).mockResolvedValue([ + { name: 'test-skill', location: '/path' }, + ]); + + await linkCmd.action!(context, '/some/path --scope workspace'); + + expect(linkSkill).toHaveBeenCalledWith( + '/some/path', + 'workspace', + expect.any(Function), + expect.any(Function), + ); + }); + + it('should pass a cleanup callback for interactive workspace consent', async () => { + const linkCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'link', + )!; + context.ui.setConfirmationRequest = vi.fn(); + vi.mocked(linkSkill).mockImplementation( + async (_sourcePath, _scope, _addItem, requestConsent) => { + expect(requestConsent).toBeDefined(); + await requestConsent!( + [{ name: 'test-skill', location: '/path' } as SkillDefinition], + '/workspace/.gemini/skills', + ); + return [{ name: 'test-skill', location: '/path' }]; + }, + ); + + await linkCmd.action!(context, '/some/path --scope workspace'); + + const requestConsentCall = vi + .mocked(requestConsentInteractive) + .mock.calls.at(-1); + expect(requestConsentCall?.[1]).toEqual(expect.any(Function)); + + const clearConfirmationRequest = requestConsentCall?.[2]; + expect(clearConfirmationRequest).toBeTypeOf('function'); + + clearConfirmationRequest?.(); + expect(context.ui.setConfirmationRequest).toHaveBeenCalledWith(null); + }); + + it('should show error if link fails', async () => { + const linkCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'link', + )!; + vi.mocked(linkSkill).mockRejectedValue(new Error('Link failed')); + + await linkCmd.action!(context, '/some/path'); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Failed to link skills: Link failed', + }), + ); + }); + + it('should show error if path is missing', async () => { + const linkCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'link', + )!; + await linkCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Usage: /skills link [--scope user|workspace]', + }), + ); + }); + }); + + describe('disable/enable', () => { + beforeEach(() => { + ( + context.services.settings as unknown as { merged: MergedSettings } + ).merged = createTestMergedSettings({ + skills: { enabled: true, disabled: [] }, + }); + ( + context.services.settings as unknown as { workspace: { path: string } } + ).workspace = { + path: '/workspace', + }; + + interface MockSettings { + user: { settings: unknown; path: string }; + workspace: { settings: unknown; path: string }; + forScope: unknown; + } + + const settings = context.services.settings as unknown as MockSettings; + + settings.forScope = vi.fn((scope) => { + if (scope === SettingScope.User) return settings.user; + if (scope === SettingScope.Workspace) return settings.workspace; + return { settings: {}, path: '' }; + }); + settings.user = { + settings: {}, + path: '/user/settings.json', + }; + settings.workspace = { + settings: {}, + path: '/workspace', + }; + }); + + it('should disable a skill', async () => { + const disableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'disable', + )!; + await disableCmd.action!(context, 'skill1'); + + expect(context.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'skills.disabled', + ['skill1'], + ); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Skill "skill1" disabled by adding it to the disabled list in workspace (/workspace) settings. You can run "/skills reload" to refresh your current instance.', + }), + ); + }); + + it('should show reload guidance even if skill is already disabled', async () => { + const disableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'disable', + )!; + ( + context.services.settings as unknown as { merged: MergedSettings } + ).merged = createTestMergedSettings({ + skills: { enabled: true, disabled: ['skill1'] }, + }); + ( + context.services.settings as unknown as { + workspace: { settings: { skills: { disabled: string[] } } }; + } + ).workspace.settings = { + skills: { disabled: ['skill1'] }, + }; + + await disableCmd.action!(context, 'skill1'); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Skill "skill1" is already disabled. You can run "/skills reload" to refresh your current instance.', + }), + ); + }); + + it('should enable a skill', async () => { + const enableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'enable', + )!; + ( + context.services.settings as unknown as { merged: MergedSettings } + ).merged = createTestMergedSettings({ + skills: { + enabled: true, + disabled: ['skill1'], + }, + }); + ( + context.services.settings as unknown as { + workspace: { settings: { skills: { disabled: string[] } } }; + } + ).workspace.settings = { + skills: { disabled: ['skill1'] }, + }; + + await enableCmd.action!(context, 'skill1'); + + expect(context.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'skills.disabled', + [], + ); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Skill "skill1" enabled by removing it from the disabled list in workspace (/workspace) and user (/user/settings.json) settings. You can run "/skills reload" to refresh your current instance.', + }), + ); + }); + + it('should enable a skill across multiple scopes', async () => { + const enableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'enable', + )!; + ( + context.services.settings as unknown as { + user: { settings: { skills: { disabled: string[] } } }; + } + ).user.settings = { + skills: { disabled: ['skill1'] }, + }; + ( + context.services.settings as unknown as { + workspace: { settings: { skills: { disabled: string[] } } }; + } + ).workspace.settings = { + skills: { disabled: ['skill1'] }, + }; + + await enableCmd.action!(context, 'skill1'); + + expect(context.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'skills.disabled', + [], + ); + expect(context.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'skills.disabled', + [], + ); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Skill "skill1" enabled by removing it from the disabled list in workspace (/workspace) and user (/user/settings.json) settings. You can run "/skills reload" to refresh your current instance.', + }), + ); + }); + + it('should show error if skill not found during disable', async () => { + const disableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'disable', + )!; + await disableCmd.action!(context, 'non-existent'); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Skill "non-existent" not found.', + }), + expect.any(Number), + ); + }); + + it('should show error if skills are disabled by admin during disable', async () => { + const skillManager = + context.services.agentContext!.config.getSkillManager(); + vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); + + const disableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'disable', + )!; + await disableCmd.action!(context, 'skill1'); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + }), + expect.any(Number), + ); + }); + + it('should show error if skills are disabled by admin during enable', async () => { + const skillManager = + context.services.agentContext!.config.getSkillManager(); + vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); + + const enableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'enable', + )!; + await enableCmd.action!(context, 'skill1'); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + }), + expect.any(Number), + ); + }); + }); + + describe('reload', () => { + it('should reload skills successfully and show success message', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + // Make reload take some time so timer can fire + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; + + const actionPromise = reloadCmd.action!(context, ''); + + // Initially, no pending item (flicker prevention) + expect(context.ui.setPendingItem).not.toHaveBeenCalled(); + + // Fast forward 100ms to trigger the pending item + await vi.advanceTimersByTimeAsync(100); + expect(context.ui.setPendingItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Reloading agent skills...', + }), + ); + + // Fast forward another 100ms (reload complete), but pending item should stay + await vi.advanceTimersByTimeAsync(100); + expect(context.ui.setPendingItem).not.toHaveBeenCalledWith(null); + + // Fast forward to reach 500ms total + await vi.advanceTimersByTimeAsync(300); + await actionPromise; + + expect(reloadSkillsMock).toHaveBeenCalled(); + expect(context.ui.reloadCommands).toHaveBeenCalled(); + expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Agent skills reloaded successfully.', + }), + ); + }); + + it('should show new skills count after reload', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + const skillManager = + context.services.agentContext!.config.getSkillManager(); + vi.mocked(skillManager.getSkills).mockReturnValue([ + { name: 'skill1' }, + { name: 'skill2' }, + { name: 'skill3' }, + ] as SkillDefinition[]); + }); + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; + + await reloadCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Agent skills reloaded successfully. 1 newly available skill.', + }), + ); + }); + + it('should show removed skills count after reload', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + const skillManager = + context.services.agentContext!.config.getSkillManager(); + vi.mocked(skillManager.getSkills).mockReturnValue([ + { name: 'skill1' }, + ] as SkillDefinition[]); + }); + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; + + await reloadCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Agent skills reloaded successfully. 1 skill no longer available.', + }), + ); + }); + + it('should show both added and removed skills count after reload', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + const skillManager = + context.services.agentContext!.config.getSkillManager(); + vi.mocked(skillManager.getSkills).mockReturnValue([ + { name: 'skill2' }, // skill1 removed, skill3 added + { name: 'skill3' }, + ] as SkillDefinition[]); + }); + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; + + await reloadCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Agent skills reloaded successfully. 1 newly available skill and 1 skill no longer available.', + }), + ); + }); + + it('should show error if configuration is missing', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + context.services.agentContext = null; + + await reloadCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Could not retrieve configuration.', + }), + ); + }); + + it('should show error if reload fails', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + const error = new Error('Reload failed'); + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + await new Promise((_, reject) => setTimeout(() => reject(error), 200)); + }); + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; + + const actionPromise = reloadCmd.action!(context, ''); + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(400); + await actionPromise; + + expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Failed to reload skills: Reload failed', + }), + ); + }); + }); + + describe('completions', () => { + it('should provide completions for disable (only enabled skills)', async () => { + const disableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'disable', + )!; + const skillManager = + context.services.agentContext!.config.getSkillManager(); + const mockSkills = [ + { + name: 'skill1', + description: 'desc1', + disabled: false, + location: '/loc1', + body: 'body1', + }, + { + name: 'skill2', + description: 'desc2', + disabled: true, + location: '/loc2', + body: 'body2', + }, + ]; + vi.mocked(skillManager.getAllSkills).mockReturnValue(mockSkills); + vi.mocked(skillManager.getSkill).mockImplementation( + (name: string) => mockSkills.find((s) => s.name === name) ?? null, + ); + + const completions = await disableCmd.completion!(context, 'sk'); + expect(completions).toEqual(['skill1']); + }); + + it('should provide completions for enable (only disabled skills)', async () => { + const enableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'enable', + )!; + const skillManager = + context.services.agentContext!.config.getSkillManager(); + const mockSkills = [ + { + name: 'skill1', + description: 'desc1', + disabled: false, + location: '/loc1', + body: 'body1', + }, + { + name: 'skill2', + description: 'desc2', + disabled: true, + location: '/loc2', + body: 'body2', + }, + ]; + vi.mocked(skillManager.getAllSkills).mockReturnValue(mockSkills); + vi.mocked(skillManager.getSkill).mockImplementation( + (name: string) => mockSkills.find((s) => s.name === name) ?? null, + ); + + const completions = await enableCmd.completion!(context, 'sk'); + expect(completions).toEqual(['skill2']); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..291186e628c0651476af5a7067ff63b05f74777b --- /dev/null +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -0,0 +1,419 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type CommandContext, + type SlashCommand, + type SlashCommandActionReturn, + CommandKind, +} from './types.js'; +import { + type HistoryItemInfo, + type HistoryItemSkillsList, + MessageType, +} from '../types.js'; +import { disableSkill, enableSkill } from '../../utils/skillSettings.js'; + +import { getAdminErrorMessage, getErrorMessage } from '@google/gemini-cli-core'; +import { + linkSkill, + renderSkillActionFeedback, +} from '../../utils/skillUtils.js'; +import { SettingScope } from '../../config/settings.js'; +import { + requestConsentInteractive, + skillsConsentString, +} from '../../config/extensions/consent.js'; + +async function listAction( + context: CommandContext, + args: string, +): Promise { + const subArgs = args.trim().split(/\s+/); + + // Default to SHOWING descriptions. The user can hide them with 'nodesc'. + let useShowDescriptions = true; + let showAll = false; + + for (const arg of subArgs) { + if (arg === 'nodesc' || arg === '--nodesc') { + useShowDescriptions = false; + } else if (arg === 'all' || arg === '--all') { + showAll = true; + } + } + + const skillManager = context.services.agentContext?.config.getSkillManager(); + if (!skillManager) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Could not retrieve skill manager.', + }); + return; + } + + const skills = showAll + ? skillManager.getAllSkills() + : skillManager.getAllSkills().filter((s) => !s.isBuiltin); + + const skillsListItem: HistoryItemSkillsList = { + type: MessageType.SKILLS_LIST, + skills: skills.map((skill) => ({ + name: skill.name, + description: skill.description, + disabled: skill.disabled, + location: skill.location, + body: skill.body, + isBuiltin: skill.isBuiltin, + })), + showDescriptions: useShowDescriptions, + }; + + context.ui.addItem(skillsListItem); +} + +async function linkAction( + context: CommandContext, + args: string, +): Promise { + const parts = args.trim().split(/\s+/); + const sourcePath = parts[0]; + + if (!sourcePath) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Usage: /skills link [--scope user|workspace]', + }); + return; + } + + let scopeArg = 'user'; + if (parts.length >= 3 && parts[1] === '--scope') { + scopeArg = parts[2]; + } else if (parts.length >= 2 && parts[1].startsWith('--scope=')) { + scopeArg = parts[1].split('=')[1]; + } + + const scope = scopeArg === 'workspace' ? 'workspace' : 'user'; + + try { + await linkSkill( + sourcePath, + scope, + (msg) => + context.ui.addItem({ + type: MessageType.INFO, + text: msg, + }), + async (skills, targetDir) => { + const consentString = await skillsConsentString( + skills, + sourcePath, + targetDir, + true, + ); + return requestConsentInteractive( + consentString, + context.ui.setConfirmationRequest.bind(context.ui), + () => context.ui.setConfirmationRequest(null), + ); + }, + ); + + context.ui.addItem({ + type: MessageType.INFO, + text: `Successfully linked skills from "${sourcePath}" (${scope}).`, + }); + + if (context.services.agentContext?.config) { + await context.services.agentContext.config.reloadSkills(); + } + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to link skills: ${getErrorMessage(error)}`, + }); + } +} + +async function disableAction( + context: CommandContext, + args: string, +): Promise { + const skillName = args.trim(); + if (!skillName) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Please provide a skill name to disable.', + }); + return; + } + const skillManager = context.services.agentContext?.config.getSkillManager(); + if (skillManager?.isAdminEnabled() === false) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: getAdminErrorMessage( + 'Agent skills', + context.services.agentContext?.config ?? undefined, + ), + }, + Date.now(), + ); + return; + } + + const skill = skillManager?.getSkill(skillName); + if (!skill) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Skill "${skillName}" not found.`, + }, + Date.now(), + ); + return; + } + + const scope = context.services.settings.workspace.path + ? SettingScope.Workspace + : SettingScope.User; + + const result = disableSkill(context.services.settings, skillName, scope); + + let feedback = renderSkillActionFeedback( + result, + (label, path) => `${label} (${path})`, + ); + if (result.status === 'success' || result.status === 'no-op') { + feedback += + ' You can run "/skills reload" to refresh your current instance.'; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: feedback, + }); +} + +async function enableAction( + context: CommandContext, + args: string, +): Promise { + const skillName = args.trim(); + if (!skillName) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Please provide a skill name to enable.', + }); + return; + } + + const skillManager = context.services.agentContext?.config.getSkillManager(); + if (skillManager?.isAdminEnabled() === false) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: getAdminErrorMessage( + 'Agent skills', + context.services.agentContext?.config ?? undefined, + ), + }, + Date.now(), + ); + return; + } + + const result = enableSkill(context.services.settings, skillName); + + let feedback = renderSkillActionFeedback( + result, + (label, path) => `${label} (${path})`, + ); + if (result.status === 'success' || result.status === 'no-op') { + feedback += + ' You can run "/skills reload" to refresh your current instance.'; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: feedback, + }); +} + +async function reloadAction( + context: CommandContext, +): Promise { + const config = context.services.agentContext?.config; + if (!config) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Could not retrieve configuration.', + }); + return; + } + + const skillManager = config.getSkillManager(); + const beforeNames = new Set(skillManager.getSkills().map((s) => s.name)); + + const startTime = Date.now(); + let pendingItemSet = false; + const pendingTimeout = setTimeout(() => { + context.ui.setPendingItem({ + type: MessageType.INFO, + text: 'Reloading agent skills...', + }); + pendingItemSet = true; + }, 100); + + try { + await config.reloadSkills(); + + clearTimeout(pendingTimeout); + if (pendingItemSet) { + // If we showed the pending item, make sure it stays for at least 500ms + // total to avoid a "flicker" where it appears and immediately disappears. + const elapsed = Date.now() - startTime; + const minVisibleDuration = 500; + if (elapsed < minVisibleDuration) { + await new Promise((resolve) => + setTimeout(resolve, minVisibleDuration - elapsed), + ); + } + context.ui.setPendingItem(null); + } + + context.ui.reloadCommands(); + + const afterSkills = skillManager.getSkills(); + const afterNames = new Set(afterSkills.map((s) => s.name)); + + const added = afterSkills.filter((s) => !beforeNames.has(s.name)); + const removedCount = [...beforeNames].filter( + (name) => !afterNames.has(name), + ).length; + + let successText = 'Agent skills reloaded successfully.'; + const details: string[] = []; + + if (added.length > 0) { + details.push( + `${added.length} newly available skill${added.length > 1 ? 's' : ''}`, + ); + } + if (removedCount > 0) { + details.push( + `${removedCount} skill${removedCount > 1 ? 's' : ''} no longer available`, + ); + } + + if (details.length > 0) { + successText += ` ${details.join(' and ')}.`; + } + + context.ui.addItem({ + type: 'info', + text: successText, + icon: '✓ ', + color: 'green', + } as HistoryItemInfo); + } catch (error) { + clearTimeout(pendingTimeout); + if (pendingItemSet) { + context.ui.setPendingItem(null); + } + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to reload skills: ${error instanceof Error ? error.message : String(error)}`, + }); + } +} + +function disableCompletion( + context: CommandContext, + partialArg: string, +): string[] { + const skillManager = context.services.agentContext?.config.getSkillManager(); + if (!skillManager) { + return []; + } + return skillManager + .getAllSkills() + .filter((s) => !s.disabled && s.name.startsWith(partialArg)) + .map((s) => s.name); +} + +function enableCompletion( + context: CommandContext, + partialArg: string, +): string[] { + const skillManager = context.services.agentContext?.config.getSkillManager(); + if (!skillManager) { + return []; + } + return skillManager + .getAllSkills() + .filter((s) => s.disabled && s.name.startsWith(partialArg)) + .map((s) => s.name); +} + +import { parseSlashCommand } from '../../utils/commands.js'; + +export const skillsCommand: SlashCommand = { + name: 'skills', + description: + 'List, enable, disable, or reload Gemini CLI agent skills. Usage: /skills [list | disable | enable | reload]', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [ + { + name: 'list', + description: + 'List available agent skills. Usage: /skills list [nodesc] [all]', + kind: CommandKind.BUILT_IN, + action: listAction, + }, + { + name: 'link', + description: + 'Link an agent skill from a local path. Usage: /skills link [--scope user|workspace]', + kind: CommandKind.BUILT_IN, + action: linkAction, + }, + { + name: 'disable', + description: 'Disable a skill by name. Usage: /skills disable ', + kind: CommandKind.BUILT_IN, + action: disableAction, + completion: disableCompletion, + }, + { + name: 'enable', + description: + 'Enable a disabled skill by name. Usage: /skills enable ', + kind: CommandKind.BUILT_IN, + action: enableAction, + completion: enableCompletion, + }, + { + name: 'reload', + altNames: ['refresh'], + description: + 'Reload the list of discovered skills. Usage: /skills reload', + kind: CommandKind.BUILT_IN, + action: reloadAction, + }, + ], + action: async (context, args) => { + if (args) { + const parsed = parseSlashCommand(`/${args}`, skillsCommand.subCommands!); + if (parsed.commandToExecute?.action) { + return parsed.commandToExecute.action(context, parsed.args); + } + } + return listAction(context, args); + }, +}; diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..86ecf686549277f592f5f3c206f857081b40ad77 --- /dev/null +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { statsCommand } from './statsCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; +import { formatDuration } from '../utils/formatters.js'; +import type { Config } from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + UserAccountManager: vi.fn().mockImplementation(() => ({ + getCachedGoogleAccount: vi.fn().mockReturnValue('mock@example.com'), + })), + getG1CreditBalance: vi.fn().mockReturnValue(undefined), + }; +}); + +describe('statsCommand', () => { + let mockContext: CommandContext; + const startTime = new Date('2025-07-14T10:00:00.000Z'); + const endTime = new Date('2025-07-14T10:00:30.000Z'); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(endTime); + + // 1. Create the mock context with all default values + mockContext = createMockCommandContext(); + + // 2. Directly set the property on the created mock context + mockContext.session.stats.sessionStartTime = startTime; + }); + + it('should display general session stats when run with no subcommand', async () => { + if (!statsCommand.action) throw new Error('Command has no action'); + + mockContext.services.agentContext = { + refreshUserQuota: vi.fn(), + refreshAvailableCredits: vi.fn(), + getUserTierName: vi.fn(), + getUserPaidTier: vi.fn(), + getModel: vi.fn(), + get config() { + return this; + }, + } as unknown as Config; + + await statsCommand.action(mockContext, ''); + + const expectedDuration = formatDuration( + endTime.getTime() - startTime.getTime(), + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.STATS, + duration: expectedDuration, + selectedAuthType: '', + tier: undefined, + userEmail: 'mock@example.com', + currentModel: undefined, + creditBalance: undefined, + }); + }); + + it('should fetch and display quota if config is available', async () => { + if (!statsCommand.action) throw new Error('Command has no action'); + + const mockQuota = { buckets: [] }; + const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota); + const mockGetUserTierName = vi.fn().mockReturnValue('Basic'); + const mockGetModel = vi.fn().mockReturnValue('gemini-pro'); + const mockGetQuotaRemaining = vi.fn().mockReturnValue(85); + const mockGetQuotaLimit = vi.fn().mockReturnValue(100); + const mockGetQuotaResetTime = vi + .fn() + .mockReturnValue('2025-01-01T12:00:00Z'); + + mockContext.services.agentContext = { + refreshUserQuota: mockRefreshUserQuota, + getUserTierName: mockGetUserTierName, + getModel: mockGetModel, + getQuotaRemaining: mockGetQuotaRemaining, + getQuotaLimit: mockGetQuotaLimit, + getQuotaResetTime: mockGetQuotaResetTime, + getUserPaidTier: vi.fn(), + refreshAvailableCredits: vi.fn(), + get config() { + return this; + }, + } as unknown as Config; + + await statsCommand.action(mockContext, ''); + + expect(mockRefreshUserQuota).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + quotas: mockQuota, + tier: 'Basic', + currentModel: 'gemini-pro', + pooledRemaining: 85, + pooledLimit: 100, + pooledResetTime: '2025-01-01T12:00:00Z', + }), + ); + }); + + it('should display model stats when using the "model" subcommand', () => { + const modelSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'model', + ); + if (!modelSubCommand?.action) throw new Error('Subcommand has no action'); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + modelSubCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.MODEL_STATS, + selectedAuthType: '', + tier: undefined, + userEmail: 'mock@example.com', + currentModel: undefined, + pooledRemaining: undefined, + pooledLimit: undefined, + }); + }); + + it('should display tool stats when using the "tools" subcommand', () => { + const toolsSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'tools', + ); + if (!toolsSubCommand?.action) throw new Error('Subcommand has no action'); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + toolsSubCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.TOOL_STATS, + }); + }); +}); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ca45963372e21a6539b1c802a45382dbb10610f --- /dev/null +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + HistoryItemStats, + HistoryItemModelStats, + HistoryItemToolStats, +} from '../types.js'; +import { MessageType } from '../types.js'; +import { formatDuration } from '../utils/formatters.js'; +import { + UserAccountManager, + getG1CreditBalance, +} from '@google/gemini-cli-core'; +import { + type CommandContext, + type SlashCommand, + CommandKind, +} from './types.js'; + +function getUserIdentity(context: CommandContext) { + const selectedAuthType = + context.services.settings.merged.security.auth.selectedType || ''; + + const userAccountManager = new UserAccountManager(); + const cachedAccount = userAccountManager.getCachedGoogleAccount(); + const userEmail = cachedAccount ?? undefined; + + const tier = context.services.agentContext?.config.getUserTierName(); + const paidTier = context.services.agentContext?.config.getUserPaidTier(); + const creditBalance = getG1CreditBalance(paidTier) ?? undefined; + + return { selectedAuthType, userEmail, tier, creditBalance }; +} + +async function defaultSessionView(context: CommandContext) { + const now = new Date(); + const { sessionStartTime } = context.session.stats; + if (!sessionStartTime) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Session start time is unavailable, cannot calculate stats.', + }); + return; + } + const wallDuration = now.getTime() - sessionStartTime.getTime(); + + const { selectedAuthType, userEmail, tier, creditBalance } = + getUserIdentity(context); + const currentModel = context.services.agentContext?.config.getModel(); + + const statsItem: HistoryItemStats = { + type: MessageType.STATS, + duration: formatDuration(wallDuration), + selectedAuthType, + userEmail, + tier, + currentModel, + creditBalance, + }; + + if (context.services.agentContext?.config) { + const [quota] = await Promise.all([ + context.services.agentContext.config.refreshUserQuota(), + context.services.agentContext.config.refreshAvailableCredits(), + ]); + if (quota) { + statsItem.quotas = quota; + statsItem.pooledRemaining = + context.services.agentContext.config.getQuotaRemaining(); + statsItem.pooledLimit = + context.services.agentContext.config.getQuotaLimit(); + statsItem.pooledResetTime = + context.services.agentContext.config.getQuotaResetTime(); + } + } + + context.ui.addItem(statsItem); +} + +export const statsCommand: SlashCommand = { + name: 'stats', + altNames: ['usage'], + description: 'Check session stats. Usage: /stats [session|model|tools]', + kind: CommandKind.BUILT_IN, + autoExecute: false, + isSafeConcurrent: true, + action: async (context: CommandContext) => { + await defaultSessionView(context); + }, + subCommands: [ + { + name: 'session', + description: 'Show session-specific usage statistics', + kind: CommandKind.BUILT_IN, + autoExecute: true, + isSafeConcurrent: true, + action: async (context: CommandContext) => { + await defaultSessionView(context); + }, + }, + { + name: 'model', + description: 'Show model-specific usage statistics', + kind: CommandKind.BUILT_IN, + autoExecute: true, + isSafeConcurrent: true, + action: (context: CommandContext) => { + const { selectedAuthType, userEmail, tier } = getUserIdentity(context); + const currentModel = context.services.agentContext?.config.getModel(); + const pooledRemaining = + context.services.agentContext?.config.getQuotaRemaining(); + const pooledLimit = + context.services.agentContext?.config.getQuotaLimit(); + const pooledResetTime = + context.services.agentContext?.config.getQuotaResetTime(); + context.ui.addItem({ + type: MessageType.MODEL_STATS, + selectedAuthType, + userEmail, + tier, + currentModel, + pooledRemaining, + pooledLimit, + pooledResetTime, + } as HistoryItemModelStats); + }, + }, + { + name: 'tools', + description: 'Show tool-specific usage statistics', + kind: CommandKind.BUILT_IN, + autoExecute: true, + isSafeConcurrent: true, + action: (context: CommandContext) => { + context.ui.addItem({ + type: MessageType.TOOL_STATS, + } as HistoryItemToolStats); + }, + }, + ], +}; diff --git a/packages/cli/src/ui/commands/tasksCommand.test.ts b/packages/cli/src/ui/commands/tasksCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b60f3f8ab3a83a7689f06211fcf4ea4e18ad0137 --- /dev/null +++ b/packages/cli/src/ui/commands/tasksCommand.test.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { tasksCommand } from './tasksCommand.js'; +import type { CommandContext } from './types.js'; + +describe('tasksCommand', () => { + it('should call toggleBackgroundTasks', async () => { + const toggleBackgroundTasks = vi.fn(); + const context = { + ui: { + toggleBackgroundTasks, + }, + } as unknown as CommandContext; + + if (tasksCommand.action) { + await tasksCommand.action(context, ''); + } + + expect(toggleBackgroundTasks).toHaveBeenCalled(); + }); + + it('should have correct name and altNames', () => { + expect(tasksCommand.name).toBe('tasks'); + expect(tasksCommand.altNames).toContain('bg'); + expect(tasksCommand.altNames).toContain('background'); + }); + + it('should auto-execute', () => { + expect(tasksCommand.autoExecute).toBe(true); + }); +}); diff --git a/packages/cli/src/ui/commands/tasksCommand.ts b/packages/cli/src/ui/commands/tasksCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..0980744e444584eb7a27247266b797007f1b1851 --- /dev/null +++ b/packages/cli/src/ui/commands/tasksCommand.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from './types.js'; + +export const tasksCommand: SlashCommand = { + name: 'tasks', + altNames: ['bg', 'background'], + kind: CommandKind.BUILT_IN, + description: 'Toggle background tasks view', + autoExecute: true, + action: async (context) => { + context.ui.toggleBackgroundTasks(); + }, +}; diff --git a/packages/cli/src/ui/commands/terminalSetupCommand.test.ts b/packages/cli/src/ui/commands/terminalSetupCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bee0d591cad02dd029dbf63cf092705022b6dc1 --- /dev/null +++ b/packages/cli/src/ui/commands/terminalSetupCommand.test.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { terminalSetupCommand } from './terminalSetupCommand.js'; +import * as terminalSetupModule from '../utils/terminalSetup.js'; +import type { CommandContext } from './types.js'; + +vi.mock('../utils/terminalSetup.js'); + +describe('terminalSetupCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should have correct metadata', () => { + expect(terminalSetupCommand.name).toBe('terminal-setup'); + expect(terminalSetupCommand.description).toContain('multiline input'); + expect(terminalSetupCommand.kind).toBe('built-in'); + }); + + it('should return success message when terminal setup succeeds', async () => { + vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({ + success: true, + message: 'Terminal configured successfully', + }); + + const result = await terminalSetupCommand.action!({} as CommandContext, ''); + + expect(result).toEqual({ + type: 'message', + content: 'Terminal configured successfully', + messageType: 'info', + }); + }); + + it('should append restart message when terminal setup requires restart', async () => { + vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({ + success: true, + message: 'Terminal configured successfully', + requiresRestart: true, + }); + + const result = await terminalSetupCommand.action!({} as CommandContext, ''); + + expect(result).toEqual({ + type: 'message', + content: + 'Terminal configured successfully\n\nPlease restart your terminal for the changes to take effect.', + messageType: 'info', + }); + }); + + it('should return error message when terminal setup fails', async () => { + vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({ + success: false, + message: 'Failed to detect terminal', + }); + + const result = await terminalSetupCommand.action!({} as CommandContext, ''); + + expect(result).toEqual({ + type: 'message', + content: 'Failed to detect terminal', + messageType: 'error', + }); + }); + + it('should handle exceptions from terminal setup', async () => { + vi.spyOn(terminalSetupModule, 'terminalSetup').mockRejectedValue( + new Error('Unexpected error'), + ); + + const result = await terminalSetupCommand.action!({} as CommandContext, ''); + + expect(result).toEqual({ + type: 'message', + content: 'Failed to configure terminal: Error: Unexpected error', + messageType: 'error', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/terminalSetupCommand.ts b/packages/cli/src/ui/commands/terminalSetupCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..64a4fb5057e7096aa98e138495af1056fe884581 --- /dev/null +++ b/packages/cli/src/ui/commands/terminalSetupCommand.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from './types.js'; +import { terminalSetup } from '../utils/terminalSetup.js'; +import { type MessageActionReturn } from '@google/gemini-cli-core'; + +/** + * Command to configure terminal keybindings for multiline input support. + * + * This command automatically detects and configures VS Code, Cursor, and Windsurf + * to support Shift+Enter and Ctrl+Enter for multiline input. + */ +export const terminalSetupCommand: SlashCommand = { + name: 'terminal-setup', + description: + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf)', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (): Promise => { + try { + const result = await terminalSetup(); + + let content = result.message; + if (result.requiresRestart) { + content += + '\n\nPlease restart your terminal for the changes to take effect.'; + } + + return { + type: 'message', + content, + messageType: result.success ? 'info' : 'error', + }; + } catch (error) { + return { + type: 'message', + content: `Failed to configure terminal: ${error}`, + messageType: 'error', + }; + } + }, +}; diff --git a/packages/cli/src/ui/commands/themeCommand.test.ts b/packages/cli/src/ui/commands/themeCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..67a12bd2fe1e686c9aec92cfed86bd14380aa9ac --- /dev/null +++ b/packages/cli/src/ui/commands/themeCommand.test.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { themeCommand } from './themeCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('themeCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should return a dialog action to open the theme dialog', () => { + // Ensure the command has an action to test. + if (!themeCommand.action) { + throw new Error('The theme command must have an action.'); + } + + const result = themeCommand.action(mockContext, ''); + + // Assert that the action returns the correct object to trigger the theme dialog. + expect(result).toEqual({ + type: 'dialog', + dialog: 'theme', + }); + }); + + it('should have the correct name and description', () => { + expect(themeCommand.name).toBe('theme'); + expect(themeCommand.description).toBe('Change the theme'); + }); +}); diff --git a/packages/cli/src/ui/commands/themeCommand.ts b/packages/cli/src/ui/commands/themeCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..265aaf9a75babb98f646fc6b90d3644e5ab9a878 --- /dev/null +++ b/packages/cli/src/ui/commands/themeCommand.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type OpenDialogActionReturn, + type SlashCommand, +} from './types.js'; + +export const themeCommand: SlashCommand = { + name: 'theme', + description: 'Change the theme', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (_context, _args): OpenDialogActionReturn => ({ + type: 'dialog', + dialog: 'theme', + }), +}; diff --git a/packages/cli/src/ui/commands/toolsCommand.test.ts b/packages/cli/src/ui/commands/toolsCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..02d9ddb5bc36dffddba4a23c68890d2ac47dd091 --- /dev/null +++ b/packages/cli/src/ui/commands/toolsCommand.test.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, type vi } from 'vitest'; +import { toolsCommand } from './toolsCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; +import type { ToolBuilder, ToolResult } from '@google/gemini-cli-core'; + +// Mock tools for testing +const mockTools = [ + { + name: 'file-reader', + displayName: 'File Reader', + description: 'Reads files from the local system.', + schema: {}, + }, + { + name: 'code-editor', + displayName: 'Code Editor', + description: 'Edits code files.', + schema: {}, + }, +] as unknown as Array>; + +describe('toolsCommand', () => { + it('should display an error if the tool registry is unavailable', async () => { + const mockContext = createMockCommandContext({ + services: { + agentContext: { + toolRegistry: undefined, + }, + }, + }); + + if (!toolsCommand.action) throw new Error('Action not defined'); + await toolsCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Could not retrieve tool registry.', + }); + }); + + it('should display "No tools available" when none are found', async () => { + const mockContext = createMockCommandContext({ + services: { + agentContext: { + toolRegistry: { + getAllTools: () => [] as Array>, + }, + }, + }, + }); + + if (!toolsCommand.action) throw new Error('Action not defined'); + await toolsCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.TOOLS_LIST, + tools: [], + showDescriptions: false, + }); + }); + + it('should list tools without descriptions by default (no args)', async () => { + const mockContext = createMockCommandContext({ + services: { + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, + }, + }, + }); + + if (!toolsCommand.action) throw new Error('Action not defined'); + await toolsCommand.action(mockContext, ''); + + const [message] = (mockContext.ui.addItem as ReturnType).mock + .calls[0]; + expect(message.type).toBe(MessageType.TOOLS_LIST); + expect(message.showDescriptions).toBe(false); + expect(message.tools).toHaveLength(2); + expect(message.tools[0].displayName).toBe('File Reader'); + expect(message.tools[1].displayName).toBe('Code Editor'); + }); + + it('should list tools without descriptions when "list" arg is passed', async () => { + const mockContext = createMockCommandContext({ + services: { + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, + }, + }, + }); + + if (!toolsCommand.action) throw new Error('Action not defined'); + await toolsCommand.action(mockContext, 'list'); + + const [message] = (mockContext.ui.addItem as ReturnType).mock + .calls[0]; + expect(message.type).toBe(MessageType.TOOLS_LIST); + expect(message.showDescriptions).toBe(false); + expect(message.tools).toHaveLength(2); + expect(message.tools[0].displayName).toBe('File Reader'); + expect(message.tools[1].displayName).toBe('Code Editor'); + }); + + it('should list tools with descriptions when "desc" arg is passed', async () => { + const mockContext = createMockCommandContext({ + services: { + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, + }, + }, + }); + + if (!toolsCommand.action) throw new Error('Action not defined'); + await toolsCommand.action(mockContext, 'desc'); + + const [message] = (mockContext.ui.addItem as ReturnType).mock + .calls[0]; + expect(message.type).toBe(MessageType.TOOLS_LIST); + expect(message.showDescriptions).toBe(true); + expect(message.tools).toHaveLength(2); + expect(message.tools[0].displayName).toBe('File Reader'); + expect(message.tools[0].description).toBe( + 'Reads files from the local system.', + ); + expect(message.tools[1].displayName).toBe('Code Editor'); + expect(message.tools[1].description).toBe('Edits code files.'); + }); + + it('should have "list" and "desc" subcommands', () => { + expect(toolsCommand.subCommands).toBeDefined(); + const names = toolsCommand.subCommands?.map((s) => s.name); + expect(names).toContain('list'); + expect(names).toContain('desc'); + expect(names).not.toContain('descriptions'); + }); + + it('subcommand "list" should display tools without descriptions', async () => { + const mockContext = createMockCommandContext({ + services: { + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, + }, + }, + }); + + const listCmd = toolsCommand.subCommands?.find((s) => s.name === 'list'); + if (!listCmd?.action) throw new Error('Action not defined'); + await listCmd.action(mockContext, ''); + + const [message] = (mockContext.ui.addItem as ReturnType).mock + .calls[0]; + expect(message.showDescriptions).toBe(false); + expect(message.tools).toHaveLength(2); + expect(message.tools[0].displayName).toBe('File Reader'); + expect(message.tools[1].displayName).toBe('Code Editor'); + }); + + it('subcommand "desc" should display tools with descriptions', async () => { + const mockContext = createMockCommandContext({ + services: { + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, + }, + }, + }); + + const descCmd = toolsCommand.subCommands?.find((s) => s.name === 'desc'); + if (!descCmd?.action) throw new Error('Action not defined'); + await descCmd.action(mockContext, ''); + + const [message] = (mockContext.ui.addItem as ReturnType).mock + .calls[0]; + expect(message.showDescriptions).toBe(true); + expect(message.tools).toHaveLength(2); + expect(message.tools[0].displayName).toBe('File Reader'); + expect(message.tools[0].description).toBe( + 'Reads files from the local system.', + ); + expect(message.tools[1].displayName).toBe('Code Editor'); + expect(message.tools[1].description).toBe('Edits code files.'); + }); + + it('should expose a desc subcommand for TUI discoverability', async () => { + const descSubCommand = toolsCommand.subCommands?.find( + (cmd) => cmd.name === 'desc', + ); + expect(descSubCommand).toBeDefined(); + expect(descSubCommand?.description).toContain('descriptions'); + + const mockContext = createMockCommandContext({ + services: { + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, + }, + }, + }); + + if (!descSubCommand?.action) throw new Error('Action not defined'); + await descSubCommand.action(mockContext, ''); + + const [message] = (mockContext.ui.addItem as ReturnType).mock + .calls[0]; + expect(message.type).toBe(MessageType.TOOLS_LIST); + expect(message.showDescriptions).toBe(true); + }); +}); diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3e5aef74bc22ed27de3ccbbbc9dcb7859b9de86 --- /dev/null +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type CommandContext, + type SlashCommand, + CommandKind, +} from './types.js'; +import { MessageType, type HistoryItemToolsList } from '../types.js'; + +async function listTools( + context: CommandContext, + showDescriptions: boolean, +): Promise { + const toolRegistry = context.services.agentContext?.toolRegistry; + if (!toolRegistry) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Could not retrieve tool registry.', + }); + return; + } + + const tools = toolRegistry.getAllTools(); + // Filter out MCP tools by checking for the absence of a serverName property + const geminiTools = tools.filter((tool) => !('serverName' in tool)); + + const toolsListItem: HistoryItemToolsList = { + type: MessageType.TOOLS_LIST, + tools: geminiTools.map((tool) => ({ + name: tool.name, + displayName: tool.displayName, + description: tool.description, + })), + showDescriptions, + }; + + context.ui.addItem(toolsListItem); +} + +const listSubCommand: SlashCommand = { + name: 'list', + description: 'List available Gemini CLI tools.', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext): Promise => + listTools(context, false), +}; + +const descSubCommand: SlashCommand = { + name: 'desc', + altNames: ['descriptions'], + description: 'List available Gemini CLI tools with descriptions.', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext): Promise => + listTools(context, true), +}; + +export const toolsCommand: SlashCommand = { + name: 'tools', + description: + 'List available Gemini CLI tools. Use /tools desc to include descriptions.', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [listSubCommand, descSubCommand], + action: async (context: CommandContext, args?: string): Promise => { + const subCommand = args?.trim(); + + // Keep backward compatibility for typed arguments while exposing subcommands in TUI. + const useShowDescriptions = + subCommand === 'desc' || subCommand === 'descriptions'; + + await listTools(context, useShowDescriptions); + }, +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..266a3bcf0204275b71bd34654f692d9b2c3a3f08 --- /dev/null +++ b/packages/cli/src/ui/commands/types.ts @@ -0,0 +1,257 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ReactNode } from 'react'; +import type { + HistoryItemWithoutId, + HistoryItem, + ConfirmationRequest, +} from '../types.js'; +import type { + GitService, + Logger, + CommandActionReturn, + AgentDefinition, + AgentLoopContext, +} from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; +import type { SessionStatsState } from '../contexts/SessionContext.js'; +import type { + ExtensionUpdateAction, + ExtensionUpdateStatus, +} from '../state/extensions.js'; + +// Grouped dependencies for clarity and easier mocking +export interface CommandContext { + // Invocation properties for when commands are called. + invocation?: { + /** The raw, untrimmed input string from the user. */ + raw: string; + /** The primary name of the command that was matched. */ + name: string; + /** The arguments string that follows the command name. */ + args: string; + }; + // Core services and configuration + services: { + // TODO(abhipatel12): Ensure that config is never null. + agentContext: AgentLoopContext | null; + settings: LoadedSettings; + git: GitService | undefined; + logger: Logger; + }; + // UI state and history management + ui: { + /** Adds a new item to the history display. */ + addItem: UseHistoryManagerReturn['addItem']; + /** Clears all history items and the console screen. */ + clear: () => void; + /** + * Sets the transient debug message displayed in the application footer in debug mode. + */ + setDebugMessage: (message: string) => void; + /** The currently pending history item, if any. */ + pendingItem: HistoryItemWithoutId | null; + /** + * Sets a pending item in the history, which is useful for indicating + * that a long-running operation is in progress. + * + * @param item The history item to display as pending, or `null` to clear. + */ + setPendingItem: (item: HistoryItemWithoutId | null) => void; + /** + * Loads a new set of history items, replacing the current history. + * + * @param history The array of history items to load. + * @param postLoadInput Optional text to set in the input buffer after loading history. + */ + loadHistory: (history: HistoryItem[], postLoadInput?: string) => void; + /** Toggles a special display mode. */ + toggleCorgiMode: () => void; + toggleVoiceMode: () => void; + toggleDebugProfiler: () => void; + toggleVimEnabled: () => Promise; + reloadCommands: () => void; + openAgentConfigDialog: ( + name: string, + displayName: string, + definition: AgentDefinition, + ) => void; + extensionsUpdateState: Map; + dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; + addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void; + /** + * Sets a confirmation request to be displayed to the user. + * + * @param value The confirmation request details. + */ + setConfirmationRequest: (value: ConfirmationRequest | null) => void; + removeComponent: () => void; + toggleBackgroundTasks: () => void; + toggleShortcutsHelp: () => void; + }; + // Session-specific data + session: { + stats: SessionStatsState; + /** A transient list of shell commands the user has approved for this session. */ + sessionShellAllowlist: Set; + }; + // Flag to indicate if an overwrite has been confirmed + overwriteConfirmed?: boolean; +} + +/** The return type for a command action that results in the app quitting. */ +export interface QuitActionReturn { + type: 'quit'; + messages: HistoryItem[]; + /** When true, the current session's history and temporary files will be deleted on exit. */ + deleteSession?: boolean; +} + +/** + * The return type for a command action that needs to open a dialog. + */ +export interface OpenDialogActionReturn { + type: 'dialog'; + props?: Record; + + dialog: + | 'help' + | 'auth' + | 'theme' + | 'editor' + | 'privacy' + | 'settings' + | 'sessionBrowser' + | 'model' + | 'voice-model' + | 'agentConfig' + | 'permissions'; +} + +/** + * The return type for a command action that needs to pause and request + * confirmation for a set of shell commands before proceeding. + */ +export interface ConfirmShellCommandsActionReturn { + type: 'confirm_shell_commands'; + /** The list of shell commands that require user confirmation. */ + commandsToConfirm: string[]; + /** The original invocation context to be re-run after confirmation. */ + originalInvocation: { + raw: string; + }; +} + +export interface ConfirmActionReturn { + type: 'confirm_action'; + /** The React node to display as the confirmation prompt. */ + prompt: ReactNode; + /** The original invocation context to be re-run after confirmation. */ + originalInvocation: { + raw: string; + }; +} + +export interface OpenCustomDialogActionReturn { + type: 'custom_dialog'; + component: ReactNode; +} + +/** + * The return type for a command action that specifically handles logout logic, + * signaling the application to explicitly transition to an unauthenticated state. + */ +export interface LogoutActionReturn { + type: 'logout'; +} + +export type SlashCommandActionReturn = + | CommandActionReturn + | QuitActionReturn + | OpenDialogActionReturn + | ConfirmShellCommandsActionReturn + | ConfirmActionReturn + | OpenCustomDialogActionReturn + | LogoutActionReturn; + +export enum CommandKind { + BUILT_IN = 'built-in', + USER_FILE = 'user-file', + WORKSPACE_FILE = 'workspace-file', + EXTENSION_FILE = 'extension-file', + MCP_PROMPT = 'mcp-prompt', + AGENT = 'agent', + SKILL = 'skill', +} + +// The standardized contract for any command in the system. +export interface SlashCommand { + name: string; + altNames?: string[]; + description: string; + hidden?: boolean; + /** + * Optional grouping label for slash completion UI sections. + * Commands with the same label are rendered under one separator. + */ + suggestionGroup?: string; + + kind: CommandKind; + + /** + * Controls whether the command auto-executes when selected with Enter. + * + * If true, pressing Enter on the suggestion will execute the command immediately. + * If false or undefined, pressing Enter will autocomplete the command into the prompt window. + */ + autoExecute?: boolean; + + /** + * Whether this command can be safely executed while the agent is busy (e.g. streaming a response). + */ + isSafeConcurrent?: boolean; + + // Optional metadata for extension commands + extensionName?: string; + extensionId?: string; + + // Optional metadata for MCP commands + mcpServerName?: string; + + // The action to run. Optional for parent commands that only group sub-commands. + action?: ( + context: CommandContext, + args: string, // TODO: Remove args. CommandContext now contains the complete invocation. + ) => + | void + | SlashCommandActionReturn + | Promise; + + // Provides argument completion (e.g., completing a tag for `/resume resume `). + completion?: ( + context: CommandContext, + partialArg: string, + ) => Promise | string[]; + + /** + * Whether to show the loading indicator while fetching completions. + * Defaults to true. Set to false for fast completions to avoid flicker. + */ + showCompletionLoading?: boolean; + + /** + * Whether the command expects arguments. + * If false, and the command is a subcommand, the command parser may treat + * any following text as arguments for the parent command instead of this subcommand, + * provided the parent command has an action. + * Defaults to true. + */ + takesArgs?: boolean; + + subCommands?: SlashCommand[]; +} diff --git a/packages/cli/src/ui/commands/upgradeCommand.test.ts b/packages/cli/src/ui/commands/upgradeCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb07c1bd447bf323a1cb0e4de6b1b6657eabbccb --- /dev/null +++ b/packages/cli/src/ui/commands/upgradeCommand.test.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { upgradeCommand } from './upgradeCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { + AuthType, + openBrowserSecurely, + shouldLaunchBrowser, + UPGRADE_URL_PAGE, +} from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + openBrowserSecurely: vi.fn(), + shouldLaunchBrowser: vi.fn().mockReturnValue(true), + UPGRADE_URL_PAGE: 'https://goo.gle/set-up-gemini-code-assist', + }; +}); + +describe('upgradeCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), + getUserTierName: vi.fn().mockReturnValue(undefined), + }, + }, + }, + } as unknown as CommandContext); + }); + + it('should have the correct name and description', () => { + expect(upgradeCommand.name).toBe('upgrade'); + expect(upgradeCommand.description).toBe( + 'Upgrade your Gemini Code Assist tier for higher limits', + ); + }); + + it('should call openBrowserSecurely with UPGRADE_URL_PAGE when logged in with Google', async () => { + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + await upgradeCommand.action(mockContext, ''); + + expect(openBrowserSecurely).toHaveBeenCalledWith(UPGRADE_URL_PAGE); + }); + + it('should return an error message when NOT logged in with Google', async () => { + vi.mocked( + mockContext.services.agentContext!.config.getContentGeneratorConfig, + ).mockReturnValue({ + authType: AuthType.USE_GEMINI, + }); + + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + const result = await upgradeCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'The /upgrade command is only available when logged in with Google.', + }); + expect(openBrowserSecurely).not.toHaveBeenCalled(); + }); + + it('should return an error message if openBrowserSecurely fails', async () => { + vi.mocked(openBrowserSecurely).mockRejectedValue( + new Error('Failed to open'), + ); + + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + const result = await upgradeCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to open upgrade page: Failed to open', + }); + }); + + it('should return URL message when shouldLaunchBrowser returns false', async () => { + vi.mocked(shouldLaunchBrowser).mockReturnValue(false); + + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + const result = await upgradeCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Please open this URL in a browser: ${UPGRADE_URL_PAGE}`, + }); + expect(openBrowserSecurely).not.toHaveBeenCalled(); + }); + + it('should return info message for ultra tiers', async () => { + vi.mocked( + mockContext.services.agentContext!.config.getUserTierName, + ).mockReturnValue('Advanced Ultra'); + + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + const result = await upgradeCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'You are already on the highest tier: Advanced Ultra.', + }); + expect(openBrowserSecurely).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/commands/upgradeCommand.ts b/packages/cli/src/ui/commands/upgradeCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7c09a42f093447071f563f6820638682b632e97 --- /dev/null +++ b/packages/cli/src/ui/commands/upgradeCommand.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthType, + openBrowserSecurely, + shouldLaunchBrowser, + UPGRADE_URL_PAGE, +} from '@google/gemini-cli-core'; +import { isUltraTier } from '../../utils/tierUtils.js'; +import { CommandKind, type SlashCommand } from './types.js'; + +/** + * Command to open the upgrade page for Gemini Code Assist. + * Only intended to be shown/available when the user is logged in with Google. + */ +export const upgradeCommand: SlashCommand = { + name: 'upgrade', + kind: CommandKind.BUILT_IN, + description: 'Upgrade your Gemini Code Assist tier for higher limits', + autoExecute: true, + action: async (context) => { + const config = context.services.agentContext?.config; + const authType = config?.getContentGeneratorConfig()?.authType; + if (authType !== AuthType.LOGIN_WITH_GOOGLE) { + // This command should ideally be hidden if not logged in with Google, + // but we add a safety check here just in case. + return { + type: 'message', + messageType: 'error', + content: + 'The /upgrade command is only available when logged in with Google.', + }; + } + + const tierName = config?.getUserTierName(); + if (isUltraTier(tierName)) { + return { + type: 'message', + messageType: 'info', + content: `You are already on the highest tier: ${tierName}.`, + }; + } + + if (!shouldLaunchBrowser()) { + return { + type: 'message', + messageType: 'info', + content: `Please open this URL in a browser: ${UPGRADE_URL_PAGE}`, + }; + } + + try { + await openBrowserSecurely(UPGRADE_URL_PAGE); + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to open upgrade page: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + return undefined; + }, +}; diff --git a/packages/cli/src/ui/commands/vimCommand.ts b/packages/cli/src/ui/commands/vimCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..74d54ee5ef207702ad2c0e25e943fe6bc55387a2 --- /dev/null +++ b/packages/cli/src/ui/commands/vimCommand.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from './types.js'; + +export const vimCommand: SlashCommand = { + name: 'vim', + description: 'Toggle vim mode on/off', + kind: CommandKind.BUILT_IN, + autoExecute: true, + isSafeConcurrent: true, + action: async (context, _args) => { + const newVimState = await context.ui.toggleVimEnabled(); + + const message = newVimState + ? 'Entered Vim mode. Run /vim again to exit.' + : 'Exited Vim mode.'; + return { + type: 'message', + messageType: 'info', + content: message, + }; + }, +}; diff --git a/packages/cli/src/ui/commands/voiceCommand.ts b/packages/cli/src/ui/commands/voiceCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9df28ca27f66b23bd652d378a1c81208a706d08 --- /dev/null +++ b/packages/cli/src/ui/commands/voiceCommand.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from './types.js'; + +export const voiceCommand: SlashCommand = { + name: 'voice', + altNames: [], + description: 'Toggle voice dictation mode', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (context) => { + context.ui.toggleVoiceMode(); + }, + subCommands: [ + { + name: 'model', + description: 'Manage voice transcription models', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async () => ({ + type: 'dialog', + dialog: 'voice-model', + }), + }, + ], +}; diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionBrowserEmpty.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserEmpty.tsx new file mode 100644 index 0000000000000000000000000000000000000000..31c9544cd8a4909c34b50f7755ed5c1cca249e5f --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserEmpty.tsx @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../../colors.js'; + +/** + * Empty state component displayed when no sessions are found. + */ +export const SessionBrowserEmpty = (): React.JSX.Element => ( + + No auto-saved conversations found. + Press q to exit + +); diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionBrowserError.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserError.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cf46fb89542217885c552d1e77ee48ad5336fce1 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserError.tsx @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../../colors.js'; +import type { SessionBrowserState } from '../SessionBrowser.js'; + +/** + * Error state component displayed when session loading fails. + */ +export const SessionBrowserError = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + Error: {state.error} + Press q to exit + +); diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionBrowserLoading.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserLoading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e0c372eca25e96a83b54655d5386f8dec088b528 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserLoading.tsx @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../../colors.js'; + +/** + * Loading state component displayed while sessions are being loaded. + */ +export const SessionBrowserLoading = (): React.JSX.Element => ( + + Loading sessions… + +); diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionBrowserNav.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserNav.tsx new file mode 100644 index 0000000000000000000000000000000000000000..99d0363ed5590b034dad276100e59a7b89c2a5be --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserNav.tsx @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../../colors.js'; +import type { SessionBrowserState } from '../SessionBrowser.js'; + +const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => ( + <> + {name}: {shortcut} + +); + +/** + * Navigation help component showing keyboard shortcuts. + */ +export const NavigationHelpDisplay = (): React.JSX.Element => ( + + + + {' '} + + {' '} + + {' '} + + {' '} + + + + + {' '} + + {' '} + + + +); + +/** + * Search input display component. + */ +export const SearchModeDisplay = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + Search: + {state.searchQuery} + (Esc to cancel) + +); + +/** + * No results display component for empty search results. + */ +export const NoResultsDisplay = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + + No sessions found matching '{state.searchQuery}'. + + +); diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionBrowserSearchNav.test.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserSearchNav.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..be37317626e07d8fe05f399586a18678a50e7597 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserSearchNav.test.tsx @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { describe, it, expect } from 'vitest'; +import { + SearchModeDisplay, + NavigationHelpDisplay, + NoResultsDisplay, +} from './SessionBrowserNav.js'; +import { SessionListHeader } from './SessionListHeader.js'; +import type { SessionBrowserState } from '../SessionBrowser.js'; + +describe('SessionBrowser Search and Navigation Components', () => { + it('SearchModeDisplay renders correctly with query', async () => { + const mockState = { searchQuery: 'test query' } as SessionBrowserState; + const { lastFrame } = await render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('NavigationHelp renders correctly', async () => { + const { lastFrame } = await render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('SessionListHeader renders correctly', async () => { + const mockState = { + totalSessions: 10, + searchQuery: '', + sortOrder: 'date', + sortReverse: false, + } as SessionBrowserState; + const { lastFrame } = await render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('SessionListHeader renders correctly with filter', async () => { + const mockState = { + totalSessions: 5, + searchQuery: 'test', + sortOrder: 'name', + sortReverse: true, + } as SessionBrowserState; + const { lastFrame } = await render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('NoResultsDisplay renders correctly', async () => { + const mockState = { searchQuery: 'no match' } as SessionBrowserState; + const { lastFrame } = await render(); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionBrowserStates.test.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserStates.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0607c28a24991cf2dcd9c7e6365768ecb0d4fd93 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionBrowserStates.test.tsx @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { describe, it, expect } from 'vitest'; +import { SessionBrowserLoading } from './SessionBrowserLoading.js'; +import { SessionBrowserError } from './SessionBrowserError.js'; +import { SessionBrowserEmpty } from './SessionBrowserEmpty.js'; +import type { SessionBrowserState } from '../SessionBrowser.js'; + +describe('SessionBrowser UI States', () => { + it('SessionBrowserLoading renders correctly', async () => { + const { lastFrame } = await render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('SessionBrowserError renders correctly', async () => { + const mockState = { error: 'Test error message' } as SessionBrowserState; + const { lastFrame } = await render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('SessionBrowserEmpty renders correctly', async () => { + const { lastFrame } = await render(); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/SessionBrowser/SessionListHeader.tsx b/packages/cli/src/ui/components/SessionBrowser/SessionListHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2b7fb79d409a7032cbae579881020b91d3042c23 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/SessionListHeader.tsx @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../../colors.js'; +import type { SessionBrowserState } from '../SessionBrowser.js'; + +/** + * Header component showing session count and sort information. + */ +export const SessionListHeader = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + + Chat Sessions ({state.totalSessions} total + {state.searchQuery ? `, filtered` : ''}) + + + sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'} + + +); diff --git a/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserSearchNav.test.tsx.snap b/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserSearchNav.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..c5ed5e5454862921ecf2712699581ae3c46c09f1 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserSearchNav.test.tsx.snap @@ -0,0 +1,29 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SessionBrowser Search and Navigation Components > NavigationHelp renders correctly 1`] = ` +"Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q +Sort: s Reverse: r First/Last: g/G +" +`; + +exports[`SessionBrowser Search and Navigation Components > NoResultsDisplay renders correctly 1`] = ` +" +No sessions found matching 'no match'. +" +`; + +exports[`SessionBrowser Search and Navigation Components > SearchModeDisplay renders correctly with query 1`] = ` +" +Search: test query (Esc to cancel) +" +`; + +exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly 1`] = ` +"Chat Sessions (10 total) sorted by date desc +" +`; + +exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly with filter 1`] = ` +"Chat Sessions (5 total, filtered) sorted by name asc +" +`; diff --git a/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserStates.test.tsx.snap b/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserStates.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..e5939219cb0e60d37632fe7859935aaf514eb447 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserStates.test.tsx.snap @@ -0,0 +1,18 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SessionBrowser UI States > SessionBrowserEmpty renders correctly 1`] = ` +" No auto-saved conversations found. + Press q to exit +" +`; + +exports[`SessionBrowser UI States > SessionBrowserError renders correctly 1`] = ` +" Error: Test error message + Press q to exit +" +`; + +exports[`SessionBrowser UI States > SessionBrowserLoading renders correctly 1`] = ` +" Loading sessions… +" +`; diff --git a/packages/cli/src/ui/components/SessionBrowser/utils.test.ts b/packages/cli/src/ui/components/SessionBrowser/utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6da97cc2088c2761f505b69e05634945ba168a2 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/utils.test.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { sortSessions, findTextMatches, filterSessions } from './utils.js'; +import type { SessionInfo } from '../../../utils/sessionUtils.js'; + +describe('SessionBrowser utils', () => { + const createTestSession = (overrides: Partial): SessionInfo => ({ + id: 'test-id', + file: 'test-file', + fileName: 'test-file.json', + startTime: '2025-01-01T10:00:00Z', + lastUpdated: '2025-01-01T10:00:00Z', + messageCount: 1, + displayName: 'Test Session', + firstUserMessage: 'Hello', + isCurrentSession: false, + index: 0, + ...overrides, + }); + + describe('sortSessions', () => { + it('sorts by date ascending/descending', () => { + const older = createTestSession({ + id: '1', + lastUpdated: '2025-01-01T10:00:00Z', + }); + const newer = createTestSession({ + id: '2', + lastUpdated: '2025-01-02T10:00:00Z', + }); + + const desc = sortSessions([older, newer], 'date', false); + expect(desc[0].id).toBe('2'); + + const asc = sortSessions([older, newer], 'date', true); + expect(asc[0].id).toBe('1'); + }); + + it('sorts by message count ascending/descending', () => { + const more = createTestSession({ id: '1', messageCount: 10 }); + const less = createTestSession({ id: '2', messageCount: 2 }); + + const desc = sortSessions([more, less], 'messages', false); + expect(desc[0].id).toBe('1'); + + const asc = sortSessions([more, less], 'messages', true); + expect(asc[0].id).toBe('2'); + }); + + it('sorts by name ascending/descending', () => { + const apple = createTestSession({ id: '1', displayName: 'Apple' }); + const banana = createTestSession({ id: '2', displayName: 'Banana' }); + + const asc = sortSessions([apple, banana], 'name', true); + expect(asc[0].id).toBe('2'); // Reversed alpha + + const desc = sortSessions([apple, banana], 'name', false); + expect(desc[0].id).toBe('1'); + }); + }); + + describe('findTextMatches', () => { + it('returns empty array if query is practically empty', () => { + expect( + findTextMatches([{ role: 'user', content: 'hello world' }], ' '), + ).toEqual([]); + }); + + it('finds simple matches with surrounding context', () => { + const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [ + { role: 'user', content: 'What is the capital of France?' }, + ]; + + const matches = findTextMatches(messages, 'capital'); + expect(matches.length).toBe(1); + expect(matches[0].match).toBe('capital'); + expect(matches[0].before.endsWith('the ')).toBe(true); + expect(matches[0].after.startsWith(' of')).toBe(true); + expect(matches[0].role).toBe('user'); + }); + + it('finds multiple matches in a single message', () => { + const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [ + { role: 'user', content: 'test here test there' }, + ]; + + const matches = findTextMatches(messages, 'test'); + expect(matches.length).toBe(2); + }); + }); + + describe('filterSessions', () => { + it('returns all sessions when query is blank and clears existing snippets', () => { + const sessions = [createTestSession({ id: '1', matchCount: 5 })]; + + const result = filterSessions(sessions, ' '); + expect(result.length).toBe(1); + expect(result[0].matchCount).toBeUndefined(); + }); + + it('filters by displayName', () => { + const session1 = createTestSession({ + id: '1', + displayName: 'Cats and Dogs', + }); + const session2 = createTestSession({ id: '2', displayName: 'Fish' }); + + const result = filterSessions([session1, session2], 'cat'); + expect(result.length).toBe(1); + expect(result[0].id).toBe('1'); + }); + + it('populates match snippets if it matches content inside messages array', () => { + const sessionWithMessages = createTestSession({ + id: '1', + displayName: 'Unrelated Title', + fullContent: 'This mentions a giraffe', + messages: [{ role: 'user', content: 'This mentions a giraffe' }], + }); + + const result = filterSessions([sessionWithMessages], 'giraffe'); + expect(result.length).toBe(1); + expect(result[0].matchCount).toBe(1); + expect(result[0].matchSnippets?.[0].match).toBe('giraffe'); + }); + }); +}); diff --git a/packages/cli/src/ui/components/SessionBrowser/utils.ts b/packages/cli/src/ui/components/SessionBrowser/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..40902656ad7b1c3c238e147a85c804da8e143cad --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/utils.ts @@ -0,0 +1,130 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + cleanMessage, + type SessionInfo, + type TextMatch, +} from '../../../utils/sessionUtils.js'; + +/** + * Sorts an array of sessions by the specified criteria. + * @param sessions - Array of sessions to sort + * @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName) + * @param reverse - Whether to reverse the sort order (ascending instead of descending) + * @returns New sorted array of sessions + */ +export const sortSessions = ( + sessions: SessionInfo[], + sortBy: 'date' | 'messages' | 'name', + reverse: boolean, +): SessionInfo[] => { + const sorted = [...sessions].sort((a, b) => { + switch (sortBy) { + case 'date': + return ( + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime() + ); + case 'messages': + return b.messageCount - a.messageCount; + case 'name': + return a.displayName.localeCompare(b.displayName); + default: + return 0; + } + }); + + return reverse ? sorted.reverse() : sorted; +}; + +/** + * Finds all text matches for a search query within conversation messages. + * Creates TextMatch objects with context (10 chars before/after) and role information. + * @param messages - Array of messages to search through + * @param query - Search query string (case-insensitive) + * @returns Array of TextMatch objects containing match context and metadata + */ +export const findTextMatches = ( + messages: Array<{ role: 'user' | 'assistant'; content: string }>, + query: string, +): TextMatch[] => { + if (!query.trim()) return []; + + const lowerQuery = query.toLowerCase(); + const matches: TextMatch[] = []; + + for (const message of messages) { + const m = cleanMessage(message.content); + const lowerContent = m.toLowerCase(); + let startIndex = 0; + + while (true) { + const matchIndex = lowerContent.indexOf(lowerQuery, startIndex); + if (matchIndex === -1) break; + + const contextStart = Math.max(0, matchIndex - 10); + const contextEnd = Math.min(m.length, matchIndex + query.length + 10); + + const snippet = m.slice(contextStart, contextEnd); + const relativeMatchStart = matchIndex - contextStart; + const relativeMatchEnd = relativeMatchStart + query.length; + + let before = snippet.slice(0, relativeMatchStart); + const match = snippet.slice(relativeMatchStart, relativeMatchEnd); + let after = snippet.slice(relativeMatchEnd); + + if (contextStart > 0) before = '…' + before; + if (contextEnd < m.length) after = after + '…'; + + matches.push({ before, match, after, role: message.role }); + startIndex = matchIndex + 1; + } + } + + return matches; +}; + +/** + * Filters sessions based on a search query, checking titles, IDs, and full content. + * Also populates matchSnippets and matchCount for sessions with content matches. + * @param sessions - Array of sessions to filter + * @param query - Search query string (case-insensitive) + * @returns Filtered array of sessions that match the query + */ +export const filterSessions = ( + sessions: SessionInfo[], + query: string, +): SessionInfo[] => { + if (!query.trim()) { + return sessions.map((session) => ({ + ...session, + matchSnippets: undefined, + matchCount: undefined, + })); + } + + const lowerQuery = query.toLowerCase(); + return sessions.filter((session) => { + const titleMatch = + session.displayName.toLowerCase().includes(lowerQuery) || + session.id.toLowerCase().includes(lowerQuery) || + session.firstUserMessage.toLowerCase().includes(lowerQuery); + + const contentMatch = session.fullContent + ?.toLowerCase() + .includes(lowerQuery); + + if (titleMatch || contentMatch) { + if (session.messages) { + session.matchSnippets = findTextMatches(session.messages, query); + session.matchCount = session.matchSnippets.length; + } + return true; + } + + return false; + }); +}; diff --git a/packages/cli/src/ui/components/__snapshots__/AdminSettingsChangedDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AdminSettingsChangedDialog.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..409e6180208bc51214e32107c7881b4125974afd --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/AdminSettingsChangedDialog.test.tsx.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AdminSettingsChangedDialog > renders correctly 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Admin settings have changed. Please restart the session to apply new settings. Press 'r' to │ +│ restart, or 'Ctrl+C' twice to exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..26a8b0a8e75c911092e02fd6b90a754ac0c2bd19 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap @@ -0,0 +1,147 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AlternateBufferQuittingDisplay > renders with a tool awaiting confirmation > with_confirming_tool 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v0.10.0 + + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results + +Action Required (was prompted): + +? confirming_tool Confirming tool description +" +`; + +exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v0.10.0 + + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results +╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool1 Description for tool 1 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool2 Description for tool 2 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────╮ +│ o tool3 Description for tool 3 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v0.10.0 + + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results +" +`; + +exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v0.10.0 + + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results +╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool1 Description for tool 1 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool2 Description for tool 2 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v0.10.0 + + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results +╭──────────────────────────────────────────────────────────────────────────╮ +│ o tool3 Description for tool 3 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v0.10.0 + + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + > Hello Gemini +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +✦ Hello User! +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..ee9ea5f70899a8af55e8ccb3e4f3b6cbbe06f1ba --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap @@ -0,0 +1,94 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > should not render the banner when no flags are set 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v1.0.0 + + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results +" +`; + +exports[` > should not render the default banner if shown count is 5 or more 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v1.0.0 + + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results +" +`; + +exports[` > should render the banner with default text 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v1.0.0 + + +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ This is the default banner │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results +" +`; + +exports[` > should render the banner with warning text 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v1.0.0 + + +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ There are capacity issues │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results +" +`; + +exports[` > should render the full logo when logged out 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v1.0.0 +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg new file mode 100644 index 0000000000000000000000000000000000000000..5c4c6426b786a85329bd522d4c3a1d3478145411 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg @@ -0,0 +1,34 @@ + + + + + ▝ + ▜ + ▄ + ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝ + ▜ + ▄ + █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗ + ▟ + ▀ + ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝ + ▀ + ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + Gemini CLI + v1.0.0 + Tips for getting started: + 1. Create + GEMINI.md + files to customize your interactions + 2. + /help + for more information + 3. Ask coding questions, edit code or run commands + 4. Be specific for the best results + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg new file mode 100644 index 0000000000000000000000000000000000000000..eaa118754f1d298d8dae04a438b1ea3acb67071d --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg @@ -0,0 +1,35 @@ + + + + + ▝ + ▜ + ▄ + ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝ + ▜ + ▄ + █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗ + ▟ + ▀ + ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▗ + ▟ + ▀ + ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + Gemini CLI + v1.0.0 + Tips for getting started: + 1. Create + GEMINI.md + files to customize your interactions + 2. + /help + for more information + 3. Ask coding questions, edit code or run commands + 4. Be specific for the best results + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..c8c4c53c8901969ad29a861b8e9b8dacc8f3b36e --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap @@ -0,0 +1,37 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AppHeader Icon Rendering > renders the default icon in standard terminals 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v1.0.0 + + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results" +`; + +exports[`AppHeader Icon Rendering > renders the symmetric icon in Apple Terminal 1`] = ` +" + ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▗▟▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ + + Gemini CLI v1.0.0 + + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..8ddb141478ad95ac3e397594d7c30518c4002ff3 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap @@ -0,0 +1,31 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode 1`] = ` +"auto-accept edits Shift+Tab to manual +" +`; + +exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode with plan enabled 1`] = ` +"auto-accept edits Shift+Tab to plan +" +`; + +exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode 1`] = ` +"Shift+Tab to accept edits +" +`; + +exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode with plan enabled 1`] = ` +"Shift+Tab to accept edits +" +`; + +exports[`ApprovalModeIndicator > renders correctly for PLAN mode 1`] = ` +"plan Shift+Tab to manual +" +`; + +exports[`ApprovalModeIndicator > renders correctly for YOLO mode 1`] = ` +"YOLO Ctrl+Y +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..3dcc3815aab15be2316d6a601b1087f524c3853c --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap @@ -0,0 +1,234 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 1`] = ` +"Select your preferred language: + + 1. TypeScript + 2. JavaScript +● 3. Enter a custom value + +Enter to submit · Esc to cancel +" +`; + +exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = ` +"Select your preferred language: + + 1. TypeScript + 2. JavaScript +● 3. Type another language... + +Enter to submit · Esc to cancel +" +`; + +exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = ` +"Choose an option + +▲ +● 1. Option 1 + Description 1 + 2. Option 2 + Description 2 + 3. Option 3 + Description 3 +▼ + +Enter to select · ↑/↓ to navigate · Esc to cancel +" +`; + +exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = ` +"Choose an option + +▲ +● 1. Option 1 + Description 1 + 2. Option 2 + Description 2 + 3. Option 3 + Description 3 +▼ + +Enter to select · ↑/↓ to navigate · Esc to cancel +" +`; + +exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = ` +"What should we name this component? + +> e.g., UserProfileCard + + +Enter to submit · Esc to cancel +" +`; + +exports[`AskUserDialog > Text type questions > shows correct keyboard hints for text type 1`] = ` +"Enter the variable name: + +> Enter your response + + +Enter to submit · Esc to cancel +" +`; + +exports[`AskUserDialog > Text type questions > shows default placeholder when none provided 1`] = ` +"Enter the database connection string: + +> Enter your response + + +Enter to submit · Esc to cancel +" +`; + +exports[`AskUserDialog > allows navigating to Review tab and back 1`] = ` +"← □ Tests │ □ Docs │ ≡ Review → + +Review your answers: + +⚠ You have 2 unanswered questions + +Tests → (not answered) +Docs → (not answered) + +Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel +" +`; + +exports[`AskUserDialog > hides progress header for single question 1`] = ` +"Which authentication method should we use? + +● 1. OAuth 2.0 + Industry standard, supports SSO + 2. JWT tokens + Stateless, good for APIs + 3. Enter a custom value + +Enter to select · ↑/↓ to navigate · Esc to cancel +" +`; + +exports[`AskUserDialog > indents multi-line descriptions correctly 1`] = ` +"Single choice? + +● 1. Option 1 + This is a very long description + that is expected to wrap onto + multiple lines in a narrow + terminal. We want to ensure that + all lines are correctly indented. + 2. Enter a custom value + +Enter to select · ↑/↓ to navigate · Esc +to cancel +" +`; + +exports[`AskUserDialog > indents multi-line descriptions correctly in multi-select mode 1`] = ` +"Multi-select? +(Select all that apply) + +● 1. [ ] Option 1 + This is a very long description + that is expected to wrap onto + multiple lines in a narrow + terminal. We want to ensure + that all lines are correctly + indented even with checkboxes. + 2. [ ] Enter a custom value + Done + Finish selection + +Enter to select · ↑/↓ to navigate · Esc +to cancel +" +`; + +exports[`AskUserDialog > renders question and options 1`] = ` +"Which authentication method should we use? + +● 1. OAuth 2.0 + Industry standard, supports SSO + 2. JWT tokens + Stateless, good for APIs + 3. Enter a custom value + +Enter to select · ↑/↓ to navigate · Esc to cancel +" +`; + +exports[`AskUserDialog > shows Review tab in progress header for multiple questions 1`] = ` +"← □ Framework │ □ Styling │ ≡ Review → + +Which framework? + +● 1. React + Component library + 2. Vue + Progressive framework + 3. Enter a custom value + +Enter to select · ←/→ to switch questions · Esc to cancel +" +`; + +exports[`AskUserDialog > shows keyboard hints 1`] = ` +"Which authentication method should we use? + +● 1. OAuth 2.0 + Industry standard, supports SSO + 2. JWT tokens + Stateless, good for APIs + 3. Enter a custom value + +Enter to select · ↑/↓ to navigate · Esc to cancel +" +`; + +exports[`AskUserDialog > shows progress header for multiple questions 1`] = ` +"← □ Database │ □ ORM │ ≡ Review → + +Which database should we use? + +● 1. PostgreSQL + Relational database + 2. MongoDB + Document database + 3. Enter a custom value + +Enter to select · ←/→ to switch questions · Esc to cancel +" +`; + +exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = ` +"← □ License │ □ README │ ≡ Review → + +Review your answers: + +⚠ You have 2 unanswered questions + +License → (not answered) +README → (not answered) + +Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel +" +`; + +exports[`AskUserDialog > verifies "All of the above" visual state with snapshot 1`] = ` +"Which features? +(Select all that apply) + + 1. [x] TypeScript + 2. [x] ESLint +● 3. [x] All of the above + Select all options + 4. [ ] Enter a custom value + Done + Finish selection + +Enter to select · ↑/↓ to navigate · Esc to cancel +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/BackgroundTaskDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/BackgroundTaskDisplay.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..4311f968dea1894bc1cc833163f6b23e23c822d5 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/BackgroundTaskDisplay.test.tsx.snap @@ -0,0 +1,87 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > highlights the focused state 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────┐ +│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │ +│ (Focused) (Ctrl+L) │ +│ Starting server... │ +│ Log: ~/.gemini/tmp/background-processes/background-1001.log │ +└──────────────────────────────────────────────────────────────────────────────┘ +" +`; + +exports[` > keeps exit code status color even when selected 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────┐ +│ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List │ +│ (Focused) (Ctrl+L) │ +│ │ +│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │ +│ │ +│ 1. npm start (PID: 1001) │ +│ 2. tail -f log.txt (PID: 1002) │ +│ ● 3. exit 0 (PID: 1003) (Exit Code: 0) │ +│ Log: ~/.gemini/tmp/background-processes/background-1003.log │ +└──────────────────────────────────────────────────────────────────────────────┘ +" +`; + +exports[` > keeps exit code status color even when selected 2`] = ` +"┌──────────────────────────────────────────────────────────────────────────────┐ +│ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List │ +│ (Focused) (Ctrl+L) │ +│ │ +│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │ +│ │ +│ 1. npm start (PID: 1001) │ +│ 2. tail -f log.txt (PID: 1002) │ +│ ● 3. exit 0 (PID: 1003) (Exit Code: 0) │ +│ Log: ~/.gemini/tmp/background-processes/background-1003.log │ +└──────────────────────────────────────────────────────────────────────────────┘ +" +`; + +exports[` > renders tabs for multiple shells 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │ +│ Starting server... │ +│ Log: ~/.gemini/tmp/background-processes/background-1001.log │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +" +`; + +exports[` > renders the output of the active shell 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────┐ +│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │ +│ Starting server... │ +│ Log: ~/.gemini/tmp/background-processes/background-1001.log │ +└──────────────────────────────────────────────────────────────────────────────┘ +" +`; + +exports[` > renders the process list when isListOpenProp is true 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────┐ +│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │ +│ (Focused) (Ctrl+L) │ +│ │ +│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │ +│ │ +│ ● 1. npm start (PID: 1001) │ +│ 2. tail -f log.txt (PID: 1002) │ +│ Log: ~/.gemini/tmp/background-processes/background-1001.log │ +└──────────────────────────────────────────────────────────────────────────────┘ +" +`; + +exports[` > scrolls to active shell when list opens 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────┐ +│ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List │ +│ (Focused) (Ctrl+L) │ +│ │ +│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │ +│ │ +│ 1. npm start (PID: 1001) │ +│ ● 2. tail -f log.txt (PID: 1002) │ +│ Log: ~/.gemini/tmp/background-processes/background-1002.log │ +└──────────────────────────────────────────────────────────────────────────────┘ +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/Banner-Banner-handles-newlines-in-text.snap.svg b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-handles-newlines-in-text.snap.svg new file mode 100644 index 0000000000000000000000000000000000000000..a6272e0fa90b25ca26bc4ab5e6a0838d7c7f38bf --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-handles-newlines-in-text.snap.svg @@ -0,0 +1,20 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────╮ + │ + L + i + n + e + 1 + │ + │ + Line 2 + │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-info-mode.snap.svg b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-info-mode.snap.svg new file mode 100644 index 0000000000000000000000000000000000000000..89d219005d744996e5307232bfe4998c2574b483 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-info-mode.snap.svg @@ -0,0 +1,23 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────╮ + │ + I + n + f + o + M + e + s + s + a + g + e + │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-multi-line-warning.snap.svg b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-multi-line-warning.snap.svg new file mode 100644 index 0000000000000000000000000000000000000000..6b3250fc6baf403b7ae0ed522678d84d389c69af --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-multi-line-warning.snap.svg @@ -0,0 +1,19 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────╮ + │ + Title Line + │ + │ + Body Line 1 + │ + │ + Body Line 2 + │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-warning-mode.snap.svg b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-warning-mode.snap.svg new file mode 100644 index 0000000000000000000000000000000000000000..4f3ee7472366a7e856813240056aa122ddfbceb0 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-warning-mode.snap.svg @@ -0,0 +1,13 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────╮ + │ + Warning Message + │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..6df246dede93a9e5193ad0d1b9f3fdfc9bd7881d --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap @@ -0,0 +1,28 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Banner > handles newlines in text 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Line 1 │ +│ Line 2 │ +╰──────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`Banner > renders in info mode 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Info Message │ +╰──────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`Banner > renders in multi-line warning 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Title Line │ +│ Body Line 1 │ +│ Body Line 2 │ +╰──────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`Banner > renders in warning mode 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Warning Message │ +╰──────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/Checklist.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Checklist.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..94dff26bb2d22e9d37917eb5be1be41ead9a1cbf --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Checklist.test.tsx.snap @@ -0,0 +1,24 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders expanded view correctly 1`] = ` +"──────────────────────────────────────────────────────────────────────────────────────────────────── + Test List 1/3 completed (toggle me) + + ✓ Task 1 + » Task 2 + ☐ Task 3 + ✗ Task 4 +" +`; + +exports[` > renders summary view correctly (collapsed) 1`] = ` +"──────────────────────────────────────────────────────────────────────────────────────────────────── + Test List 1/3 completed (toggle me) » Task 2 +" +`; + +exports[` > renders summary view without in-progress item if none exists 1`] = ` +"──────────────────────────────────────────────────────────────────────────────────────────────────── + Test List 1/2 completed +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/ChecklistItem.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ChecklistItem.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..80599ae8784854d3150d687cee78626cf330dbde --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ChecklistItem.test.tsx.snap @@ -0,0 +1,38 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders { status: 'blocked', label: 'Blocked this' } item correctly 1`] = ` +"⛔ Blocked this +" +`; + +exports[` > renders { status: 'cancelled', label: 'Skipped this' } item correctly 1`] = ` +"✗ Skipped this +" +`; + +exports[` > renders { status: 'completed', label: 'Done this' } item correctly 1`] = ` +"✓ Done this +" +`; + +exports[` > renders { status: 'in_progress', label: 'Doing this' } item correctly 1`] = ` +"» Doing this +" +`; + +exports[` > renders { status: 'pending', label: 'Do this' } item correctly 1`] = ` +"☐ Do this +" +`; + +exports[` > truncates long text when wrap="truncate" 1`] = ` +"» This is a very long text th… +" +`; + +exports[` > wraps long text by default 1`] = ` +"» This is a very long text + that should wrap because the + default behavior is wrapping +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..745347bc959fadf9b612c62d60551c691e1b666d --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap @@ -0,0 +1,45 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Composer > Snapshots > matches snapshot in idle state 1`] = ` +" + ? for shortcuts +──────────────────────────────────────────────────────────────────────────────────────────────────── + ApprovalModeIndicator: default StatusDisplay +InputPrompt: Type your message or @path/to/file +Footer +" +`; + +exports[`Composer > Snapshots > matches snapshot in minimal UI mode 1`] = ` +" press tab twice for more +InputPrompt: Type your message or @path/to/file +" +`; + +exports[`Composer > Snapshots > matches snapshot in minimal UI mode while loading 1`] = ` +"LoadingIndicator press tab twice for more +InputPrompt: Type your message or @path/to/file +" +`; + +exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = ` +" + ? for shortcuts +──────────────────────────────────────── + ApprovalModeIndicator: StatusDispl + default ay +InputPrompt: Type your message or +@path/to/file +Footer +" +`; + +exports[`Composer > Snapshots > matches snapshot while streaming 1`] = ` +" + LoadingIndicator: Thinking ? for shortcuts +──────────────────────────────────────────────────────────────────────────────────────────────────── + ApprovalModeIndicator: default StatusDisplay +InputPrompt: Type your message or @path/to/file +Footer +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..8358ec79189d3a979b209a2bc158ee768aea5469 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap @@ -0,0 +1,25 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ConfigInitDisplay > handles empty clients map 1`] = ` +" +Spinner Working... +" +`; + +exports[`ConfigInitDisplay > renders initial state 1`] = ` +" +Spinner Working... +" +`; + +exports[`ConfigInitDisplay > truncates list of waiting servers if too many 1`] = ` +" +Spinner Working... +" +`; + +exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = ` +" +Spinner Working... +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/ContextSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ContextSummaryDisplay.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..7330b89e4d040d24a2afa99ec560c2756474b5b7 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ContextSummaryDisplay.test.tsx.snap @@ -0,0 +1,16 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > should not render empty parts 1`] = ` +" 1 open file (F4 to view) +" +`; + +exports[` > should render on a single line on a wide screen 1`] = ` +" 1 open file (F4 to view) · 1 GEMINI.md file · 1 MCP server · 1 skill +" +`; + +exports[` > should render on multiple lines on a narrow screen 1`] = ` +" 1 open file (F4 to view) · 1 GEMINI.md file · 1 MCP server · 1 skill +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/DetailedMessagesDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/DetailedMessagesDisplay.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..a8454fd5716cdd44b6d37ca9978d1a14ba2e7da1 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/DetailedMessagesDisplay.test.tsx.snap @@ -0,0 +1,41 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DetailedMessagesDisplay > renders message counts 1`] = ` +" +╭──────────────────────────────────────────────────────────────────────────────╮ +│ Debug Console (F12 to close) │ +│ │ +│ ℹ Repeated message (x5) │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`DetailedMessagesDisplay > renders messages correctly 1`] = ` +" +╭──────────────────────────────────────────────────────────────────────────────╮ +│ Debug Console (F12 to close) │ +│ │ +│ ℹ Log message │ +│ ⚠ Warning message │ +│ ✖ Error message │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/EditorSettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/EditorSettingsDialog.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..3042d13ca02308a1ae2d0b936cca25a3fd8746c6 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/EditorSettingsDialog.test.tsx.snap @@ -0,0 +1,19 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`EditorSettingsDialog > renders correctly 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ > Select Editor Editor Preference │ +│ ● 1. VS Code │ +│ 2. Vim These editors are currently supported. Please note │ +│ that some editors cannot be used in sandbox mode. │ +│ Apply To │ +│ ● 1. User Settings Your preferred editor is: VS Code. │ +│ 2. Workspace Settings │ +│ │ +│ (Use Enter to select, Tab to change │ +│ focus, Esc to close) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/EmptyWalletDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/EmptyWalletDialog.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..c8ebd612af1032eb39a8c84b9a7b7cf9e31e3bd9 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/EmptyWalletDialog.test.tsx.snap @@ -0,0 +1,49 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`EmptyWalletDialog > rendering > should match snapshot with fallback available 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Usage limit reached for gemini-2.5-pro. │ +│ Access resets at 2:00 PM. │ +│ /stats model for usage details │ +│ /model to switch models. │ +│ /auth to switch to API key. │ +│ │ +│ To continue using this model now, purchase more AI Credits. │ +│ │ +│ Newly purchased AI credits may take a few minutes to update. │ +│ │ +│ How would you like to proceed? │ +│ │ +│ │ +│ ● 1. Get AI Credits - Open browser to purchase credits │ +│ 2. Switch to gemini-3-flash-preview │ +│ 3. Stop - Abort request │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`EmptyWalletDialog > rendering > should match snapshot without fallback 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Usage limit reached for gemini-2.5-pro. │ +│ /stats model for usage details │ +│ /model to switch models. │ +│ /auth to switch to API key. │ +│ │ +│ To continue using this model now, purchase more AI Credits. │ +│ │ +│ Newly purchased AI credits may take a few minutes to update. │ +│ │ +│ How would you like to proceed? │ +│ │ +│ │ +│ ● 1. Get AI Credits - Open browser to purchase credits │ +│ 2. Stop - Abort request │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..71acb9388cf267e1d3cab7ac95b1fbbbc69869e6 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap @@ -0,0 +1,248 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 1`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + + 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically +● 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool + 3. Type your feedback... + +Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel +" +`; + +exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + +● 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool + 3. Type your feedback... + +Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel +" +`; + +exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = ` +" Error reading plan: File not found +" +`; + +exports[`ExitPlanModeDialog > useAlternateBuffer: false > handles long plan content appropriately 1`] = ` +"Overview + +Implement a comprehensive authentication system with multiple providers. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add OAuth2 provider support in src/auth/providers/OAuth2Provider.ts + 5. Add SAML provider support in src/auth/providers/SAMLProvider.ts + 6. Add LDAP provider support in src/auth/providers/LDAPProvider.ts + 7. Create token refresh mechanism in src/auth/TokenManager.ts + 8. Add multi-factor authentication in src/auth/MFAService.ts +... last 22 lines hidden (Ctrl+O to show) ... + +● 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool + 3. Type your feedback... + +Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel +" +`; + +exports[`ExitPlanModeDialog > useAlternateBuffer: false > renders correctly with plan content 1`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + +● 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool + 3. Type your feedback... + +Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel +" +`; + +exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 1`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + + 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically +● 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool + 3. Type your feedback... + +Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel +" +`; + +exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + +● 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool + 3. Type your feedback... + +Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel +" +`; + +exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = ` +" Error reading plan: File not found +" +`; + +exports[`ExitPlanModeDialog > useAlternateBuffer: true > handles long plan content appropriately 1`] = ` +"Overview + +Implement a comprehensive authentication system with multiple providers. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add OAuth2 provider support in src/auth/providers/OAuth2Provider.ts + 5. Add SAML provider support in src/auth/providers/SAMLProvider.ts + 6. Add LDAP provider support in src/auth/providers/LDAPProvider.ts + 7. Create token refresh mechanism in src/auth/TokenManager.ts + 8. Add multi-factor authentication in src/auth/MFAService.ts + 9. Implement session timeout handling in src/auth/SessionManager.ts + 10. Add audit logging for auth events in src/auth/AuditLogger.ts + 11. Create user profile management in src/auth/UserProfile.ts + 12. Add role-based access control in src/auth/RBACService.ts + 13. Implement password policy enforcement in src/auth/PasswordPolicy.ts + 14. Add brute force protection in src/auth/BruteForceGuard.ts + 15. Create secure cookie handling in src/auth/CookieManager.ts + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + - src/routes/api.ts - Add auth endpoints + - src/middleware/cors.ts - Update CORS for auth headers + - src/utils/crypto.ts - Add encryption utilities + +Testing Strategy + + - Unit tests for each auth provider + - Integration tests for full auth flows + - Security penetration testing + - Load testing for session management + +● 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool + 3. Type your feedback... + +Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel +" +`; + +exports[`ExitPlanModeDialog > useAlternateBuffer: true > renders correctly with plan content 1`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + +● 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool + 3. Type your feedback... + +Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..77fe519c64f1116b9ae019eff99e28e74a85f10e --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap @@ -0,0 +1,45 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`