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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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[` > displays "Limit reached" message when remaining is 0 1`] = `
+" workspace (/directory) sandbox /model quota
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
+"
+`;
+
+exports[` > displays the usage indicator when usage is low 1`] = `
+" workspace (/directory) sandbox /model quota
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85% used
+"
+`;
+
+exports[` > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
+" workspace (/directory) sandbox /model context
+ ...me/more/directories/to/make/it/long no sandbox gemini-pro 14%
+"
+`;
+
+exports[` > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
+" workspace (/directory) sandbox /model context
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used
+"
+`;
+
+exports[` > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `
+" sandbox
+ no sandbox
+"
+`;
+
+exports[` > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
+
+exports[` > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `
+" workspace (/directory) sandbox
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox
+"
+`;
+
+exports[` > hides the usage indicator when usage is not near limit 1`] = `
+" workspace (/directory) sandbox /model quota
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15% used
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-highlights-the-active-item-in-the-preview.snap.svg b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-highlights-the-active-item-in-the-preview.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..9362b2a8f9127fa96bd9d6888f3a92c41e187ff4
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-highlights-the-active-item-in-the-preview.snap.svg
@@ -0,0 +1,172 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-renders-correctly-with-default-settings.snap.svg b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-renders-correctly-with-default-settings.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..6539150367eea880da3b7cef1249b25af40d2888
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-renders-correctly-with-default-settings.snap.svg
@@ -0,0 +1,167 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-updates-the-preview-when-Show-footer-labels-is-toggled-off.snap.svg b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-updates-the-preview-when-Show-footer-labels-is-toggled-off.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..d19be17e27726fb56ab8f1643d7e746dcf3b597f
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-updates-the-preview-when-Show-footer-labels-is-toggled-off.snap.svg
@@ -0,0 +1,157 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..452ae96d7c9e71b8c8779994fd4e0bbc09c69123
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap
@@ -0,0 +1,189 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > highlights the active item in the preview 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Configure Footer │
+│ │
+│ Select which items to display in the footer. │
+│ │
+│ [✓] workspace │
+│ Current working directory │
+│ [✓] git-branch │
+│ Current git branch name (not shown when unavailable) │
+│ [✓] sandbox │
+│ Sandbox type and trust indicator │
+│ [✓] model-name │
+│ Current model identifier │
+│ [✓] quota │
+│ Percentage of daily limit used (not shown when unavailable) │
+│ [ ] context-used │
+│ Percentage of context window used │
+│ [ ] memory-usage │
+│ Memory used by the application │
+│ [ ] session-id │
+│ Unique identifier for the current session │
+│ [ ] hostname │
+│ Current machine hostname │
+│ [ ] auth │
+│ Current authentication info │
+│ > [✓] code-changes │
+│ Lines added/removed in the session (not shown when zero) │
+│ [ ] token-count │
+│ Total tokens used in the session (not shown when zero) │
+│ [✓] Show footer labels │
+│ │
+│ Reset to default footer │
+│ │
+│ │
+│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
+│ │
+│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
+│ │ Preview: │ │
+│ │ workspace (/directory) branch sandbox /model quota diff │ │
+│ │ ~/project/path main docker gemini-2.5-pro 42% used +12 -4 │ │
+│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[` > renders correctly with default settings 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Configure Footer │
+│ │
+│ Select which items to display in the footer. │
+│ │
+│ > [✓] workspace │
+│ Current working directory │
+│ [✓] git-branch │
+│ Current git branch name (not shown when unavailable) │
+│ [✓] sandbox │
+│ Sandbox type and trust indicator │
+│ [✓] model-name │
+│ Current model identifier │
+│ [✓] quota │
+│ Percentage of daily limit used (not shown when unavailable) │
+│ [ ] context-used │
+│ Percentage of context window used │
+│ [ ] memory-usage │
+│ Memory used by the application │
+│ [ ] session-id │
+│ Unique identifier for the current session │
+│ [ ] hostname │
+│ Current machine hostname │
+│ [ ] auth │
+│ Current authentication info │
+│ [ ] code-changes │
+│ Lines added/removed in the session (not shown when zero) │
+│ [ ] token-count │
+│ Total tokens used in the session (not shown when zero) │
+│ [✓] Show footer labels │
+│ │
+│ Reset to default footer │
+│ │
+│ │
+│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
+│ │
+│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
+│ │ Preview: │ │
+│ │ workspace (/directory) branch sandbox /model quota │ │
+│ │ ~/project/path main docker gemini-2.5-pro 42% used │ │
+│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > renders correctly with default settings 2`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Configure Footer │
+│ │
+│ Select which items to display in the footer. │
+│ │
+│ > [✓] workspace │
+│ Current working directory │
+│ [✓] git-branch │
+│ Current git branch name (not shown when unavailable) │
+│ [✓] sandbox │
+│ Sandbox type and trust indicator │
+│ [✓] model-name │
+│ Current model identifier │
+│ [✓] quota │
+│ Percentage of daily limit used (not shown when unavailable) │
+│ [ ] context-used │
+│ Percentage of context window used │
+│ [ ] memory-usage │
+│ Memory used by the application │
+│ [ ] session-id │
+│ Unique identifier for the current session │
+│ [ ] hostname │
+│ Current machine hostname │
+│ [ ] auth │
+│ Current authentication info │
+│ [ ] code-changes │
+│ Lines added/removed in the session (not shown when zero) │
+│ [ ] token-count │
+│ Total tokens used in the session (not shown when zero) │
+│ [✓] Show footer labels │
+│ │
+│ Reset to default footer │
+│ │
+│ │
+│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
+│ │
+│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
+│ │ Preview: │ │
+│ │ workspace (/directory) branch sandbox /model quota │ │
+│ │ ~/project/path main docker gemini-2.5-pro 42% used │ │
+│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[` > updates the preview when Show footer labels is toggled off 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Configure Footer │
+│ │
+│ Select which items to display in the footer. │
+│ │
+│ [✓] workspace │
+│ Current working directory │
+│ [✓] git-branch │
+│ Current git branch name (not shown when unavailable) │
+│ [✓] sandbox │
+│ Sandbox type and trust indicator │
+│ [✓] model-name │
+│ Current model identifier │
+│ [✓] quota │
+│ Percentage of daily limit used (not shown when unavailable) │
+│ [ ] context-used │
+│ Percentage of context window used │
+│ [ ] memory-usage │
+│ Memory used by the application │
+│ [ ] session-id │
+│ Unique identifier for the current session │
+│ [ ] hostname │
+│ Current machine hostname │
+│ [ ] auth │
+│ Current authentication info │
+│ [ ] code-changes │
+│ Lines added/removed in the session (not shown when zero) │
+│ [ ] token-count │
+│ Total tokens used in the session (not shown when zero) │
+│ > [ ] Show footer labels │
+│ │
+│ Reset to default footer │
+│ │
+│ │
+│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
+│ │
+│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
+│ │ Preview: │ │
+│ │ ~/project/path · main · docker · gemini-2.5-pro · 42% used │ │
+│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..d237b30f99d3a49a0b9865efdbe500c4c459f63c
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap
@@ -0,0 +1,404 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > gemini items (alternateBuffer=false) > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
+"✦ Example code block:
+ 1 Line 1
+ 2 Line 2
+ 3 Line 3
+ 4 Line 4
+ 5 Line 5
+ 6 Line 6
+ 7 Line 7
+ 8 Line 8
+ 9 Line 9
+ 10 Line 10
+ 11 Line 11
+ 12 Line 12
+ 13 Line 13
+ 14 Line 14
+ 15 Line 15
+ 16 Line 16
+ 17 Line 17
+ 18 Line 18
+ 19 Line 19
+ 20 Line 20
+ 21 Line 21
+ 22 Line 22
+ 23 Line 23
+ 24 Line 24
+ 25 Line 25
+ 26 Line 26
+ 27 Line 27
+ 28 Line 28
+ 29 Line 29
+ 30 Line 30
+ 31 Line 31
+ 32 Line 32
+ 33 Line 33
+ 34 Line 34
+ 35 Line 35
+ 36 Line 36
+ 37 Line 37
+ 38 Line 38
+ 39 Line 39
+ 40 Line 40
+ 41 Line 41
+ 42 Line 42
+ 43 Line 43
+ 44 Line 44
+ 45 Line 45
+ 46 Line 46
+ 47 Line 47
+ 48 Line 48
+ 49 Line 49
+ 50 Line 50
+"
+`;
+
+exports[` > gemini items (alternateBuffer=false) > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
+" Example code block:
+ 1 Line 1
+ 2 Line 2
+ 3 Line 3
+ 4 Line 4
+ 5 Line 5
+ 6 Line 6
+ 7 Line 7
+ 8 Line 8
+ 9 Line 9
+ 10 Line 10
+ 11 Line 11
+ 12 Line 12
+ 13 Line 13
+ 14 Line 14
+ 15 Line 15
+ 16 Line 16
+ 17 Line 17
+ 18 Line 18
+ 19 Line 19
+ 20 Line 20
+ 21 Line 21
+ 22 Line 22
+ 23 Line 23
+ 24 Line 24
+ 25 Line 25
+ 26 Line 26
+ 27 Line 27
+ 28 Line 28
+ 29 Line 29
+ 30 Line 30
+ 31 Line 31
+ 32 Line 32
+ 33 Line 33
+ 34 Line 34
+ 35 Line 35
+ 36 Line 36
+ 37 Line 37
+ 38 Line 38
+ 39 Line 39
+ 40 Line 40
+ 41 Line 41
+ 42 Line 42
+ 43 Line 43
+ 44 Line 44
+ 45 Line 45
+ 46 Line 46
+ 47 Line 47
+ 48 Line 48
+ 49 Line 49
+ 50 Line 50
+"
+`;
+
+exports[` > gemini items (alternateBuffer=false) > should render a truncated gemini item 1`] = `
+"✦ Example code block:
+ ... 42 hidden (Ctrl+O) ...
+ 43 Line 43
+ 44 Line 44
+ 45 Line 45
+ 46 Line 46
+ 47 Line 47
+ 48 Line 48
+ 49 Line 49
+ 50 Line 50
+"
+`;
+
+exports[` > gemini items (alternateBuffer=false) > should render a truncated gemini_content item 1`] = `
+" Example code block:
+ ... 42 hidden (Ctrl+O) ...
+ 43 Line 43
+ 44 Line 44
+ 45 Line 45
+ 46 Line 46
+ 47 Line 47
+ 48 Line 48
+ 49 Line 49
+ 50 Line 50
+"
+`;
+
+exports[` > gemini items (alternateBuffer=true) > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
+"✦ Example code block:
+ 1 Line 1
+ 2 Line 2
+ 3 Line 3
+ 4 Line 4
+ 5 Line 5
+ 6 Line 6
+ 7 Line 7
+ 8 Line 8
+ 9 Line 9
+ 10 Line 10
+ 11 Line 11
+ 12 Line 12
+ 13 Line 13
+ 14 Line 14
+ 15 Line 15
+ 16 Line 16
+ 17 Line 17
+ 18 Line 18
+ 19 Line 19
+ 20 Line 20
+ 21 Line 21
+ 22 Line 22
+ 23 Line 23
+ 24 Line 24
+ 25 Line 25
+ 26 Line 26
+ 27 Line 27
+ 28 Line 28
+ 29 Line 29
+ 30 Line 30
+ 31 Line 31
+ 32 Line 32
+ 33 Line 33
+ 34 Line 34
+ 35 Line 35
+ 36 Line 36
+ 37 Line 37
+ 38 Line 38
+ 39 Line 39
+ 40 Line 40
+ 41 Line 41
+ 42 Line 42
+ 43 Line 43
+ 44 Line 44
+ 45 Line 45
+ 46 Line 46
+ 47 Line 47
+ 48 Line 48
+ 49 Line 49
+ 50 Line 50
+"
+`;
+
+exports[` > gemini items (alternateBuffer=true) > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
+" Example code block:
+ 1 Line 1
+ 2 Line 2
+ 3 Line 3
+ 4 Line 4
+ 5 Line 5
+ 6 Line 6
+ 7 Line 7
+ 8 Line 8
+ 9 Line 9
+ 10 Line 10
+ 11 Line 11
+ 12 Line 12
+ 13 Line 13
+ 14 Line 14
+ 15 Line 15
+ 16 Line 16
+ 17 Line 17
+ 18 Line 18
+ 19 Line 19
+ 20 Line 20
+ 21 Line 21
+ 22 Line 22
+ 23 Line 23
+ 24 Line 24
+ 25 Line 25
+ 26 Line 26
+ 27 Line 27
+ 28 Line 28
+ 29 Line 29
+ 30 Line 30
+ 31 Line 31
+ 32 Line 32
+ 33 Line 33
+ 34 Line 34
+ 35 Line 35
+ 36 Line 36
+ 37 Line 37
+ 38 Line 38
+ 39 Line 39
+ 40 Line 40
+ 41 Line 41
+ 42 Line 42
+ 43 Line 43
+ 44 Line 44
+ 45 Line 45
+ 46 Line 46
+ 47 Line 47
+ 48 Line 48
+ 49 Line 49
+ 50 Line 50
+"
+`;
+
+exports[` > gemini items (alternateBuffer=true) > should render a truncated gemini item 1`] = `
+"✦ Example code block:
+ 1 Line 1
+ 2 Line 2
+ 3 Line 3
+ 4 Line 4
+ 5 Line 5
+ 6 Line 6
+ 7 Line 7
+ 8 Line 8
+ 9 Line 9
+ 10 Line 10
+ 11 Line 11
+ 12 Line 12
+ 13 Line 13
+ 14 Line 14
+ 15 Line 15
+ 16 Line 16
+ 17 Line 17
+ 18 Line 18
+ 19 Line 19
+ 20 Line 20
+ 21 Line 21
+ 22 Line 22
+ 23 Line 23
+ 24 Line 24
+ 25 Line 25
+ 26 Line 26
+ 27 Line 27
+ 28 Line 28
+ 29 Line 29
+ 30 Line 30
+ 31 Line 31
+ 32 Line 32
+ 33 Line 33
+ 34 Line 34
+ 35 Line 35
+ 36 Line 36
+ 37 Line 37
+ 38 Line 38
+ 39 Line 39
+ 40 Line 40
+ 41 Line 41
+ 42 Line 42
+ 43 Line 43
+ 44 Line 44
+ 45 Line 45
+ 46 Line 46
+ 47 Line 47
+ 48 Line 48
+ 49 Line 49
+ 50 Line 50
+"
+`;
+
+exports[` > gemini items (alternateBuffer=true) > should render a truncated gemini_content item 1`] = `
+" Example code block:
+ 1 Line 1
+ 2 Line 2
+ 3 Line 3
+ 4 Line 4
+ 5 Line 5
+ 6 Line 6
+ 7 Line 7
+ 8 Line 8
+ 9 Line 9
+ 10 Line 10
+ 11 Line 11
+ 12 Line 12
+ 13 Line 13
+ 14 Line 14
+ 15 Line 15
+ 16 Line 16
+ 17 Line 17
+ 18 Line 18
+ 19 Line 19
+ 20 Line 20
+ 21 Line 21
+ 22 Line 22
+ 23 Line 23
+ 24 Line 24
+ 25 Line 25
+ 26 Line 26
+ 27 Line 27
+ 28 Line 28
+ 29 Line 29
+ 30 Line 30
+ 31 Line 31
+ 32 Line 32
+ 33 Line 33
+ 34 Line 34
+ 35 Line 35
+ 36 Line 36
+ 37 Line 37
+ 38 Line 38
+ 39 Line 39
+ 40 Line 40
+ 41 Line 41
+ 42 Line 42
+ 43 Line 43
+ 44 Line 44
+ 45 Line 45
+ 46 Line 46
+ 47 Line 47
+ 48 Line 48
+ 49 Line 49
+ 50 Line 50
+"
+`;
+
+exports[` > renders AgentsStatus for "agents_list" type 1`] = `
+"Local Agents
+
+ - Local Agent (local_agent)
+ Local agent description.
+ Second line.
+
+Remote Agents
+
+ - remote_agent
+ Remote agent description.
+"
+`;
+
+exports[` > renders InfoMessage for "info" type with multi-line text (alternateBuffer=false) 1`] = `
+"
+ℹ ⚡ Line 1
+ ⚡ Line 2
+ ⚡ Line 3
+"
+`;
+
+exports[` > renders InfoMessage for "info" type with multi-line text (alternateBuffer=true) 1`] = `
+"
+ℹ ⚡ Line 1
+ ⚡ Line 2
+ ⚡ Line 3
+"
+`;
+
+exports[` > thinking items > renders "Thinking..." header when isFirstThinking is true 1`] = `
+" Thinking...
+ │
+ │ Thinking
+ │ test
+"
+`;
+
+exports[` > thinking items > renders thinking item when enabled 1`] = `
+" │
+ │ Thinking
+ │ test
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay--HookStatusDisplay-matches-SVG-snapshot-for-single-hook.snap.svg b/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay--HookStatusDisplay-matches-SVG-snapshot-for-single-hook.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..7c9cc6473cb9c6f18099fd60c6567d8c7ea61481
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay--HookStatusDisplay-matches-SVG-snapshot-for-single-hook.snap.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..5e04b96cb8497ca2ab1813af41a0620046041524
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay.test.tsx.snap
@@ -0,0 +1,18 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > matches SVG snapshot for single hook 1`] = `"Executing Hook: test-hook"`;
+
+exports[` > should render a single executing hook 1`] = `
+"Executing Hook: test-hook
+"
+`;
+
+exports[` > should render multiple executing hooks 1`] = `
+"Executing Hooks: h1, h2
+"
+`;
+
+exports[` > should render sequential hook progress 1`] = `
+"Executing Hook: step (1/3)
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/HooksDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HooksDialog.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..8da063afe532ff4e55062d77a1bf9e844ba7de24
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/HooksDialog.test.tsx.snap
@@ -0,0 +1,162 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`HooksDialog > snapshots > renders empty hooks dialog 1`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ No hooks configured. │
+│ │
+│ (Press Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`HooksDialog > snapshots > renders hook using command as name when name is not provided 1`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Security Warning: │
+│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
+│ Review hook scripts carefully. │
+│ │
+│ Learn more: https://geminicli.com/docs/hooks │
+│ │
+│ Configured Hooks │
+│ │
+│ before-tool │
+│ │
+│ echo hello [enabled] │
+│ Source: /mock/path │
+│ │
+│ │
+│ Tip: Use /hooks enable or /hooks disable to toggle individual hooks. Use │
+│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
+│ │
+│ (Press Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`HooksDialog > snapshots > renders hook with all metadata (matcher, sequential, timeout) 1`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Security Warning: │
+│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
+│ Review hook scripts carefully. │
+│ │
+│ Learn more: https://geminicli.com/docs/hooks │
+│ │
+│ Configured Hooks │
+│ │
+│ before-tool │
+│ │
+│ my-hook [enabled] │
+│ A hook with all metadata fields │
+│ Source: /mock/path/GEMINI.md | Matcher: shell_exec | Sequential | Timeout: 30s │
+│ │
+│ │
+│ Tip: Use /hooks enable or /hooks disable to toggle individual hooks. Use │
+│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
+│ │
+│ (Press Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`HooksDialog > snapshots > renders hook with all metadata (matcher, sequential, timeout) 2`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Security Warning: │
+│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
+│ Review hook scripts carefully. │
+│ │
+│ Learn more: https://geminicli.com/docs/hooks │
+│ │
+│ Configured Hooks │
+│ │
+│ before-tool │
+│ │
+│ my-hook [enabled] │
+│ A hook with all metadata fields │
+│ Source: /mock/path/GEMINI.md | Matcher: shell_exec | Sequential | Timeout: 30s │
+│ │
+│ │
+│ Tip: Use /hooks enable or /hooks disable to toggle individual hooks. Use │
+│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
+│ │
+│ (Press Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`HooksDialog > snapshots > renders hooks grouped by event name with enabled and disabled status 1`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Security Warning: │
+│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
+│ Review hook scripts carefully. │
+│ │
+│ Learn more: https://geminicli.com/docs/hooks │
+│ │
+│ Configured Hooks │
+│ │
+│ before-tool │
+│ │
+│ hook1 [enabled] │
+│ Test hook: hook1 │
+│ Source: /mock/path/GEMINI.md | Command: run-hook1 │
+│ │
+│ hook2 [disabled] │
+│ Test hook: hook2 │
+│ Source: /mock/path/GEMINI.md | Command: run-hook2 │
+│ │
+│ after-agent │
+│ │
+│ hook3 [enabled] │
+│ Test hook: hook3 │
+│ Source: /mock/path/GEMINI.md | Command: run-hook3 │
+│ │
+│ │
+│ Tip: Use /hooks enable or /hooks disable to toggle individual hooks. Use │
+│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
+│ │
+│ (Press Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`HooksDialog > snapshots > renders single hook with security warning, source, and tips 1`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Security Warning: │
+│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
+│ Review hook scripts carefully. │
+│ │
+│ Learn more: https://geminicli.com/docs/hooks │
+│ │
+│ Configured Hooks │
+│ │
+│ before-tool │
+│ │
+│ test-hook [enabled] │
+│ Test hook: test-hook │
+│ Source: /mock/path/GEMINI.md | Command: run-test-hook │
+│ │
+│ │
+│ Tip: Use /hooks enable or /hooks disable to toggle individual hooks. Use │
+│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
+│ │
+│ (Press Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/IDEContextDetailDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/IDEContextDetailDisplay.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..dd659db091e3a98117f2495184da3ad9a8056855
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/IDEContextDetailDisplay.test.tsx.snap
@@ -0,0 +1,24 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`IDEContextDetailDisplay > handles duplicate basenames by showing path hints 1`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ VS Code Context (ctrl+g to toggle) │
+│ │
+│ Open files: │
+│ - bar.txt (/foo) (active) │
+│ - bar.txt (/qux) │
+│ - unique.txt │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`IDEContextDetailDisplay > renders a list of open files with active status 1`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ VS Code Context (ctrl+g to toggle) │
+│ │
+│ Open files: │
+│ - bar.txt (active) │
+│ - baz.txt │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-multi-line-scenarios-should-display-cursor-correctly-at-the-beginning-of-a-line-in-a-multiline-block.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-multi-line-scenarios-should-display-cursor-correctly-at-the-beginning-of-a-line-in-a-multiline-block.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..642bc59c8464056c1a77f2a56bc1c24588190b29
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-multi-line-scenarios-should-display-cursor-correctly-at-the-beginning-of-a-line-in-a-multiline-block.snap.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-multi-line-scenarios-should-display-cursor-correctly-at-the-end-of-a-line-in-a-multiline-block.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-multi-line-scenarios-should-display-cursor-correctly-at-the-end-of-a-line-in-a-multiline-block.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..3205c6c8944492182c2256015ca76d71386fc92f
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-multi-line-scenarios-should-display-cursor-correctly-at-the-end-of-a-line-in-a-multiline-block.snap.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-multi-line-scenarios-should-display-cursor-correctly-in-the-middle-of-a-line-in-a-multiline-block.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-multi-line-scenarios-should-display-cursor-correctly-in-the-middle-of-a-line-in-a-multiline-block.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..0c81aea4e48a546b3d781bec541cad11193af4e5
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-multi-line-scenarios-should-display-cursor-correctly-in-the-middle-of-a-line-in-a-multiline-block.snap.svg
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-multi-line-scenarios-should-display-cursor-on-a-blank-line-in-a-multiline-block.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-multi-line-scenarios-should-display-cursor-on-a-blank-line-in-a-multiline-block.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..4c64a915c22dd7cc18e088a66620eeeb6727bb4e
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-multi-line-scenarios-should-display-cursor-on-a-blank-line-in-a-multiline-block.snap.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-after-multi-byte-unicode-characters-.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-after-multi-byte-unicode-characters-.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..7e9fea0a16261787d759852824c5aefc1917021d
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-after-multi-byte-unicode-characters-.snap.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-beginning-of-the-line-.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-beginning-of-the-line-.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..1dc10e142eef57eddf8bdf5a1627f2c667c28fdf
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-beginning-of-the-line-.snap.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-a-line-with-unicode-cha-.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-a-line-with-unicode-cha-.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..1f9e1103fbe933501fc95e411d4ab4aacb216584
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-a-line-with-unicode-cha-.snap.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-a-short-line-with-unico-.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-a-short-line-with-unico-.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..1eec5aa08e09799b559dece7df816dfa05da3034
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-a-short-line-with-unico-.snap.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-the-line-.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-the-line-.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..e29648f655f5b7e9f199b021ad2b01a17e04ddb5
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-the-line-.snap.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-the-line-2.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-the-line-2.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..2f9e66a77a85f7609016d5170f3c531312dddd93
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-at-the-end-of-the-line-2.snap.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-for-multi-byte-unicode-characters-.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-for-multi-byte-unicode-characters-.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..dc432f9b272793e4836e9b669a26f19e1e3013b1
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-for-multi-byte-unicode-characters-.snap.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-mid-word-.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-mid-word-.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..4eebe4398e605025d1fe06473f9459a48d08455e
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-mid-word-.snap.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-on-a-highlighted-token-.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-on-a-highlighted-token-.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..19e63ba44f52e2965f922d4bbb5b43aab2676d90
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-on-a-highlighted-token-.snap.svg
@@ -0,0 +1,16 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-on-a-space-between-words-.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-on-a-space-between-words-.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..eb37c0087788d8dc8227096aebc40add6aa5d3e0
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-on-a-space-between-words-.snap.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-on-an-empty-line-.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-on-an-empty-line-.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..d6833e1f018e346caff6693d7844623f6736a377
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-Highlighting-and-Cursor-Display-single-line-scenarios-should-display-cursor-correctly-on-an-empty-line-.snap.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-multiline-rendering-should-correctly-render-multiline-input-including-blank-lines.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-multiline-rendering-should-correctly-render-multiline-input-including-blank-lines.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..4415f9a82e08fe169cca48906450f518f6af9711
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-multiline-rendering-should-correctly-render-multiline-input-including-blank-lines.snap.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-snapshots-should-not-show-inverted-cursor-when-shell-is-focused.snap.svg b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-snapshots-should-not-show-inverted-cursor-when-shell-is-focused.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..c6b962237133e478e2bb4e2afd2f386f8f24e118
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt-InputPrompt-snapshots-should-not-show-inverted-cursor-when-shell-is-focused.snap.svg
@@ -0,0 +1,16 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..6d09c86ee59d4c57e3d53f2341a721edc3921e04
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
@@ -0,0 +1,203 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the beginning of a line' in a multiline block 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > first line
+ second line
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the end of a line' in a multiline block 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > first line
+ second line
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'in the middle of a line' in a multiline block 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > first line
+ second line
+ third line
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor on a blank line in a multiline block 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > first line
+
+ third line
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'after multi-byte unicode characters' 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > 👍A
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the beginning of the line' 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > hello
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a line with unicode cha…' 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > hello 👍
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a short line with unico…' 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > 👍
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of the line' 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > hello
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'for multi-byte unicode characters' 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > hello 👍 world
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'mid-word' 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > hello world
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a highlighted token' 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > run @path/to/file
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a space between words' 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > hello world
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on an empty line' 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > Type your message or @path/to/file
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > second message
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+"
+`;
+
+exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ (r:) Type your message or @path/to/file
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
+ lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
+ ...
+"
+`;
+
+exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-expanded-match 1`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ (r:) Type your message or @path/to/file
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
+ lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
+ llllllllllllllllllllllllllllllllllllllllllllllllll
+"
+`;
+
+exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ (r:) commit
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ git commit -m "feat: add search" in src/app
+"
+`;
+
+exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ (r:) commit
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ git commit -m "feat: add search" in src/app
+"
+`;
+
+exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > [Image ...reenshot2x.png]
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+"
+`;
+
+exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > @/path/to/screenshots/screenshot2x.png
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+"
+`;
+
+exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 1`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > [Pasted Text: 10 lines]
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+"
+`;
+
+exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > [Pasted Text: 10 lines]
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+"
+`;
+
+exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ > hello
+
+ world
+────────────────────────────────────────────────────────────────────────────────────────────────────"
+`;
+
+exports[`InputPrompt > should still allow input when shell is not focused 1`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > Type your message or @path/to/file
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"
+`;
+
+exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > Type your message or @path/to/file
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"
+`;
+
+exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ ! Type your message or @path/to/file
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+"
+`;
+
+exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ * Type your message or @path/to/file
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+"
+`;
+
+exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
+"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > Type your message or @path/to/file
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..666525e72052e9ebb8a941c4425c158e36e86d9a
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap
@@ -0,0 +1,7 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > should truncate long primary text instead of wrapping 1`] = `
+"MockRespondin This is an extremely long loading phrase that shoul…(esc to
+gSpinner cancel, 5s)
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/LoopDetectionConfirmation.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/LoopDetectionConfirmation.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..e8914dceeb11526be6f6c43132f7d60979e5e47d
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/LoopDetectionConfirmation.test.tsx.snap
@@ -0,0 +1,14 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`LoopDetectionConfirmation > renders correctly 1`] = `
+" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │ ? A potential loop was detected │
+ │ │
+ │ This can happen due to repetitive tool calls or other model behavior. Do you want to keep loop │
+ │ detection enabled or disable it for this session? │
+ │ │
+ │ ● 1. Keep loop detection enabled (esc) │
+ │ 2. Disable loop detection for this session │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..298c0a6ad4faa3a16c801721031fab0b569716e2
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg
@@ -0,0 +1,40 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..9090335b03eb3760d3e62573937a0f5c76615cdd
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
@@ -0,0 +1,236 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Focused shell should expand' 1`] = `
+"ScrollableList
+AppHeader(full)
+
+╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+│ ⠋ Shell Command Running a long command... │
+│ │
+│ Line 11 │
+│ Line 12 │
+│ Line 13 │
+│ Line 14 │
+│ Line 15 │
+│ Line 16 █ │
+│ Line 17 █ │
+│ Line 18 █ │
+│ Line 19 █ │
+│ Line 20 █ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocused shell' 1`] = `
+"ScrollableList
+AppHeader(full)
+
+╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+│ ⠋ Shell Command Running a long command... │
+│ │
+│ Line 11 │
+│ Line 12 │
+│ Line 13 │
+│ Line 14 │
+│ Line 15 │
+│ Line 16 █ │
+│ Line 17 █ │
+│ Line 18 █ │
+│ Line 19 █ │
+│ Line 20 █ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = `
+"AppHeader(full)
+
+╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+│ ⠋ Shell Command Running a long command... │
+│ │
+│ ... first 11 lines hidden (Ctrl+O to show) ... │
+│ Line 12 │
+│ Line 13 │
+│ Line 14 │
+│ Line 15 │
+│ Line 16 │
+│ Line 17 │
+│ Line 18 │
+│ Line 19 │
+│ Line 20 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = `
+"AppHeader(full)
+
+╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+│ ⠋ Shell Command Running a long command... │
+│ │
+│ Line 1 │
+│ Line 2 │
+│ Line 3 │
+│ Line 4 │
+│ Line 5 │
+│ Line 6 │
+│ Line 7 │
+│ Line 8 │
+│ Line 9 │
+│ Line 10 │
+│ Line 11 │
+│ Line 12 │
+│ Line 13 │
+│ Line 14 │
+│ Line 15 │
+│ Line 16 │
+│ Line 17 │
+│ Line 18 │
+│ Line 19 │
+│ Line 20 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`MainContent > renders a ToolConfirmationQueue without an extra line when preceded by hidden tools 1`] = `
+"AppHeader(full)
+▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > Apply plan
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+
+╭──────────────────────────────────────────────────────────────────────────────╮
+│ Ready to start implementation? │
+│ │
+│ Error reading plan: File not found: /path/to/plan │
+╰──────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`MainContent > renders a split tool group without a gap between static and pending areas 1`] = `
+"AppHeader(full)
+
+╭──────────────────────────────────────────────────────────────────────────╮
+│ ✓ test-tool A tool for testing │
+│ │
+│ Part 1 │
+│ │
+│ ✓ test-tool A tool for testing │
+│ │
+│ Part 2 │
+╰──────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`MainContent > renders a spurious line when a tool group has only hidden tools and borderBottom true 1`] = `
+"AppHeader(full)
+▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > Apply plan
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+"
+`;
+
+exports[`MainContent > renders a subagent with a complete box including bottom border 1`] = `
+"AppHeader(full)
+▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > Investigate
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+
+╭──────────────────────────────────────────────────────────────────────────╮
+│ ≡ Running Agent... (ctrl+o to collapse) │
+│ │
+│ Running subagent codebase_investigator... │
+│ │
+│ ⠋ run_shell_command echo hello │
+│ │
+╰──────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`MainContent > renders mixed history items (user + gemini) with single line padding between them 1`] = `
+"ScrollableList
+AppHeader(full)
+▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > User message
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+✦ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+"
+`;
+
+exports[`MainContent > renders multiple history items with single line padding between them 1`] = `
+"ScrollableList
+AppHeader(full)
+✦ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+
+✦ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+"
+`;
+
+exports[`MainContent > renders multiple thinking messages sequentially correctly 1`] = `
+"ScrollableList
+AppHeader(full)
+▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > Plan a solution
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ Thinking...
+ │
+ │ Initial analysis
+ │ This is a multiple line paragraph for the first thinking message of how the
+ │ model analyzes the problem.
+ │
+ │ Planning execution
+ │ This a second multiple line paragraph for the second thinking message
+ │ explaining the plan in detail so that it wraps around the terminal display.
+ │
+ │ Refining approach
+ │ And finally a third multiple line paragraph for the third thinking message to
+ │ refine the solution.
+"
+`;
+
+exports[`MainContent > renders multiple thinking messages sequentially correctly 2`] = `
+"ScrollableList
+AppHeader(full)
+▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ > Plan a solution
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ Thinking...
+ │
+ │ Initial analysis
+ │ This is a multiple line paragraph for the first thinking message of how the
+ │ model analyzes the problem.
+ │
+ │ Planning execution
+ │ This a second multiple line paragraph for the second thinking message
+ │ explaining the plan in detail so that it wraps around the terminal display.
+ │
+ │ Refining approach
+ │ And finally a third multiple line paragraph for the third thinking message to
+ │ refine the solution."
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ModelQuotaDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ModelQuotaDisplay.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..cc601cffbd49ff67d4884200ec5c2b585faa78ca
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ModelQuotaDisplay.test.tsx.snap
@@ -0,0 +1,10 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > renders quota information when buckets are provided 1`] = `
+"
+────────────────────────────────────────────────────────────────────────────────────────────────────
+Model usage
+
+Pro ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 25% Resets: 1:30 PM (1h 30m)
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..6830e10c2abddb6da80b1c139009b138e6f53809
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap
@@ -0,0 +1,240 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > should display a single model correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ │
+│ Metric gemini-2.5-pro │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 1 │
+│ Errors 0 (0.0%) │
+│ Avg Latency 100ms │
+│ Tokens │
+│ Total 30 │
+│ ↳ Input 5 │
+│ ↳ Cache Reads 5 (50.0%) │
+│ ↳ Thoughts 2 │
+│ ↳ Tool 1 │
+│ ↳ Output 20 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should display conditional rows if at least one model has data 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ │
+│ Metric gemini-2.5-pro gemini-2.5-flash │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 1 1 │
+│ Errors 0 (0.0%) 0 (0.0%) │
+│ Avg Latency 100ms 50ms │
+│ Tokens │
+│ Total 30 15 │
+│ ↳ Input 5 5 │
+│ ↳ Cache Reads 5 (50.0%) 0 (0.0%) │
+│ ↳ Thoughts 2 0 │
+│ ↳ Tool 0 3 │
+│ ↳ Output 20 10 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should display role breakdown correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ │
+│ Metric gemini-2.5-pro │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 2 │
+│ Errors 0 (0.0%) │
+│ Avg Latency 100ms │
+│ Tokens │
+│ Total 70 │
+│ ↳ Input 20 │
+│ ↳ Cache Reads 10 (33.3%) │
+│ ↳ Output 40 │
+│ Roles │
+│ main │
+│ ↳ Requests 1 │
+│ ↳ Input 10 │
+│ ↳ Output 20 │
+│ ↳ Cache Reads 5 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should display stats for multiple models correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ │
+│ Metric gemini-2.5-pro gemini-2.5-flash │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 10 20 │
+│ Errors 1 (10.0%) 2 (10.0%) │
+│ Avg Latency 100ms 25ms │
+│ Tokens │
+│ Total 300 600 │
+│ ↳ Input 50 100 │
+│ ↳ Cache Reads 50 (50.0%) 100 (50.0%) │
+│ ↳ Thoughts 10 20 │
+│ ↳ Tool 5 10 │
+│ ↳ Output 200 400 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should filter out invalid role names 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ │
+│ Metric gemini-2.5-pro │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 1 │
+│ Errors 0 (0.0%) │
+│ Avg Latency 100ms │
+│ Tokens │
+│ Total 30 │
+│ ↳ Input 10 │
+│ ↳ Output 20 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should handle large values without wrapping or overlapping 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ │
+│ Metric gemini-2.5-pro │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 999,999,999 │
+│ Errors 123,456,789 (12.3%) │
+│ Avg Latency 0ms │
+│ Tokens │
+│ Total 999,999,999 │
+│ ↳ Input 864,197,532 │
+│ ↳ Cache Reads 123,456,789 (12.5%) │
+│ ↳ Thoughts 111,111,111 │
+│ ↳ Tool 222,222,222 │
+│ ↳ Output 123,456,789 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should handle long role name layout 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ │
+│ Metric gemini-2.5-pro │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 1 │
+│ Errors 0 (0.0%) │
+│ Avg Latency 100ms │
+│ Tokens │
+│ Total 30 │
+│ ↳ Input 10 │
+│ ↳ Output 20 │
+│ Roles │
+│ utility_loop_detector │
+│ ↳ Requests 1 │
+│ ↳ Input 10 │
+│ ↳ Output 20 │
+│ ↳ Cache Reads 0 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should handle models with long names (gemini-3-*-preview) without layout breaking 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Auto (Gemini 3) Stats For Nerds │
+│ │
+│ │
+│ Metric gemini-3-pro-preview gemini-3-flash-preview │
+│ ────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 10 20 │
+│ Errors 0 (0.0%) 0 (0.0%) │
+│ Avg Latency 200ms 50ms │
+│ Tokens │
+│ Total 6,000 12,000 │
+│ ↳ Input 1,000 2,000 │
+│ ↳ Cache Reads 500 (25.0%) 1,000 (25.0%) │
+│ ↳ Thoughts 100 200 │
+│ ↳ Tool 50 100 │
+│ ↳ Output 4,000 8,000 │
+╰──────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should not display conditional rows if no model has data for them 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ │
+│ Metric gemini-2.5-pro │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 1 │
+│ Errors 0 (0.0%) │
+│ Avg Latency 100ms │
+│ Tokens │
+│ Total 30 │
+│ ↳ Input 10 │
+│ ↳ Output 20 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should render "no API calls" message when there are no active models 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ No API calls have been made in this session. │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should resolve gemini-3-flash to gemini-3.5-flash via getDisplayString 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ │
+│ Metric gemini-3.5-flash │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 1 │
+│ Errors 0 (0.0%) │
+│ Avg Latency 100ms │
+│ Tokens │
+│ Total 30 │
+│ ↳ Input 5 │
+│ ↳ Cache Reads 5 (50.0%) │
+│ ↳ Thoughts 2 │
+│ ↳ Tool 1 │
+│ ↳ Output 20 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/NewAgentsNotification.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/NewAgentsNotification.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..74dcb8a91484cbf68e34a62dd4cd87e571f93689
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/NewAgentsNotification.test.tsx.snap
@@ -0,0 +1,47 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`NewAgentsNotification > renders agent list 1`] = `
+" ╭────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │ │
+ │ New Agents Discovered │
+ │ The following agents were found in this project. Please review them: │
+ │ │
+ │ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
+ │ │ │ │
+ │ │ - Agent A: Description A │ │
+ │ │ - Agent B: Description B │ │
+ │ │ (Includes MCP servers: github, postgres) │ │
+ │ │ - Agent C: Description C │ │
+ │ │ │ │
+ │ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
+ │ │
+ │ ● 1. Acknowledge and Enable │
+ │ 2. Do not enable (Ask again next time) │
+ │ │
+ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`NewAgentsNotification > truncates list if more than 5 agents 1`] = `
+" ╭────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │ │
+ │ New Agents Discovered │
+ │ The following agents were found in this project. Please review them: │
+ │ │
+ │ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
+ │ │ │ │
+ │ │ - Agent 0: Description 0 │ │
+ │ │ - Agent 1: Description 1 │ │
+ │ │ - Agent 2: Description 2 │ │
+ │ │ - Agent 3: Description 3 │ │
+ │ │ - Agent 4: Description 4 │ │
+ │ │ ... and 2 more. │ │
+ │ │ │ │
+ │ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
+ │ │
+ │ ● 1. Acknowledge and Enable │
+ │ 2. Do not enable (Ask again next time) │
+ │ │
+ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/Notifications.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Notifications.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..e34cf675d69b806d6eb42ef3076b166a13161210
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/Notifications.test.tsx.snap
@@ -0,0 +1,23 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Notifications > renders init error 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ Initialization Error: Something went wrong Please check API key and configuration. │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`Notifications > renders screen reader nudge when enabled and not seen (no legacy file) 1`] = `
+"You are currently in screen reader-friendly view. To switch out, open
+/mock/home/.gemini/settings.json and remove the entry for "screenReader". This will disappear on
+next run.
+"
+`;
+
+exports[`Notifications > renders update notification 1`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ Update available │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/OverageMenuDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/OverageMenuDialog.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..978c5cd17cd79c2bb0c4a888db255ec08a015e18
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/OverageMenuDialog.test.tsx.snap
@@ -0,0 +1,47 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`OverageMenuDialog > 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. │
+│ │
+│ You have 500 AI Credits available. │
+│ │
+│ How would you like to proceed? │
+│ │
+│ │
+│ ● 1. Use AI Credits - Continue this request (Overage) │
+│ 2. Manage - View balance and purchase more credits │
+│ 3. Switch to gemini-3-flash-preview │
+│ 4. Stop - Abort request │
+│ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`OverageMenuDialog > 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. │
+│ │
+│ You have 500 AI Credits available. │
+│ │
+│ How would you like to proceed? │
+│ │
+│ │
+│ ● 1. Use AI Credits - Continue this request (Overage) │
+│ 2. Manage - View balance and purchase more credits │
+│ 3. Stop - Abort request │
+│ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..5f5b3c9c270175f87c417a5e949cab9c47f7f11f
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap
@@ -0,0 +1,15 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`PolicyUpdateDialog > renders correctly and matches snapshot 1`] = `
+" ╭────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │ │
+ │ New or changed workspace policies detected │
+ │ Location: /test/workspace/.gemini/policies │
+ │ Do you want to accept and load these policies? │
+ │ │
+ │ ● 1. Accept and Load │
+ │ 2. Ignore (Use Default Policies) │
+ │ │
+ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/PrepareLabel.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/PrepareLabel.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..86c2da9f2033e916cb69cfc010d322a182e34c71
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/PrepareLabel.test.tsx.snap
@@ -0,0 +1,25 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`PrepareLabel > creates centered window around match when collapsed 1`] = `
+"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
+components//and/then/some/more/components//and/..."
+`;
+
+exports[`PrepareLabel > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
+
+exports[`PrepareLabel > renders plain label when no match (short label) 1`] = `"simple command"`;
+
+exports[`PrepareLabel > shows full long label when expanded and no match 1`] = `
+"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
+yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
+`;
+
+exports[`PrepareLabel > truncates long label when collapsed and no match 1`] = `
+"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
+`;
+
+exports[`PrepareLabel > truncates match itself when match is very long 1`] = `
+"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ProgressBar.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ProgressBar.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..cb4fd02cba8949e74196eb8971644300a8a863e6
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ProgressBar.test.tsx.snap
@@ -0,0 +1,21 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > renders 0% correctly 1`] = `
+"▬▬▬▬▬▬▬▬▬▬
+"
+`;
+
+exports[` > renders 50% correctly 1`] = `
+"▬▬▬▬▬▬▬▬▬▬
+"
+`;
+
+exports[` > renders error threshold correctly at 100% 1`] = `
+"▬▬▬▬▬▬▬▬▬▬
+"
+`;
+
+exports[` > renders warning threshold correctly 1`] = `
+"▬▬▬▬▬▬▬▬▬▬
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..822777ad67544d554291204530ca330c3eacc233
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap
@@ -0,0 +1,26 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`QuotaDisplay > should NOT render reset time when terse is true 1`] = `
+"85%
+"
+`;
+
+exports[`QuotaDisplay > should render critical when used >= 95% 1`] = `
+"96% used
+"
+`;
+
+exports[`QuotaDisplay > should render terse limit reached message 1`] = `
+"Limit reached
+"
+`;
+
+exports[`QuotaDisplay > should render warning when used >= 80% 1`] = `
+"85% used
+"
+`;
+
+exports[`QuotaDisplay > should render with reset time when provided 1`] = `
+"85% used (Limit resets in 1h)
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..77d37faa6120b765c6f74f691ae5e301feb85d08
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap
@@ -0,0 +1,56 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`RewindConfirmation > renders correctly with stats 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Confirm Rewind │
+│ │
+│ ┌──────────────────────────────────────────────────────────────────────────┐ │
+│ │ File: test.ts │ │
+│ │ Lines added: 10 Lines removed: 5 │ │
+│ │ │ │
+│ │ ℹ Rewinding does not affect files edited manually or by the shell tool. │ │
+│ └──────────────────────────────────────────────────────────────────────────┘ │
+│ │
+│ Select an action: │
+│ │
+│ ● 1. Rewind conversation and revert code changes │
+│ 2. Rewind conversation │
+│ 3. Revert code changes │
+│ 4. Do nothing (esc) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindConfirmation > renders correctly without stats 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Confirm Rewind │
+│ │
+│ No code changes to revert. │
+│ │
+│ Select an action: │
+│ │
+│ ● 1. Rewind conversation │
+│ 2. Do nothing (esc) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindConfirmation > renders timestamp when provided 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Confirm Rewind │
+│ │
+│ No code changes to revert. (just now) │
+│ │
+│ Select an action: │
+│ │
+│ ● 1. Rewind conversation │
+│ 2. Do nothing (esc) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..06f7d49105fcf16057e7f4810db4abe671886942
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap
@@ -0,0 +1,320 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`RewindViewer > Content Filtering > 'removes reference markers' 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ some command @file │
+│ No files have been changed │
+│ │
+│ ● Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource content' 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ read @server3:mcp://demo-resource hello │
+│ No files have been changed │
+│ │
+│ ● Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > Content Filtering > 'uses displayContent if present and do…' 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ clean display content │
+│ No files have been changed │
+│ │
+│ ● Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > Interaction Selection > 'cancels on Escape' > confirmation-dialog 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Confirm Rewind │
+│ │
+│ No code changes to revert. (some time ago) │
+│ │
+│ Select an action: │
+│ │
+│ ● 1. Rewind conversation │
+│ 2. Do nothing (esc) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > Interaction Selection > 'confirms on Enter' > confirmation-dialog 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Confirm Rewind │
+│ │
+│ No code changes to revert. (some time ago) │
+│ │
+│ Select an action: │
+│ │
+│ ● 1. Rewind conversation │
+│ 2. Do nothing (esc) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ Q1 │
+│ No files have been changed │
+│ │
+│ Q2 │
+│ No files have been changed │
+│ │
+│ Q3 │
+│ No files have been changed │
+│ │
+│ ● Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ Q1 │
+│ No files have been changed │
+│ │
+│ Q2 │
+│ No files have been changed │
+│ │
+│ ● Q3 │
+│ No files have been changed │
+│ │
+│ Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ Q1 │
+│ No files have been changed │
+│ │
+│ Q2 │
+│ No files have been changed │
+│ │
+│ Q3 │
+│ No files have been changed │
+│ │
+│ ● Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ Q1 │
+│ No files have been changed │
+│ │
+│ Q2 │
+│ No files have been changed │
+│ │
+│ ● Q3 │
+│ No files have been changed │
+│ │
+│ Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ Hello │
+│ No files have been changed │
+│ │
+│ ● Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > Rendering > renders 'full text for selected item' 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ 1 │
+│ 2... │
+│ No files have been changed │
+│ │
+│ ● Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > Rendering > renders 'nothing interesting for empty convers…' 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ ● Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > updates content when conversation changes (background update) > after-update 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ Message 1 │
+│ No files have been changed │
+│ │
+│ Message 2 │
+│ No files have been changed │
+│ │
+│ ● Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > updates content when conversation changes (background update) > initial 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ Message 1 │
+│ No files have been changed │
+│ │
+│ ● Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > updates selection and expansion on navigation > after-down 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ Line A │
+│ Line B... │
+│ No files have been changed │
+│ │
+│ Line 1 │
+│ Line 2... │
+│ No files have been changed │
+│ │
+│ ● Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`RewindViewer > updates selection and expansion on navigation > initial-state 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Rewind │
+│ │
+│ Line A │
+│ Line B... │
+│ No files have been changed │
+│ │
+│ Line 1 │
+│ Line 2... │
+│ No files have been changed │
+│ │
+│ ● Stay at current position │
+│ Cancel rewind and stay here │
+│ │
+│ │
+│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..15cd8748ae58c1976e30a4b146ca729c5895933d
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap
@@ -0,0 +1,36 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`SessionBrowser component > enters search mode, filters sessions, and renders match snippets 1`] = `
+" Chat Sessions (1 total, filtered) sorted by date desc
+
+ Search: query (Esc to cancel)
+
+ Index │ Msgs │ Age │ Match
+ ❯ #1 │ 1 │ 10mo │ You: Query is here a… (+1 more)
+ ▼
+"
+`;
+
+exports[`SessionBrowser component > renders a list of sessions and marks current session as disabled 1`] = `
+" Chat Sessions (2 total) sorted by date desc
+ Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q
+ Sort: s Reverse: r First/Last: g/G
+
+ Index │ Msgs │ Age │ Name
+ ❯ #1 │ 5 │ 10mo │ Second conversation about dogs (current)
+ #2 │ 2 │ 10mo │ First conversation about cats
+ ▼
+"
+`;
+
+exports[`SessionBrowser component > shows an error state when loading sessions fails 1`] = `
+" Error: storage failure
+ Press q to exit
+"
+`;
+
+exports[`SessionBrowser component > shows empty state when no sessions exist 1`] = `
+" No auto-saved conversations found.
+ Press q to exit
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..f23344a11686caaf1202850c84620195b356fd5a
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
@@ -0,0 +1,30 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > renders the summary display with a title 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Agent powering down. Goodbye! │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session │
+│ Tool Calls: 0 ( ✓ 0 x 0 ) │
+│ Success Rate: 0.0% │
+│ Code Changes: +42 -15 │
+│ │
+│ Performance │
+│ Wall Time: 1h 23m 45s │
+│ Agent Active: 50.2s │
+│ » API Time: 50.2s (100.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+│ │
+│ Model Usage │
+│ Use /model to view model quota information │
+│ │
+│ Model Reqs Input Tokens Cache Reads Output Tokens │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 10 1,000 500 2,000 │
+│ To resume this session: gemini --resume test-session │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a07095d37bfbd70f240b322d97ee07402f044f66
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg
@@ -0,0 +1,145 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-the-bottom-border-correctly-when-height-is-constrained.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-the-bottom-border-correctly-when-height-is-constrained.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..d3f10b6af8ea0f0284306a86c13d043574a65bde
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-the-bottom-border-correctly-when-height-is-constrained.snap.svg
@@ -0,0 +1,63 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..e90fa4363e2ddadf110c12c87e8dbaafff529362
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg
@@ -0,0 +1,145 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..2a30e9f212cac7c1d10642ace5ec5b22ad71f8e9
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg
@@ -0,0 +1,145 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a07095d37bfbd70f240b322d97ee07402f044f66
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg
@@ -0,0 +1,145 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a07095d37bfbd70f240b322d97ee07402f044f66
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg
@@ -0,0 +1,145 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..31b81aa227fb1b99cbe9fa5e89ae879c8a80a976
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg
@@ -0,0 +1,139 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..926eaef5e79d7b959c87631297dc01d556edd686
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg
@@ -0,0 +1,145 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a07095d37bfbd70f240b322d97ee07402f044f66
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg
@@ -0,0 +1,145 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..54288835eae4f718c1276d81d0bbe110a811bf44
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg
@@ -0,0 +1,145 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..a4b19e00f9f0be9fe1fdbbd38d31f6594b2896ff
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
@@ -0,0 +1,433 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`SettingsDialog > Initial Rendering > should render settings list with visual indicators 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Settings │
+│ │
+│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
+│ │ Search to filter │ │
+│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
+│ │
+│ ▲ │
+│ ● Vim Mode false │
+│ Enable Vim keybindings │
+│ │
+│ Default Approval Mode Default │
+│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
+│ │
+│ Enable Auto Update true │
+│ Enable automatic updates. │
+│ │
+│ Enable Terminal Notifications false │
+│ Enable terminal run-event notifications for action-required prompts and session com… │
+│ │
+│ Terminal Notification Method Auto │
+│ How to send terminal notifications. │
+│ │
+│ Enable Plan Mode true │
+│ Enable Plan Mode for read-only safety during planning. │
+│ │
+│ Plan Directory undefined │
+│ The directory where planning artifacts are stored. If not specified, defaults t… │
+│ │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ ▼ │
+│ │
+│ Apply To │
+│ ● User Settings │
+│ Workspace Settings │
+│ System Settings │
+│ │
+│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`SettingsDialog > Initial Rendering > should render the bottom border correctly when height is constrained 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Settings │
+│ │
+│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
+│ ╰─Search to filter─────────────────────────────────────────────────────────────────────────────╯ │
+│ ▲ │
+│ ● Vim Mode false │
+│ ▼ Enable Vim keybindings │
+│ │
+│ Apply To │
+│ ● User Settings │
+│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings enabled' correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Settings │
+│ │
+│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
+│ │ Search to filter │ │
+│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
+│ │
+│ ▲ │
+│ ● Vim Mode true* │
+│ Enable Vim keybindings │
+│ │
+│ Default Approval Mode Default │
+│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
+│ │
+│ Enable Auto Update true │
+│ Enable automatic updates. │
+│ │
+│ Enable Terminal Notifications false │
+│ Enable terminal run-event notifications for action-required prompts and session com… │
+│ │
+│ Terminal Notification Method Auto │
+│ How to send terminal notifications. │
+│ │
+│ Enable Plan Mode true │
+│ Enable Plan Mode for read-only safety during planning. │
+│ │
+│ Plan Directory undefined │
+│ The directory where planning artifacts are stored. If not specified, defaults t… │
+│ │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ ▼ │
+│ │
+│ Apply To │
+│ ● User Settings │
+│ Workspace Settings │
+│ System Settings │
+│ │
+│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings disabled' correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Settings │
+│ │
+│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
+│ │ Search to filter │ │
+│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
+│ │
+│ ▲ │
+│ ● Vim Mode false* │
+│ Enable Vim keybindings │
+│ │
+│ Default Approval Mode Default │
+│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
+│ │
+│ Enable Auto Update true* │
+│ Enable automatic updates. │
+│ │
+│ Enable Terminal Notifications false │
+│ Enable terminal run-event notifications for action-required prompts and session com… │
+│ │
+│ Terminal Notification Method Auto │
+│ How to send terminal notifications. │
+│ │
+│ Enable Plan Mode true │
+│ Enable Plan Mode for read-only safety during planning. │
+│ │
+│ Plan Directory undefined │
+│ The directory where planning artifacts are stored. If not specified, defaults t… │
+│ │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ ▼ │
+│ │
+│ Apply To │
+│ ● User Settings │
+│ Workspace Settings │
+│ System Settings │
+│ │
+│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`SettingsDialog > Snapshot Tests > should render 'default state' correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Settings │
+│ │
+│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
+│ │ Search to filter │ │
+│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
+│ │
+│ ▲ │
+│ ● Vim Mode false │
+│ Enable Vim keybindings │
+│ │
+│ Default Approval Mode Default │
+│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
+│ │
+│ Enable Auto Update true │
+│ Enable automatic updates. │
+│ │
+│ Enable Terminal Notifications false │
+│ Enable terminal run-event notifications for action-required prompts and session com… │
+│ │
+│ Terminal Notification Method Auto │
+│ How to send terminal notifications. │
+│ │
+│ Enable Plan Mode true │
+│ Enable Plan Mode for read-only safety during planning. │
+│ │
+│ Plan Directory undefined │
+│ The directory where planning artifacts are stored. If not specified, defaults t… │
+│ │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ ▼ │
+│ │
+│ Apply To │
+│ ● User Settings │
+│ Workspace Settings │
+│ System Settings │
+│ │
+│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`SettingsDialog > Snapshot Tests > should render 'file filtering settings configured' correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Settings │
+│ │
+│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
+│ │ Search to filter │ │
+│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
+│ │
+│ ▲ │
+│ ● Vim Mode false │
+│ Enable Vim keybindings │
+│ │
+│ Default Approval Mode Default │
+│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
+│ │
+│ Enable Auto Update true │
+│ Enable automatic updates. │
+│ │
+│ Enable Terminal Notifications false │
+│ Enable terminal run-event notifications for action-required prompts and session com… │
+│ │
+│ Terminal Notification Method Auto │
+│ How to send terminal notifications. │
+│ │
+│ Enable Plan Mode true │
+│ Enable Plan Mode for read-only safety during planning. │
+│ │
+│ Plan Directory undefined │
+│ The directory where planning artifacts are stored. If not specified, defaults t… │
+│ │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ ▼ │
+│ │
+│ Apply To │
+│ ● User Settings │
+│ Workspace Settings │
+│ System Settings │
+│ │
+│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selector' correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Settings │
+│ │
+│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
+│ │ Search to filter │ │
+│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
+│ │
+│ ▲ │
+│ Vim Mode false │
+│ Enable Vim keybindings │
+│ │
+│ Default Approval Mode Default │
+│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
+│ │
+│ Enable Auto Update true │
+│ Enable automatic updates. │
+│ │
+│ Enable Terminal Notifications false │
+│ Enable terminal run-event notifications for action-required prompts and session com… │
+│ │
+│ Terminal Notification Method Auto │
+│ How to send terminal notifications. │
+│ │
+│ Enable Plan Mode true │
+│ Enable Plan Mode for read-only safety during planning. │
+│ │
+│ Plan Directory undefined │
+│ The directory where planning artifacts are stored. If not specified, defaults t… │
+│ │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ ▼ │
+│ │
+│ > Apply To │
+│ ● 1. User Settings │
+│ 2. Workspace Settings │
+│ 3. System Settings │
+│ │
+│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and number settings' correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Settings │
+│ │
+│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
+│ │ Search to filter │ │
+│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
+│ │
+│ ▲ │
+│ ● Vim Mode false* │
+│ Enable Vim keybindings │
+│ │
+│ Default Approval Mode Default │
+│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
+│ │
+│ Enable Auto Update false* │
+│ Enable automatic updates. │
+│ │
+│ Enable Terminal Notifications false │
+│ Enable terminal run-event notifications for action-required prompts and session com… │
+│ │
+│ Terminal Notification Method Auto │
+│ How to send terminal notifications. │
+│ │
+│ Enable Plan Mode true │
+│ Enable Plan Mode for read-only safety during planning. │
+│ │
+│ Plan Directory undefined │
+│ The directory where planning artifacts are stored. If not specified, defaults t… │
+│ │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ ▼ │
+│ │
+│ Apply To │
+│ ● User Settings │
+│ Workspace Settings │
+│ System Settings │
+│ │
+│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`SettingsDialog > Snapshot Tests > should render 'tools and security settings' correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Settings │
+│ │
+│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
+│ │ Search to filter │ │
+│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
+│ │
+│ ▲ │
+│ ● Vim Mode false │
+│ Enable Vim keybindings │
+│ │
+│ Default Approval Mode Default │
+│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
+│ │
+│ Enable Auto Update true │
+│ Enable automatic updates. │
+│ │
+│ Enable Terminal Notifications false │
+│ Enable terminal run-event notifications for action-required prompts and session com… │
+│ │
+│ Terminal Notification Method Auto │
+│ How to send terminal notifications. │
+│ │
+│ Enable Plan Mode true │
+│ Enable Plan Mode for read-only safety during planning. │
+│ │
+│ Plan Directory undefined │
+│ The directory where planning artifacts are stored. If not specified, defaults t… │
+│ │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ ▼ │
+│ │
+│ Apply To │
+│ ● User Settings │
+│ Workspace Settings │
+│ System Settings │
+│ │
+│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settings enabled' correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Settings │
+│ │
+│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
+│ │ Search to filter │ │
+│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
+│ │
+│ ▲ │
+│ ● Vim Mode true* │
+│ Enable Vim keybindings │
+│ │
+│ Default Approval Mode Default │
+│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
+│ │
+│ Enable Auto Update false* │
+│ Enable automatic updates. │
+│ │
+│ Enable Terminal Notifications false │
+│ Enable terminal run-event notifications for action-required prompts and session com… │
+│ │
+│ Terminal Notification Method Auto │
+│ How to send terminal notifications. │
+│ │
+│ Enable Plan Mode true │
+│ Enable Plan Mode for read-only safety during planning. │
+│ │
+│ Plan Directory undefined │
+│ The directory where planning artifacts are stored. If not specified, defaults t… │
+│ │
+│ Plan Model Routing true │
+│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
+│ │
+│ ▼ │
+│ │
+│ Apply To │
+│ ● User Settings │
+│ Workspace Settings │
+│ System Settings │
+│ │
+│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..f51dca08608e466f620223783dad2774ef272a5c
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap
@@ -0,0 +1,53 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
+"────────────────────────────────────────
+ Shortcuts See /help for more
+ ! shell mode
+ @ select file or folder
+ Double Esc clear & rewind
+ Tab focus UI
+ Ctrl+Y YOLO mode
+ Shift+Tab cycle mode
+ Ctrl+V paste images
+ Alt+M raw markdown mode
+ Ctrl+R reverse-search history
+ Ctrl+G open external editor
+"
+`;
+
+exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
+"────────────────────────────────────────
+ Shortcuts See /help for more
+ ! shell mode
+ @ select file or folder
+ Double Esc clear & rewind
+ Tab focus UI
+ Ctrl+Y YOLO mode
+ Shift+Tab cycle mode
+ Ctrl+V paste images
+ Option+M raw markdown mode
+ Ctrl+R reverse-search history
+ Ctrl+G open external editor
+"
+`;
+
+exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ Shortcuts See /help for more
+ ! shell mode Shift+Tab cycle mode Ctrl+V paste images
+ @ select file or folder Ctrl+Y YOLO mode Alt+M raw markdown mode
+ Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+G open external editor
+ Tab focus UI
+"
+`;
+
+exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ Shortcuts See /help for more
+ ! shell mode Shift+Tab cycle mode Ctrl+V paste images
+ @ select file or folder Ctrl+Y YOLO mode Option+M raw markdown mode
+ Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+G open external editor
+ Tab focus UI
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..59a257144abc0367372cb786c466beadb0c52565
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
@@ -0,0 +1,321 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > Code Changes Display > displays Code Changes when line counts are present 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 1 ( ✓ 1 x 0 ) │
+│ Success Rate: 100.0% │
+│ Code Changes: +42 -18 │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 100ms │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 100ms (100.0%) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > Code Changes Display > hides Code Changes when no lines are added or removed 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 1 ( ✓ 1 x 0 ) │
+│ Success Rate: 100.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 100ms │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 100ms (100.0%) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > Conditional Color Tests > renders success rate in green for high values 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 10 ( ✓ 10 x 0 ) │
+│ Success Rate: 100.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 0s │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > Conditional Color Tests > renders success rate in red for low values 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 10 ( ✓ 5 x 5 ) │
+│ Success Rate: 50.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 0s │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > Conditional Color Tests > renders success rate in yellow for medium values 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 10 ( ✓ 9 x 1 ) │
+│ Success Rate: 90.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 0s │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > Conditional Rendering Tests > hides Efficiency section when cache is not used 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 0 ( ✓ 0 x 0 ) │
+│ Success Rate: 0.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 100ms │
+│ » API Time: 100ms (100.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+│ │
+│ Model Usage │
+│ Use /model to view model quota information │
+│ │
+│ Model Reqs Input Tokens Cache Reads Output Tokens │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 1 100 0 100 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > Conditional Rendering Tests > hides User Agreement when no decisions are made 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 2 ( ✓ 1 x 1 ) │
+│ Success Rate: 50.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 123ms │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 123ms (100.0%) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > Title Rendering > renders the custom title when a title prop is provided 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Agent powering down. Goodbye! │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 0 ( ✓ 0 x 0 ) │
+│ Success Rate: 0.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 0s │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > Title Rendering > renders the default title when no title prop is provided 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 0 ( ✓ 0 x 0 ) │
+│ Success Rate: 0.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 0s │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > renders a table with two models correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 0 ( ✓ 0 x 0 ) │
+│ Success Rate: 0.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 19.5s │
+│ » API Time: 19.5s (100.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+│ │
+│ Model Usage │
+│ Use /model to view model quota information │
+│ │
+│ Model Reqs Input Tokens Cache Reads Output Tokens │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 3 1,000 500 2,000 │
+│ gemini-2.5-flash 5 25,000 10,000 15,000 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > renders all sections when all data is present 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 2 ( ✓ 1 x 1 ) │
+│ Success Rate: 50.0% │
+│ User Agreement: 100.0% (1 reviewed) │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 223ms │
+│ » API Time: 100ms (44.8%) │
+│ » Tool Time: 123ms (55.2%) │
+│ │
+│ │
+│ Model Usage │
+│ Use /model to view model quota information │
+│ │
+│ Model Reqs Input Tokens Cache Reads Output Tokens │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 1 100 50 100 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > renders only the Performance section in its zero state 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 0 ( ✓ 0 x 0 ) │
+│ Success Rate: 0.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 0s │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > renders role breakdown correctly under models 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 0 ( ✓ 0 x 0 ) │
+│ Success Rate: 0.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 10.0s │
+│ » API Time: 10.0s (100.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+│ │
+│ Model Usage │
+│ Use /model to view model quota information │
+│ │
+│ Model Reqs Input Tokens Cache Reads Output Tokens │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-flash 10 1,200 200 2,000 │
+│ ↳ main 7 900 100 1,500 │
+│ ↳ utility_tool 3 300 100 500 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > resolves gemini-3-flash to gemini-3.5-flash in the model usage table 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Session ID: test-session-id │
+│ Tool Calls: 0 ( ✓ 0 x 0 ) │
+│ Success Rate: 0.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 3.0s │
+│ » API Time: 3.0s (100.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+│ │
+│ Model Usage │
+│ Use /model to view model quota information │
+│ │
+│ Model Reqs Input Tokens Cache Reads Output Tokens │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ gemini-3.5-flash 5 2,000 500 3,000 │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..2e6b4b75adc9e3666aab7c68da90e8ac7f5699d8
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap
@@ -0,0 +1,21 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `
+"Mock Context Summary Display (Skills: 2, Shells: 0)
+"
+`;
+
+exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `
+"Mock Context Summary Display (Skills: 2, Shells: 0)
+"
+`;
+
+exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `
+"Mock Context Summary Display (Skills: 2, Shells: 0)
+"
+`;
+
+exports[`StatusDisplay > renders system md indicator if env var is set 1`] = `
+"|⌐■_■|
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..3c79a534a27e3283d7617d64af8e497f062622d4
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap
@@ -0,0 +1,40 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`SuggestionsDisplay > handles scrolling 1`] = `
+" ▲
+ Cmd 5 Description 5
+ Cmd 6 Description 6
+ Cmd 7 Description 7
+ Cmd 8 Description 8
+ Cmd 9 Description 9
+ Cmd 10 Description 10
+ Cmd 11 Description 11
+ Cmd 12 Description 12
+ ▼
+ (11/20)
+"
+`;
+
+exports[`SuggestionsDisplay > highlights active item 1`] = `
+" command1 Description 1
+ command2 Description 2
+ command3 Description 3
+"
+`;
+
+exports[`SuggestionsDisplay > renders MCP tag for MCP prompts 1`] = `
+" mcp-tool [MCP]
+"
+`;
+
+exports[`SuggestionsDisplay > renders loading state 1`] = `
+" Loading suggestions...
+"
+`;
+
+exports[`SuggestionsDisplay > renders suggestions list 1`] = `
+" command1 Description 1
+ command2 Description 2
+ command3 Description 3
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..fca715c952cfcd782ee3219f0592e63db3b8b4d8
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..870e292d66ce94f1de827c6002db2da560235c02
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..508eca9a5bfb2775adfd298449df6a41cb04d67b
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..8356ef434585ae5b54461572657c21217ac2162f
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap
@@ -0,0 +1,20 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Table > should render headers and data correctly 1`] = `
+"ID Name
+────────────────────────────────────────────────────────────────────────────────────────────────────
+1 Alice
+2 Bob"
+`;
+
+exports[`Table > should support custom cell rendering 1`] = `
+"Value
+────────────────────────────────────────────────────────────────────────────────────────────────────
+20"
+`;
+
+exports[`Table > should support inverse text rendering 1`] = `
+"Status
+────────────────────────────────────────────────────────────────────────────────────────────────────
+Active"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..37ed33585c36c08d2fbf703fba18f636ef75edeb
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap
@@ -0,0 +1,202 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Initial Theme Selection > should default to a dark theme when terminal background is dark and no theme is set 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Select Theme Preview │
+│ ▲ ┌─────────────────────────────────────────────────┐ │
+│ 1. ANSI Dark │ │ │
+│ 2. Atom One Dark │ 1 # function │ │
+│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
+│ ● 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
+│ 5. Dracula Dark │ 4 for _ in range(n): │ │
+│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
+│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
+│ 8. Holiday Dark │ │ │
+│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
+│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
+│ 11. Tokyo Night Dark │ │ │
+│ 12. ANSI Light (Incompatible) └─────────────────────────────────────────────────┘ │
+│ ▼ │
+│ │
+│ (Use Enter to select, Tab to configure scope, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`Initial Theme Selection > should default to a light theme when terminal background is light and no theme is set 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Select Theme Preview │
+│ ▲ ┌─────────────────────────────────────────────────┐ │
+│ 1. ANSI Light │ │ │
+│ 2. Ayu Light │ 1 # function │ │
+│ ● 3. Default Light (Matches terminal) │ 2 def fibonacci(n): │ │
+│ 4. GitHub Light │ 3 a, b = 0, 1 │ │
+│ 5. GitHub Light Colorblind Light (Mat… │ 4 for _ in range(n): │ │
+│ 6. Google Code Light │ 5 a, b = b, a + b │ │
+│ 7. Solarized Light │ 6 return a │ │
+│ 8. Xcode Light │ │ │
+│ 9. ANSI Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
+│ 10. Atom One Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
+│ 11. Ayu Dark (Incompatible) │ │ │
+│ 12. Default Dark (Incompatible) └─────────────────────────────────────────────────┘ │
+│ ▼ │
+│ │
+│ (Use Enter to select, Tab to configure scope, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`Initial Theme Selection > should use the theme from settings even if terminal background suggests a different theme type 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Select Theme Preview │
+│ ▲ ┌─────────────────────────────────────────────────┐ │
+│ ● 1. ANSI Dark │ │ │
+│ 2. Atom One Dark │ 1 # function │ │
+│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
+│ 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
+│ 5. Dracula Dark │ 4 for _ in range(n): │ │
+│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
+│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
+│ 8. Holiday Dark │ │ │
+│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
+│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
+│ 11. Tokyo Night Dark │ │ │
+│ 12. ANSI Light (Incompatible) └─────────────────────────────────────────────────┘ │
+│ ▼ │
+│ │
+│ (Use Enter to select, Tab to configure scope, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`ThemeDialog Snapshots > should render correctly in scope selector mode 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Apply To │
+│ ● 1. User Settings │
+│ 2. Workspace Settings │
+│ 3. System Settings │
+│ │
+│ (Use Enter to apply scope, Tab to select theme, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`ThemeDialog Snapshots > should render correctly in theme selection mode (isDevelopment: false) 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Select Theme Preview │
+│ ▲ ┌─────────────────────────────────────────────────┐ │
+│ ● 1. ANSI Dark (Matches terminal) │ │ │
+│ 2. Atom One Dark │ 1 # function │ │
+│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
+│ 4. Default Dark │ 3 a, b = 0, 1 │ │
+│ 5. Dracula Dark │ 4 for _ in range(n): │ │
+│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
+│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
+│ 8. Holiday Dark │ │ │
+│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
+│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
+│ 11. Tokyo Night Dark │ │ │
+│ 12. ANSI Light (Incompatible) └─────────────────────────────────────────────────┘ │
+│ ▼ │
+│ │
+│ (Use Enter to select, Tab to configure scope, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`ThemeDialog Snapshots > should render correctly in theme selection mode (isDevelopment: true) 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Select Theme Preview │
+│ ▲ ┌─────────────────────────────────────────────────┐ │
+│ ● 1. ANSI Dark (Matches terminal) │ │ │
+│ 2. Atom One Dark │ 1 # function │ │
+│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
+│ 4. Default Dark │ 3 a, b = 0, 1 │ │
+│ 5. Dracula Dark │ 4 for _ in range(n): │ │
+│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
+│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
+│ 8. Holiday Dark │ │ │
+│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
+│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
+│ 11. Tokyo Night Dark │ │ │
+│ 12. ANSI Light (Incompatible) └─────────────────────────────────────────────────┘ │
+│ ▼ │
+│ ╭─────────────────────────────────────────────────╮ │
+│ │ DEVELOPER TOOLS (Not visible to users) │ │
+│ │ │ │
+│ │ How do colors get applied? │ │
+│ │ • Hex: Rendered exactly by modern terminals. │ │
+│ │ Not overridden by app themes. │ │
+│ │ • Blank: Uses your terminal's default │ │
+│ │ foreground/background. │ │
+│ │ • Compatibility: On older terminals, hex is │ │
+│ │ approximated to the nearest ANSI color. │ │
+│ │ • ANSI Names: 'red', 'green', etc. are mapped │ │
+│ │ to your terminal app's palette. │ │
+│ │ │ │
+│ │ Value Name │ │
+│ │ #0000… backgroun Main terminal background │ │
+│ │ d.primary color │ │
+│ │ #5F5… backgroun Subtle background for │ │
+│ │ d.message message blocks │ │
+│ │ #5F5… backgroun Background for the input │ │
+│ │ d.input prompt │ │
+│ │ #00… background. Background highlight for │ │
+│ │ focus selected/focused items │ │
+│ │ #005… backgrou Background for added lines │ │
+│ │ nd.diff. in diffs │ │
+│ │ added │ │
+│ │ #5F0… backgroun Background for removed │ │
+│ │ d.diff.re lines in diffs │ │
+│ │ moved │ │
+│ │ #FFFFF text.prim Primary text color (uses │ │
+│ │ F ary terminal default if blank) │ │
+│ │ #AFAFAF text.secon Secondary/dimmed text │ │
+│ │ dary color │ │
+│ │ #87AFFF text.link Hyperlink and highlighting │ │
+│ │ color │ │
+│ │ #D7AFFF text.accen Accent color for │ │
+│ │ t emphasis │ │
+│ │ #FFFFFF text.res Color for model response │ │
+│ │ ponse text (uses terminal default │ │
+│ │ if blank) │ │
+│ │ #878787 border.def Standard border color │ │
+│ │ ault │ │
+│ │ #AFAFAFui.comme Color for code comments and │ │
+│ │ nt metadata │ │
+│ │ #AFAFA ui.symbol Color for technical symbols │ │
+│ │ F and UI icons │ │
+│ │ #87AFF ui.active Border color for active or │ │
+│ │ F running elements │ │
+│ │ #87878 ui.dark Deeply dimmed color for │ │
+│ │ 7 subtle UI elements │ │
+│ │ #D7FFD ui.focus Color for focused elements │ │
+│ │ 7 (e.g. selected menu items, │ │
+│ │ focused borders) │ │
+│ │ #FF87AFstatus.err Color for error messages │ │
+│ │ or and critical status │ │
+│ │ #D7FFD7status.suc Color for success messages │ │
+│ │ cess and positive status │ │
+│ │ #FFFFA status.wa Color for warnings and │ │
+│ │ F rning cautionary status │ │
+│ │ #4796E4 ui.gradien │ │
+│ │ #847ACE t │ │
+│ │ #C3677F │ │
+│ ╰─────────────────────────────────────────────────╯ │
+│ │
+│ (Use Enter to select, Tab to configure scope, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/Tips.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Tips.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..dbc60fcf4d198d0922b2cc62822f46718ab81dfb
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/Tips.test.tsx.snap
@@ -0,0 +1,20 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Tips > 'renders all tips including GEMINI.md …' 1`] = `
+"
+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[`Tips > 'renders fewer tips when GEMINI.md exi…' 1`] = `
+"
+Tips for getting started:
+1. /help for more information
+2. Ask coding questions, edit code or run commands
+3. Be specific for the best results
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ToastDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToastDisplay.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..94b4717762d0e09e5daa171b4d8be62beedff8ae
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ToastDisplay.test.tsx.snap
@@ -0,0 +1,36 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ToastDisplay > renders Ctrl+C prompt 1`] = `
+"Press Ctrl+C again to exit.
+"
+`;
+
+exports[`ToastDisplay > renders Ctrl+D prompt 1`] = `
+"Press Ctrl+D again to exit.
+"
+`;
+
+exports[`ToastDisplay > renders Escape prompt when buffer is NOT empty 1`] = `
+"Press Esc again to clear prompt.
+"
+`;
+
+exports[`ToastDisplay > renders Escape prompt when buffer is empty 1`] = `
+"Press Esc again to rewind.
+"
+`;
+
+exports[`ToastDisplay > renders Queue Error Message 1`] = `
+"Queue Error
+"
+`;
+
+exports[`ToastDisplay > renders hint message 1`] = `
+"This is a hint
+"
+`;
+
+exports[`ToastDisplay > renders warning message 1`] = `
+"This is a warning
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-handle-security-warning-height-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-handle-security-warning-height-correctly.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..b3f4741541cd2af1b235475b3c3734ac318dfa10
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-handle-security-warning-height-correctly.snap.svg
@@ -0,0 +1,113 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-edit-diffs.snap.svg b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-edit-diffs.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a257a1253c04df33566da024af53254449695414
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-edit-diffs.snap.svg
@@ -0,0 +1,544 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-exec-commands.snap.svg b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-exec-commands.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..92578029de749b3582be54cae692c7cc7daa0f49
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-exec-commands.snap.svg
@@ -0,0 +1,220 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-renders-a-multiline-shell-command-with-syntax-highlighting-and-redirection-warning-SVG-snapshot-.snap.svg b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-renders-a-multiline-shell-command-with-syntax-highlighting-and-redirection-warning-SVG-snapshot-.snap.svg
new file mode 100644
index 0000000000000000000000000000000000000000..32ece1f90f214ced00180ff8797fd938eb9a2d17
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-renders-a-multiline-shell-command-with-syntax-highlighting-and-redirection-warning-SVG-snapshot-.snap.svg
@@ -0,0 +1,88 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..5bc3980fcec18bffb50d217d89b9bd69657f984c
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap
@@ -0,0 +1,212 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ToolConfirmationQueue > calculates availableContentHeight based on availableTerminalHeight from UI state 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ ? replace edit file │
+│ ╭──────────────────────────────────────────────────────────────────────────╮ │
+│ ╰─... 48 hidden (Ctrl+O) ...───────────────────────────────────────────────╯ │
+│ Apply this change? │
+│ │
+│ ● 1. Allow once │
+│ 2. Allow for this session │
+│ 3. Modify with external editor │
+│ 4. No, suggest changes (esc) │
+╰──────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`ToolConfirmationQueue > does not render expansion hint when constrainHeight is false 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ ? replace edit file │
+│ ╭──────────────────────────────────────────────────────────────────────────╮ │
+│ │ │ │
+│ │ No changes detected. │ │
+│ │ │ │
+│ ╰──────────────────────────────────────────────────────────────────────────╯ │
+│ Apply this change? │
+│ │
+│ ● 1. Allow once │
+│ 2. Allow for this session │
+│ 3. Modify with external editor │
+│ 4. No, suggest changes (esc) │
+╰──────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`ToolConfirmationQueue > height allocation and layout > should handle security warning height correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ ? Shell Executes a bash command with a deceptive URL 3 of 3 │
+│ ... 6 hidden (Ctrl+O) ... │
+│ │ echo "Line 44" │ │
+│ │ echo "Line 45" │ │
+│ │ echo "Line 46" │ │
+│ │ echo "Line 47" │ │
+│ │ echo "Line 48" │ │
+│ │ echo "Line 49" │ │
+│ │ echo "Line 50" │ │
+│ │ curl https://täst.com │ │
+│ ╰──────────────────────────────────────────────────────────────────────────╯ │
+│ │
+│ ⚠ Warning: Deceptive URL(s) detected: │
+│ │
+│ Original: https://täst.com/ │
+│ Actual Host (Punycode): https://xn--tst-qla.com/ │
+│ │
+│ Allow execution of [Shell]? │
+│ │
+│ ● 1. Allow once │
+│ 2. Allow for this session │
+│ 3. No, suggest changes (esc) │
+╰──────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`ToolConfirmationQueue > height allocation and layout > should render the full queue wrapper with borders and content for large edit diffs 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ ? replace Replaces content in a file │
+│ ╭──────────────────────────────────────────────────────────────────────────╮ │
+│ │ ... 13 hidden (Ctrl+O) ... │ │
+│ │ 7 + const newLine7 = true; │ │
+│ │ 8 - const oldLine8 = true; │ │
+│ │ 8 + const newLine8 = true; │ │
+│ │ 9 - const oldLine9 = true; │ │
+│ │ 9 + const newLine9 = true; │ │
+│ │ 10 - const oldLine10 = true; │ │
+│ │ 10 + const newLine10 = true; │ │
+│ │ 11 - const oldLine11 = true; │ │
+│ │ 11 + const newLine11 = true; │ │
+│ │ 12 - const oldLine12 = true; │ │
+│ │ 12 + const newLine12 = true; │ │
+│ │ 13 - const oldLine13 = true; │ │
+│ │ 13 + const newLine13 = true; │ │
+│ │ 14 - const oldLine14 = true; │ │
+│ │ 14 + const newLine14 = true; │ │
+│ │ 15 - const oldLine15 = true; │ │
+│ │ 15 + const newLine15 = true; │ │
+│ │ 16 - const oldLine16 = true; │ │
+│ │ 16 + const newLine16 = true; │ │
+│ │ 17 - const oldLine17 = true; │ │
+│ │ 17 + const newLine17 = true; │ │
+│ │ 18 - const oldLine18 = true; │ │
+│ │ 18 + const newLine18 = true; │ │
+│ │ 19 - const oldLine19 = true; │ │
+│ │ 19 + const newLine19 = true; │ │
+│ │ 20 - const oldLine20 = true; │ │
+│ │ 20 + const newLine20 = true; │ │
+│ ╰──────────────────────────────────────────────────────────────────────────╯ │
+│ Apply this change? │
+│ │
+│ ● 1. Allow once │
+│ 2. Allow for this session │
+│ 3. Modify with external editor │
+│ 4. No, suggest changes (esc) │
+╰──────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`ToolConfirmationQueue > height allocation and layout > should render the full queue wrapper with borders and content for large exec commands 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ ? Shell Executes a bash command 2 of 3 │
+│ ╭──────────────────────────────────────────────────────────────────────────╮ │
+│ │ ... 22 hidden (Ctrl+O) ... │ │
+│ │ echo "Line 23" │ │
+│ │ echo "Line 24" │ │
+│ │ echo "Line 25" │ │
+│ │ echo "Line 26" │ │
+│ │ echo "Line 27" │ │
+│ │ echo "Line 28" │ │
+│ │ echo "Line 29" │ │
+│ │ echo "Line 30" │ │
+│ │ echo "Line 31" │ │
+│ │ echo "Line 32" │ │
+│ │ echo "Line 33" │ │
+│ │ echo "Line 34" │ │
+│ │ echo "Line 35" │ │
+│ │ echo "Line 36" │ │
+│ │ echo "Line 37" │ │
+│ │ echo "Line 38" │ │
+│ │ echo "Line 39" │ │
+│ │ echo "Line 40" │ │
+│ │ echo "Line 41" │ │
+│ │ echo "Line 42" │ │
+│ │ echo "Line 43" │ │
+│ │ echo "Line 44" │ │
+│ │ echo "Line 45" │ │
+│ │ echo "Line 46" │ │
+│ │ echo "Line 47" │ │
+│ │ echo "Line 48" │ │
+│ │ echo "Line 49" │ │
+│ │ echo "Line 50" │ │
+│ ╰──────────────────────────────────────────────────────────────────────────╯ │
+│ Allow execution of [Shell]? │
+│ │
+│ ● 1. Allow once │
+│ 2. Allow for this session │
+│ 3. No, suggest changes (esc) │
+╰──────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`ToolConfirmationQueue > provides more height for ask_user by subtracting less overhead 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ Answer Questions │
+│ │
+│ Line 1 │
+│ Line 2 │
+│ Line 3 │
+│ Line 4 │
+│ Line 5 │
+│ Line 6 │
+│ │
+│ ● 1. Option 1 │
+│ Desc │
+│ 2. Enter a custom value │
+│ │
+│ Enter to select · ↑/↓ to navigate · Esc to cancel │
+╰──────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`ToolConfirmationQueue > renders AskUser tool confirmation with Success color 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ Answer Questions │
+│ │
+│ Review your answers: │
+│ │
+│ │
+│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
+╰──────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`ToolConfirmationQueue > renders ExitPlanMode tool confirmation with Success color 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ Ready to start implementation? │
+│ │
+│ Plan content goes here │
+│ │
+│ ● 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[`ToolConfirmationQueue > renders the confirming tool with progress indicator 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ ? Shell list files 1 of 3 │
+│ ╭──────────────────────────────────────────────────────────────────────────╮ │
+│ │ ls │ │
+│ ╰──────────────────────────────────────────────────────────────────────────╯ │
+│ Allow execution of [Shell]? │
+│ │
+│ ● 1. Allow once │
+│ 2. Allow for this session │
+│ 3. No, suggest changes (esc) │
+╰──────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ToolStatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolStatsDisplay.test.tsx.snap
new file mode 100644
index 0000000000000000000000000000000000000000..6b7b847f1231285f6db9b055bc719c59be6fc280
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ToolStatsDisplay.test.tsx.snap
@@ -0,0 +1,91 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > should display stats for a single tool correctly 1`] = `
+"╭────────────────────────────────────────────────────────────────────╮
+│ │
+│ Tool Stats For Nerds │
+│ │
+│ Tool Name Calls Success Rate Avg Duration │
+│ ──────────────────────────────────────────────────────────────── │
+│ test-tool 1 100.0% 100ms │
+│ │
+│ User Decision Summary │
+│ Total Reviewed Suggestions: 1 │
+│ » Accepted: 1 │
+│ » Rejected: 0 │
+│ » Modified: 0 │
+│ ──────────────────────────────────────────────────────────────── │
+│ Overall Agreement Rate: 100.0% │
+╰────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should display stats for multiple tools correctly 1`] = `
+"╭────────────────────────────────────────────────────────────────────╮
+│ │
+│ Tool Stats For Nerds │
+│ │
+│ Tool Name Calls Success Rate Avg Duration │
+│ ──────────────────────────────────────────────────────────────── │
+│ tool-a 2 50.0% 100ms │
+│ tool-b 1 100.0% 100ms │
+│ │
+│ User Decision Summary │
+│ Total Reviewed Suggestions: 3 │
+│ » Accepted: 1 │
+│ » Rejected: 1 │
+│ » Modified: 1 │
+│ ──────────────────────────────────────────────────────────────── │
+│ Overall Agreement Rate: 33.3% │
+╰────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should handle large values without wrapping or overlapping 1`] = `
+"╭────────────────────────────────────────────────────────────────────╮
+│ │
+│ Tool Stats For Nerds │
+│ │
+│ Tool Name Calls Success Rate Avg Duration │
+│ ──────────────────────────────────────────────────────────────── │
+│ long-named-tool-for-testi99999999 88.9% 1ms │
+│ ng-wrapping-and-such 9 │
+│ │
+│ User Decision Summary │
+│ Total Reviewed Suggestions: 222234566 │
+│ » Accepted: 123456789 │
+│ » Rejected: 98765432 │
+│ » Modified: 12345 │
+│ ──────────────────────────────────────────────────────────────── │
+│ Overall Agreement Rate: 55.6% │
+╰────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should handle zero decisions gracefully 1`] = `
+"╭────────────────────────────────────────────────────────────────────╮
+│ │
+│ Tool Stats For Nerds │
+│ │
+│ Tool Name Calls Success Rate Avg Duration │
+│ ──────────────────────────────────────────────────────────────── │
+│ test-tool 1 100.0% 100ms │
+│ │
+│ User Decision Summary │
+│ Total Reviewed Suggestions: 0 │
+│ » Accepted: 0 │
+│ » Rejected: 0 │
+│ » Modified: 0 │
+│ ──────────────────────────────────────────────────────────────── │
+│ Overall Agreement Rate: -- │
+╰────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > should render "no tool calls" message when there are no active tools 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ No tool calls have been made in this session. │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ac645d312c2094b304ca228c82e90447dc9700ef
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx
@@ -0,0 +1,256 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderWithProviders } from '../../../test-utils/render.js';
+import {
+ CompressionMessage,
+ type CompressionDisplayProps,
+} from './CompressionMessage.js';
+import { CompressionStatus } from '@google/gemini-cli-core';
+import { type CompressionProps } from '../../types.js';
+import { describe, it, expect } from 'vitest';
+
+describe('', () => {
+ const createCompressionProps = (
+ overrides: Partial = {},
+ ): CompressionDisplayProps => ({
+ compression: {
+ isPending: false,
+ originalTokenCount: null,
+ newTokenCount: null,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ ...overrides,
+ },
+ });
+
+ describe('pending state', () => {
+ it('renders pending message when compression is in progress', async () => {
+ const props = createCompressionProps({ isPending: true });
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('Compressing chat history');
+ unmount();
+ });
+ });
+
+ describe('normal compression (successful token reduction)', () => {
+ it('renders success message when tokens are reduced', async () => {
+ const props = createCompressionProps({
+ isPending: false,
+ originalTokenCount: 100,
+ newTokenCount: 50,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ });
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('✦');
+ expect(output).toContain(
+ 'Chat history compressed from 100 to 50 tokens.',
+ );
+ unmount();
+ });
+
+ it.each([
+ { original: 50000, newTokens: 25000 }, // Large compression
+ { original: 700000, newTokens: 350000 }, // Very large compression
+ ])(
+ 'renders success message for large successful compression (from $original to $newTokens)',
+ async ({ original, newTokens }) => {
+ const props = createCompressionProps({
+ isPending: false,
+ originalTokenCount: original,
+ newTokenCount: newTokens,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ });
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('✦');
+ expect(output).toContain(
+ `compressed from ${original} to ${newTokens} tokens`,
+ );
+ expect(output).not.toContain('Skipping compression');
+ expect(output).not.toContain('did not reduce size');
+ unmount();
+ },
+ );
+ });
+
+ describe('skipped compression (tokens increased or same)', () => {
+ it('renders skip message when compression would increase token count', async () => {
+ const props = createCompressionProps({
+ isPending: false,
+ originalTokenCount: 50,
+ newTokenCount: 75,
+ compressionStatus:
+ CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
+ });
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('✦');
+ expect(output).toContain(
+ 'Compression was not beneficial for this history size.',
+ );
+ unmount();
+ });
+
+ it('renders skip message when token counts are equal', async () => {
+ const props = createCompressionProps({
+ isPending: false,
+ originalTokenCount: 50,
+ newTokenCount: 50,
+ compressionStatus:
+ CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
+ });
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain(
+ 'Compression was not beneficial for this history size.',
+ );
+ unmount();
+ });
+ });
+
+ describe('message content validation', () => {
+ it.each([
+ {
+ original: 200,
+ newTokens: 80,
+ expected: 'compressed from 200 to 80 tokens',
+ },
+ {
+ original: 500,
+ newTokens: 150,
+ expected: 'compressed from 500 to 150 tokens',
+ },
+ {
+ original: 1500,
+ newTokens: 400,
+ expected: 'compressed from 1500 to 400 tokens',
+ },
+ ])(
+ 'displays correct compression statistics (from $original to $newTokens)',
+ async ({ original, newTokens, expected }) => {
+ const props = createCompressionProps({
+ isPending: false,
+ originalTokenCount: original,
+ newTokenCount: newTokens,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ });
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain(expected);
+ unmount();
+ },
+ );
+
+ it.each([
+ { original: 50, newTokens: 60 }, // Increased
+ { original: 100, newTokens: 100 }, // Same
+ { original: 49999, newTokens: 50000 }, // Just under 50k threshold
+ ])(
+ 'shows skip message for small histories when new tokens >= original tokens ($original -> $newTokens)',
+ async ({ original, newTokens }) => {
+ const props = createCompressionProps({
+ isPending: false,
+ originalTokenCount: original,
+ newTokenCount: newTokens,
+ compressionStatus:
+ CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
+ });
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain(
+ 'Compression was not beneficial for this history size.',
+ );
+ expect(output).not.toContain('compressed from');
+ unmount();
+ },
+ );
+
+ it.each([
+ { original: 50000, newTokens: 50100 }, // At 50k threshold
+ { original: 700000, newTokens: 710000 }, // Large history case
+ { original: 100000, newTokens: 100000 }, // Large history, same count
+ ])(
+ 'shows compression failure message for large histories when new tokens >= original tokens ($original -> $newTokens)',
+ async ({ original, newTokens }) => {
+ const props = createCompressionProps({
+ isPending: false,
+ originalTokenCount: original,
+ newTokenCount: newTokens,
+ compressionStatus:
+ CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
+ });
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('compression did not reduce size');
+ expect(output).not.toContain('compressed from');
+ expect(output).not.toContain('Compression was not beneficial');
+ unmount();
+ },
+ );
+ });
+
+ describe('failure states', () => {
+ it('renders failure message when model returns an empty summary', async () => {
+ const props = createCompressionProps({
+ isPending: false,
+ compressionStatus: CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
+ });
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('✦');
+ expect(output).toContain(
+ 'Chat history compression failed: the model returned an empty summary.',
+ );
+ unmount();
+ });
+
+ it('renders failure message for token count errors', async () => {
+ const props = createCompressionProps({
+ isPending: false,
+ compressionStatus:
+ CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR,
+ });
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain(
+ 'Could not compress chat history due to a token counting error.',
+ );
+ unmount();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d5f10cc12ca261557dbbacbdb598b55320f3428c
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/CompressionMessage.tsx
@@ -0,0 +1,81 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Box, Text } from 'ink';
+import type { CompressionProps } from '../../types.js';
+import { CliSpinner } from '../CliSpinner.js';
+import { theme } from '../../semantic-colors.js';
+import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
+import { CompressionStatus } from '@google/gemini-cli-core';
+
+export interface CompressionDisplayProps {
+ compression: CompressionProps;
+}
+
+/*
+ * Compression messages appear when the /compress command is run, and show a loading spinner
+ * while compression is in progress, followed up by some compression stats.
+ */
+export function CompressionMessage({
+ compression,
+}: CompressionDisplayProps): React.JSX.Element {
+ const { isPending, originalTokenCount, newTokenCount, compressionStatus } =
+ compression;
+
+ const originalTokens = originalTokenCount ?? 0;
+ const newTokens = newTokenCount ?? 0;
+
+ const getCompressionText = () => {
+ if (isPending) {
+ return 'Compressing chat history';
+ }
+
+ switch (compressionStatus) {
+ case CompressionStatus.COMPRESSED:
+ return `Chat history compressed from ${originalTokens} to ${newTokens} tokens.`;
+ case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
+ // For smaller histories (< 50k tokens), compression overhead likely exceeds benefits
+ if (originalTokens < 50000) {
+ return 'Compression was not beneficial for this history size.';
+ }
+ // For larger histories where compression should work but didn't,
+ // this suggests an issue with the compression process itself
+ return 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.';
+ case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
+ return 'Could not compress chat history due to a token counting error.';
+ case CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY:
+ return 'Chat history compression failed: the model returned an empty summary.';
+ case CompressionStatus.NOOP:
+ return 'Nothing to compress.';
+ default:
+ return '';
+ }
+ };
+
+ const text = getCompressionText();
+
+ return (
+
+
+ {isPending ? (
+
+ ) : (
+ ✦
+ )}
+
+
+
+ {text}
+
+
+
+ );
+}
diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..586ce89ab23d017c031239d9bd8a35ad99dffae4
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx
@@ -0,0 +1,617 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { renderWithProviders } from '../../../test-utils/render.js';
+import { createMockSettings } from '../../../test-utils/settings.js';
+import { waitFor } from '../../../test-utils/async.js';
+import { DenseToolMessage } from './DenseToolMessage.js';
+import {
+ CoreToolCallStatus,
+ type DiffStat,
+ type FileDiff,
+ type GrepResult,
+ type ListDirectoryResult,
+ type ReadManyFilesResult,
+ makeFakeConfig,
+} from '@google/gemini-cli-core';
+import type {
+ SerializableConfirmationDetails,
+ ToolResultDisplay,
+} from '../../types.js';
+
+describe('DenseToolMessage', () => {
+ const defaultProps = {
+ callId: 'call-1',
+ name: 'test-tool',
+ description: 'Test description',
+ status: CoreToolCallStatus.Success,
+ resultDisplay: 'Success result' as ToolResultDisplay,
+ confirmationDetails: undefined,
+ terminalWidth: 80,
+ };
+
+ it('explicitly renders the filename in the header for FileDiff results', async () => {
+ const fileDiff: FileDiff = {
+ fileName: 'test-file.ts',
+ filePath: '/test-file.ts',
+ fileDiff:
+ '--- a/test-file.ts\n+++ b/test-file.ts\n@@ -1 +1 @@\n-old\n+new',
+ originalContent: 'old',
+ newContent: 'new',
+ };
+
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('test-file.ts');
+ });
+
+ it('renders correctly for a successful string result', async () => {
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('test-tool');
+ expect(output).toContain('Test description');
+ expect(output).toContain('→ Success result');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('truncates long string results', async () => {
+ const longResult = 'A'.repeat(200);
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('…');
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('flattens newlines in string results', async () => {
+ const multilineResult = 'Line 1\nLine 2';
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('→ Line 1 Line 2');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders correctly for file diff results with stats', async () => {
+ const diffResult: FileDiff = {
+ fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+diff content',
+ fileName: 'test.ts',
+ filePath: '/path/to/test.ts',
+ originalContent: 'old content',
+ newContent: 'new content',
+ diffStat: {
+ user_added_lines: 5,
+ user_removed_lines: 2,
+ user_added_chars: 50,
+ user_removed_chars: 20,
+ model_added_lines: 10,
+ model_removed_lines: 4,
+ model_added_chars: 100,
+ model_removed_chars: 40,
+ },
+ };
+ const { lastFrame } = await renderWithProviders(
+ ,
+ {
+ settings: createMockSettings({
+ merged: { useAlternateBuffer: false, useTerminalBuffer: false },
+ }),
+ },
+ );
+ await waitFor(() => expect(lastFrame()).toContain('test-tool'));
+ await waitFor(() =>
+ expect(lastFrame()).toContain('test.ts → Accepted (+15, -6)'),
+ );
+ const output = lastFrame();
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders correctly for Edit tool using confirmationDetails', async () => {
+ const confirmationDetails = {
+ type: 'edit' as const,
+ title: 'Confirm Edit',
+ fileName: 'styles.scss',
+ filePath: '/path/to/styles.scss',
+ fileDiff:
+ '@@ -1,1 +1,1 @@\n-body { color: blue; }\n+body { color: red; }',
+ originalContent: 'body { color: blue; }',
+ newContent: 'body { color: red; }',
+ };
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ {},
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('Edit');
+ expect(output).toContain('styles.scss');
+ expect(output).toContain('→ Confirming');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders correctly for Rejected Edit tool', async () => {
+ const diffResult: FileDiff = {
+ fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
+ fileName: 'styles.scss',
+ filePath: '/path/to/styles.scss',
+ originalContent: 'old line',
+ newContent: 'new line',
+ diffStat: {
+ user_added_lines: 1,
+ user_removed_lines: 1,
+ user_added_chars: 0,
+ user_removed_chars: 0,
+ model_added_lines: 0,
+ model_removed_lines: 0,
+ model_added_chars: 0,
+ model_removed_chars: 0,
+ },
+ };
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ {},
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('Edit');
+ expect(output).toContain('styles.scss → Rejected (+1, -1)');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders correctly for Rejected Edit tool with confirmationDetails and diffStat', async () => {
+ const confirmationDetails = {
+ type: 'edit' as const,
+ title: 'Confirm Edit',
+ fileName: 'styles.scss',
+ filePath: '/path/to/styles.scss',
+ fileDiff:
+ '@@ -1,1 +1,1 @@\n-body { color: blue; }\n+body { color: red; }',
+ originalContent: 'body { color: blue; }',
+ newContent: 'body { color: red; }',
+ diffStat: {
+ user_added_lines: 1,
+ user_removed_lines: 1,
+ user_added_chars: 0,
+ user_removed_chars: 0,
+ model_added_lines: 0,
+ model_removed_lines: 0,
+ model_added_chars: 0,
+ model_removed_chars: 0,
+ } as DiffStat,
+ };
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ {},
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('Edit');
+ expect(output).toContain('styles.scss → Rejected (+1, -1)');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders correctly for WriteFile tool', async () => {
+ const diffResult: FileDiff = {
+ fileDiff: '@@ -1,1 +1,1 @@\n-old content\n+new content',
+ fileName: 'config.json',
+ filePath: '/path/to/config.json',
+ originalContent: 'old content',
+ newContent: 'new content',
+ diffStat: {
+ user_added_lines: 1,
+ user_removed_lines: 1,
+ user_added_chars: 0,
+ user_removed_chars: 0,
+ model_added_lines: 0,
+ model_removed_lines: 0,
+ model_added_chars: 0,
+ model_removed_chars: 0,
+ },
+ };
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ {},
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('WriteFile');
+ expect(output).toContain('config.json → Accepted (+1, -1)');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders correctly for Rejected WriteFile tool', async () => {
+ const diffResult: FileDiff = {
+ fileDiff: '@@ -1,1 +1,1 @@\n-old content\n+new content',
+ fileName: 'config.json',
+ filePath: '/path/to/config.json',
+ originalContent: 'old content',
+ newContent: 'new content',
+ };
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ {},
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('WriteFile');
+ expect(output).toContain('config.json');
+ expect(output).toContain('→ Rejected');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders correctly for Errored Edit tool', async () => {
+ const diffResult: FileDiff = {
+ fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
+ fileName: 'styles.scss',
+ filePath: '/path/to/styles.scss',
+ originalContent: 'old line',
+ newContent: 'new line',
+ diffStat: {
+ user_added_lines: 1,
+ user_removed_lines: 1,
+ user_added_chars: 0,
+ user_removed_chars: 0,
+ model_added_lines: 0,
+ model_removed_lines: 0,
+ model_added_chars: 0,
+ model_removed_chars: 0,
+ },
+ };
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('Edit');
+ expect(output).toContain('styles.scss → Failed (+1, -1)');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders correctly for grep results', async () => {
+ const grepResult: GrepResult = {
+ summary: 'Found 2 matches',
+ matches: [
+ {
+ filePath: 'file1.ts',
+ absolutePath: '/file1.ts',
+ lineNumber: 10,
+ line: 'match 1',
+ },
+ {
+ filePath: 'file2.ts',
+ absolutePath: '/file2.ts',
+ lineNumber: 20,
+ line: 'match 2',
+ },
+ ],
+ };
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('→ Found 2 matches');
+ // Matches should no longer be rendered in dense mode to keep it compact
+ expect(output).not.toContain('file1.ts:10: match 1');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders correctly for ls results', async () => {
+ const lsResult: ListDirectoryResult = {
+ summary: 'Listed 2 files. (1 ignored)',
+ files: ['file1.ts', 'dir1'],
+ };
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('→ Listed 2 files. (1 ignored)');
+ // Directory listings should not have a payload in dense mode
+ expect(output).not.toContain('file1.ts');
+ expect(output).not.toContain('dir1');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders correctly for ReadManyFiles results', async () => {
+ const rmfResult: ReadManyFilesResult = {
+ summary: 'Read 3 file(s)',
+ files: ['file1.ts', 'file2.ts', 'file3.ts'],
+ include: ['**/*.ts'],
+ skipped: [{ path: 'skipped.bin', reason: 'binary' }],
+ };
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('Attempting to read files from **/*.ts');
+ expect(output).toContain('→ Read 3 file(s) (1 ignored)');
+ // File lists should no longer be rendered in dense mode
+ expect(output).not.toContain('file1.ts');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders correctly for todo updates', async () => {
+ const todoResult = {
+ todos: [],
+ };
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('→ Todos updated');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders generic output message for unknown object results', async () => {
+ const genericResult = {
+ some: 'data',
+ } as unknown as ToolResultDisplay;
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('→ Returned (possible empty result)');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders correctly for error status with string message', async () => {
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('→ Error occurred');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders generic failure message for error status without string message', async () => {
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('→ Failed');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('does not render result arrow if resultDisplay is missing', async () => {
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).not.toContain('→');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('truncates long description but preserves tool name (< 25 chars)', async () => {
+ const longDescription =
+ 'This is a very long description that should definitely be truncated because it exceeds the available terminal width and we want to see how it behaves.';
+ const toolName = 'tool-name-is-24-chars-!!'; // Exactly 24 chars
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+
+ // Tool name should be fully present (it plus one space is exactly 25, fitting the maxWidth)
+ expect(output).toContain(toolName);
+ // Description should be present but truncated
+ expect(output).toContain('This is a');
+ expect(output).toMatchSnapshot();
+ });
+
+ describe('Toggleable Diff View (Alternate Buffer)', () => {
+ const diffResult: FileDiff = {
+ fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
+ fileName: 'test.ts',
+ filePath: '/path/to/test.ts',
+ originalContent: 'old content',
+ newContent: 'new content',
+ };
+
+ it('hides diff content by default when in alternate buffer mode', async () => {
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ {
+ config: makeFakeConfig({ useAlternateBuffer: true }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
+ },
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('Accepted');
+ expect(output).not.toContain('new line');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('shows diff content by default when NOT in alternate buffer mode', async () => {
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ {
+ config: makeFakeConfig({ useAlternateBuffer: false }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
+ },
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+ expect(output).toContain('Accepted');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('shows diff content when expanded via ToolActionsContext', async () => {
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ {
+ config: makeFakeConfig({ useAlternateBuffer: true }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
+ toolActions: {
+ isExpanded: () => true,
+ },
+ },
+ );
+ await waitUntilReady();
+
+ // Verify it shows the diff when expanded
+ expect(lastFrame()).toContain('new line');
+ });
+ });
+
+ describe('Visual Regression', () => {
+ it('matches SVG snapshot for an Accepted file edit with diff stats', async () => {
+ const diffResult: FileDiff = {
+ fileName: 'test.ts',
+ filePath: '/mock/test.ts',
+ fileDiff: '--- a/test.ts\n+++ b/test.ts\n@@ -1 +1 @@\n-old\n+new',
+ originalContent: 'old',
+ newContent: 'new',
+ diffStat: {
+ model_added_lines: 1,
+ model_removed_lines: 1,
+ model_added_chars: 3,
+ model_removed_chars: 3,
+ user_added_lines: 0,
+ user_removed_lines: 0,
+ user_added_chars: 0,
+ user_removed_chars: 0,
+ },
+ };
+
+ const renderResult = await renderWithProviders(
+ ,
+ );
+
+ await renderResult.waitUntilReady();
+ await expect(renderResult).toMatchSvgSnapshot();
+ });
+
+ it('matches SVG snapshot for a Rejected tool call', async () => {
+ const renderResult = await renderWithProviders(
+ ,
+ );
+
+ await renderResult.waitUntilReady();
+ await expect(renderResult).toMatchSvgSnapshot();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f5e4b31c668034f8cea95a23e829073ef81112b7
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx
@@ -0,0 +1,528 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useMemo, useState, useRef } from 'react';
+import { Box, Text, type DOMElement } from 'ink';
+import {
+ CoreToolCallStatus,
+ type FileDiff,
+ type ListDirectoryResult,
+ type ReadManyFilesResult,
+ isFileDiff,
+ hasSummary,
+ isGrepResult,
+ isListResult,
+ isReadManyFilesResult,
+} from '@google/gemini-cli-core';
+import {
+ type IndividualToolCallDisplay,
+ type ToolResultDisplay,
+ isTodoList,
+} from '../../types.js';
+import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
+import { ToolStatusIndicator } from './ToolShared.js';
+import { theme } from '../../semantic-colors.js';
+import {
+ DiffRenderer,
+ renderDiffLines,
+ isNewFile,
+ parseDiffWithLineNumbers,
+} from './DiffRenderer.js';
+import { useMouseClick } from '../../hooks/useMouseClick.js';
+import { ScrollableList } from '../shared/ScrollableList.js';
+import { COMPACT_TOOL_SUBVIEW_MAX_LINES } from '../../constants.js';
+import { useSettings } from '../../contexts/SettingsContext.js';
+import { colorizeCode } from '../../utils/CodeColorizer.js';
+import { useToolActions } from '../../contexts/ToolActionsContext.js';
+import { getFileExtension } from '../../utils/fileUtils.js';
+
+const PAYLOAD_MARGIN_LEFT = 6;
+const PAYLOAD_BORDER_CHROME_WIDTH = 4; // paddingX=1 (2 cols) + borders (2 cols)
+const PAYLOAD_SCROLL_GUTTER = 4;
+const PAYLOAD_MAX_WIDTH = 120 + PAYLOAD_SCROLL_GUTTER;
+
+interface DenseToolMessageProps extends IndividualToolCallDisplay {
+ terminalWidth: number;
+ availableTerminalHeight?: number;
+}
+
+interface ViewParts {
+ // brief description of action
+ description?: React.ReactNode;
+ // result summary or status text
+ summary?: React.ReactNode;
+ // detailed output, e.g. diff or command output
+ payload?: React.ReactNode;
+}
+
+interface PayloadResult {
+ summary: string;
+ payload: string;
+}
+
+const hasPayload = (res: unknown): res is PayloadResult => {
+ if (!hasSummary(res)) return false;
+ if (!('payload' in res)) return false;
+
+ const value = (res as { payload?: unknown }).payload;
+ return typeof value === 'string';
+};
+
+function getFileOpData(
+ diff: FileDiff,
+ status: CoreToolCallStatus,
+ resultDisplay: ToolResultDisplay | undefined,
+ terminalWidth: number,
+ availableTerminalHeight: number | undefined,
+ isClickable: boolean,
+): ViewParts {
+ const added =
+ (diff.diffStat?.model_added_lines ?? 0) +
+ (diff.diffStat?.user_added_lines ?? 0);
+ const removed =
+ (diff.diffStat?.model_removed_lines ?? 0) +
+ (diff.diffStat?.user_removed_lines ?? 0);
+
+ const isAcceptedOrConfirming =
+ status === CoreToolCallStatus.Success ||
+ status === CoreToolCallStatus.Executing ||
+ status === CoreToolCallStatus.AwaitingApproval;
+
+ const addColor = isAcceptedOrConfirming
+ ? theme.status.success
+ : theme.text.secondary;
+ const removeColor = isAcceptedOrConfirming
+ ? theme.status.error
+ : theme.text.secondary;
+
+ // Always show diff stats if available, using neutral colors for rejected
+ const showDiffStat = !!diff.diffStat;
+
+ const description = (
+
+
+ {diff.fileName}
+
+
+ );
+ let resultSummary = '';
+ let resultColor = theme.text.secondary;
+
+ if (status === CoreToolCallStatus.AwaitingApproval) {
+ resultSummary = 'Confirming';
+ } else if (
+ status === CoreToolCallStatus.Success ||
+ status === CoreToolCallStatus.Executing
+ ) {
+ resultSummary = 'Accepted';
+ resultColor = theme.text.accent;
+ } else if (status === CoreToolCallStatus.Cancelled) {
+ resultSummary = 'Rejected';
+ resultColor = theme.status.error;
+ } else if (status === CoreToolCallStatus.Error) {
+ resultSummary =
+ typeof resultDisplay === 'string' ? resultDisplay : 'Failed';
+ resultColor = theme.status.error;
+ }
+
+ const summary = (
+
+ {resultSummary && (
+
+ →{' '}
+
+ {resultSummary.replace(/\n/g, ' ')}
+
+
+ )}
+ {showDiffStat && (
+
+
+ {'('}
+ +{added}
+ {', '}
+ -{removed}
+ {')'}
+
+
+ )}
+
+ );
+
+ const payload = (
+
+ );
+
+ return { description, summary, payload };
+}
+
+function getReadManyFilesData(result: ReadManyFilesResult): ViewParts {
+ const includePatterns = result.include?.join(', ') ?? '';
+ const description = (
+
+ Attempting to read files from {includePatterns}
+
+ );
+
+ const skippedCount = result.skipped?.length ?? 0;
+ const summaryStr = `Read ${result.files.length} file(s)${
+ skippedCount > 0 ? ` (${skippedCount} ignored)` : ''
+ }`;
+ const summary = → {summaryStr};
+
+ return { description, summary, payload: undefined };
+}
+
+function getListDirectoryData(
+ result: ListDirectoryResult,
+ originalDescription?: string,
+): ViewParts {
+ const description = originalDescription ? (
+
+ {originalDescription}
+
+ ) : undefined;
+ const summary = → {result.summary};
+
+ // For directory listings, we want NO payload in dense mode
+ return { description, summary, payload: undefined };
+}
+
+function getListResultData(
+ result: ListDirectoryResult | ReadManyFilesResult,
+ originalDescription?: string,
+): ViewParts {
+ if (isReadManyFilesResult(result)) {
+ return getReadManyFilesData(result);
+ }
+ return getListDirectoryData(result, originalDescription);
+}
+
+function getGenericSuccessData(
+ resultDisplay: unknown,
+ originalDescription?: string,
+): ViewParts {
+ let summary: React.ReactNode;
+ let payload: React.ReactNode;
+
+ const description = originalDescription ? (
+
+ {originalDescription}
+
+ ) : undefined;
+
+ if (typeof resultDisplay === 'string') {
+ const flattened = resultDisplay.replace(/\n/g, ' ').trim();
+ summary = (
+
+ → {flattened}
+
+ );
+ } else if (isGrepResult(resultDisplay)) {
+ summary = (
+
+ → {resultDisplay.summary}
+
+ );
+ } else if (isTodoList(resultDisplay)) {
+ summary = (
+
+ → Todos updated
+
+ );
+ } else if (hasPayload(resultDisplay)) {
+ summary = → {resultDisplay.summary};
+ payload = (
+
+ {resultDisplay.payload}
+
+ );
+ } else {
+ summary = (
+
+ → Returned (possible empty result)
+
+ );
+ }
+
+ return { description, summary, payload };
+}
+
+export const DenseToolMessage: React.FC = (props) => {
+ const {
+ callId,
+ name,
+ status,
+ resultDisplay,
+ confirmationDetails,
+ outputFile,
+ terminalWidth,
+ availableTerminalHeight,
+ description: originalDescription,
+ } = props;
+
+ const settings = useSettings();
+ const isAlternateBuffer = useAlternateBuffer();
+ const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
+
+ // Handle optional context members
+ const [localIsExpanded, setLocalIsExpanded] = useState(false);
+ const isExpanded = isExpandedInContext
+ ? isExpandedInContext(callId)
+ : localIsExpanded;
+
+ const [isFocused, setIsFocused] = useState(false);
+ const toggleRef = useRef(null);
+
+ // Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails)
+ const diff = useMemo((): FileDiff | undefined => {
+ if (isFileDiff(resultDisplay)) return resultDisplay;
+ if (confirmationDetails?.type === 'edit') {
+ const details = confirmationDetails;
+ return {
+ fileName: details.fileName,
+ fileDiff: details.fileDiff,
+ filePath: details.filePath,
+ originalContent: details.originalContent,
+ newContent: details.newContent,
+ diffStat: details.diffStat,
+ };
+ }
+ return undefined;
+ }, [resultDisplay, confirmationDetails]);
+
+ const handleToggle = () => {
+ const next = !isExpanded;
+ if (!next) {
+ setIsFocused(false);
+ } else {
+ setIsFocused(true);
+ }
+
+ if (toggleExpansion) {
+ toggleExpansion(callId);
+ } else {
+ setLocalIsExpanded(next);
+ }
+ };
+
+ useMouseClick(toggleRef, handleToggle, {
+ isActive: isAlternateBuffer && !!diff,
+ });
+
+ // State-to-View Coordination
+ const viewParts = useMemo((): ViewParts => {
+ if (diff) {
+ return getFileOpData(
+ diff,
+ status,
+ resultDisplay,
+ terminalWidth,
+ availableTerminalHeight,
+ isAlternateBuffer,
+ );
+ }
+ if (isListResult(resultDisplay)) {
+ return getListResultData(resultDisplay, originalDescription);
+ }
+
+ if (isGrepResult(resultDisplay)) {
+ return getGenericSuccessData(resultDisplay, originalDescription);
+ }
+
+ if (status === CoreToolCallStatus.Success && resultDisplay) {
+ return getGenericSuccessData(resultDisplay, originalDescription);
+ }
+ if (status === CoreToolCallStatus.Error) {
+ const text =
+ typeof resultDisplay === 'string'
+ ? resultDisplay.replace(/\n/g, ' ')
+ : 'Failed';
+ const errorSummary = (
+
+ → {text}
+
+ );
+ const descriptionText = originalDescription ? (
+
+ {originalDescription}
+
+ ) : undefined;
+ return {
+ description: descriptionText,
+ summary: errorSummary,
+ payload: undefined,
+ };
+ }
+
+ const descriptionText = originalDescription ? (
+
+ {originalDescription}
+
+ ) : undefined;
+ return {
+ description: descriptionText,
+ summary: undefined,
+ payload: undefined,
+ };
+ }, [
+ diff,
+ status,
+ resultDisplay,
+ terminalWidth,
+ availableTerminalHeight,
+ originalDescription,
+ isAlternateBuffer,
+ ]);
+
+ const { description, summary } = viewParts;
+
+ const diffLines = useMemo(() => {
+ if (!diff || !isExpanded || !isAlternateBuffer) return [];
+
+ const parsedLines = parseDiffWithLineNumbers(diff.fileDiff);
+ const isNewFileResult = isNewFile(parsedLines);
+
+ if (isNewFileResult) {
+ const addedContent = parsedLines
+ .filter((line) => line.type === 'add')
+ .map((line) => line.content)
+ .join('\n');
+
+ const fileExtension = getFileExtension(diff.fileName);
+
+ return colorizeCode({
+ code: addedContent,
+ language: fileExtension,
+ maxWidth: terminalWidth - PAYLOAD_MARGIN_LEFT,
+ settings,
+ disableColor: status === CoreToolCallStatus.Cancelled,
+ returnLines: true,
+ });
+ } else {
+ return renderDiffLines({
+ parsedLines,
+ filename: diff.fileName,
+ terminalWidth: terminalWidth - PAYLOAD_MARGIN_LEFT,
+ disableColor: status === CoreToolCallStatus.Cancelled,
+ });
+ }
+ }, [diff, isExpanded, isAlternateBuffer, terminalWidth, settings, status]);
+
+ const showPayload = useMemo(() => {
+ const policy = !isAlternateBuffer || !diff || isExpanded;
+ if (!policy) return false;
+
+ if (diff) {
+ if (isAlternateBuffer) {
+ return isExpanded && diffLines.length > 0;
+ }
+ // In non-alternate buffer mode, we always show the diff.
+ return true;
+ }
+
+ return !!(viewParts.payload || outputFile);
+ }, [
+ isAlternateBuffer,
+ diff,
+ isExpanded,
+ diffLines.length,
+ viewParts.payload,
+ outputFile,
+ ]);
+
+ const keyExtractor = (_item: React.ReactNode, index: number) =>
+ `diff-line-${index}`;
+ const renderItem = ({ item }: { item: React.ReactNode }) => (
+ {item}
+ );
+
+ return (
+
+
+
+
+
+
+ {name}{' '}
+
+
+
+ {description}
+
+
+
+ {summary && (
+
+ {summary}
+
+ )}
+
+
+ {showPayload && isAlternateBuffer && diffLines.length > 0 && (
+
+ 1}
+ hasFocus={isFocused}
+ width={Math.min(
+ PAYLOAD_MAX_WIDTH,
+ terminalWidth -
+ PAYLOAD_MARGIN_LEFT -
+ PAYLOAD_BORDER_CHROME_WIDTH -
+ PAYLOAD_SCROLL_GUTTER,
+ )}
+ />
+
+ )}
+
+ {showPayload && (!isAlternateBuffer || !diff) && viewParts.payload && (
+
+ {viewParts.payload}
+
+ )}
+
+ {showPayload && outputFile && (
+
+
+ (Output saved to: {outputFile})
+
+
+ )}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..49cccfbc785f73e52d6c578220a718065f39cf55
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx
@@ -0,0 +1,433 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { OverflowProvider } from '../../contexts/OverflowContext.js';
+import { renderWithProviders } from '../../../test-utils/render.js';
+import { createMockSettings } from '../../../test-utils/settings.js';
+import { waitFor } from '../../../test-utils/async.js';
+import { DiffRenderer } from './DiffRenderer.js';
+import * as CodeColorizer from '../../utils/CodeColorizer.js';
+import { vi } from 'vitest';
+
+describe('', () => {
+ const mockColorizeCode = vi.spyOn(CodeColorizer, 'colorizeCode');
+
+ beforeEach(() => {
+ mockColorizeCode.mockClear();
+ });
+
+ const sanitizeOutput = (output: string | undefined, terminalWidth: number) =>
+ output?.replace(/GAP_INDICATOR/g, '═'.repeat(terminalWidth));
+
+ describe.each([true, false])(
+ 'with useAlternateBuffer = %s',
+ (useAlternateBuffer) => {
+ it('should call colorizeCode with correct language for new file with known extension', async () => {
+ const newFileDiffContent = `
+diff --git a/test.py b/test.py
+new file mode 100644
+index 0000000..e69de29
+--- /dev/null
++++ b/test.py
+@@ -0,0 +1 @@
++print("hello world")
+`;
+ await renderWithProviders(
+
+
+ ,
+ {
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ await waitFor(() =>
+ expect(mockColorizeCode).toHaveBeenCalledWith(
+ expect.objectContaining({
+ code: 'print("hello world")',
+ language: 'python',
+ availableHeight: undefined,
+ maxWidth: 80,
+ theme: undefined,
+ settings: expect.anything(),
+ disableColor: false,
+ paddingX: 0,
+ }),
+ ),
+ );
+ });
+
+ it('should call colorizeCode with null language for new file with unknown extension', async () => {
+ const newFileDiffContent = `
+diff --git a/test.unknown b/test.unknown
+new file mode 100644
+index 0000000..e69de29
+--- /dev/null
++++ b/test.unknown
+@@ -0,0 +1 @@
++some content
+`;
+ await renderWithProviders(
+
+
+ ,
+ {
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ await waitFor(() =>
+ expect(mockColorizeCode).toHaveBeenCalledWith(
+ expect.objectContaining({
+ code: 'some content',
+ language: null,
+ availableHeight: undefined,
+ maxWidth: 80,
+ theme: undefined,
+ settings: expect.anything(),
+ disableColor: false,
+ paddingX: 0,
+ }),
+ ),
+ );
+ });
+
+ it('should call colorizeCode with null language for new file if no filename is provided', async () => {
+ const newFileDiffContent = `
+diff --git a/test.txt b/test.txt
+new file mode 100644
+index 0000000..e69de29
+--- /dev/null
++++ b/test.txt
+@@ -0,0 +1 @@
++some text content
+`;
+ await renderWithProviders(
+
+
+ ,
+ {
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ await waitFor(() =>
+ expect(mockColorizeCode).toHaveBeenCalledWith(
+ expect.objectContaining({
+ code: 'some text content',
+ language: null,
+ availableHeight: undefined,
+ maxWidth: 80,
+ theme: undefined,
+ settings: expect.anything(),
+ disableColor: false,
+ paddingX: 0,
+ }),
+ ),
+ );
+ });
+
+ it('should render diff content for existing file (not calling colorizeCode directly for the whole block)', async () => {
+ const existingFileDiffContent = `
+
+diff --git a/test.txt b/test.txt
+index 0000001..0000002 100644
+--- a/test.txt
++++ b/test.txt
+@@ -1 +1 @@
+-old line
++new line
+`;
+ const { lastFrame } = await renderWithProviders(
+
+
+ ,
+ {
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ // colorizeCode is used internally by the line-by-line rendering, not for the whole block
+ await waitFor(() => expect(lastFrame()).toContain('new line'));
+ expect(mockColorizeCode).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ code: expect.stringContaining('old line'),
+ }),
+ );
+ expect(mockColorizeCode).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ code: expect.stringContaining('new line'),
+ }),
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('should handle diff with only header and no changes', async () => {
+ const noChangeDiff = `diff --git a/file.txt b/file.txt
+index 1234567..1234567 100644
+--- a/file.txt
++++ b/file.txt
+`;
+ const { lastFrame } = await renderWithProviders(
+
+
+ ,
+ {
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ await waitFor(() => expect(lastFrame()).toBeDefined());
+ expect(lastFrame()).toMatchSnapshot();
+ expect(mockColorizeCode).not.toHaveBeenCalled();
+ });
+
+ it('should handle empty diff content', async () => {
+ const { lastFrame } = await renderWithProviders(
+
+
+ ,
+ {
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ await waitFor(() => expect(lastFrame()).toBeDefined());
+ expect(lastFrame()).toMatchSnapshot();
+ expect(mockColorizeCode).not.toHaveBeenCalled();
+ });
+
+ it('should render a gap indicator for skipped lines', async () => {
+ const diffWithGap = `
+
+diff --git a/file.txt b/file.txt
+index 123..456 100644
+--- a/file.txt
++++ b/file.txt
+@@ -1,2 +1,2 @@
+ context line 1
+-deleted line
++added line
+@@ -10,2 +10,2 @@
+ context line 10
+ context line 11
+`;
+ const { lastFrame } = await renderWithProviders(
+
+
+ ,
+ {
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ await waitFor(() => expect(lastFrame()).toContain('added line'));
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP)', async () => {
+ const diffWithSmallGap = `
+
+diff --git a/file.txt b/file.txt
+index abc..def 100644
+--- a/file.txt
++++ b/file.txt
+@@ -1,5 +1,5 @@
+ context line 1
+ context line 2
+ context line 3
+ context line 4
+ context line 5
+@@ -11,5 +11,5 @@
+ context line 11
+ context line 12
+ context line 13
+ context line 14
+ context line 15
+`;
+ const { lastFrame } = await renderWithProviders(
+
+
+ ,
+ {
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ await waitFor(() => expect(lastFrame()).toContain('context line 15'));
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ describe('should correctly render a diff with multiple hunks and a gap indicator', () => {
+ const diffWithMultipleHunks = `
+
+diff --git a/multi.js b/multi.js
+index 123..789 100644
+--- a/multi.js
++++ b/multi.js
+@@ -1,3 +1,3 @@
+ console.log('first hunk');
+-const oldVar = 1;
++const newVar = 1;
+ console.log('end of first hunk');
+@@ -20,3 +20,3 @@
+ console.log('second hunk');
+-const anotherOld = 'test';
++const anotherNew = 'test';
+ console.log('end of second hunk');
+`;
+
+ it.each([
+ {
+ terminalWidth: 80,
+ height: undefined,
+ },
+ {
+ terminalWidth: 80,
+ height: 6,
+ },
+ {
+ terminalWidth: 30,
+ height: 6,
+ },
+ ])(
+ 'with terminalWidth $terminalWidth and height $height',
+ async ({ terminalWidth, height }) => {
+ const { lastFrame } = await renderWithProviders(
+
+
+ ,
+ {
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ await waitFor(() => expect(lastFrame()).toContain('anotherNew'));
+ const output = lastFrame();
+ expect(sanitizeOutput(output, terminalWidth)).toMatchSnapshot();
+ },
+ );
+ });
+
+ it('should correctly render a diff with a SVN diff format', async () => {
+ const newFileDiff = `
+
+fileDiff Index: file.txt
+===================================================================
+--- a/file.txt Current
++++ b/file.txt Proposed
+--- a/multi.js
++++ b/multi.js
+@@ -1,1 +1,1 @@
+-const oldVar = 1;
++const newVar = 1;
+@@ -20,1 +20,1 @@
+-const anotherOld = 'test';
++const anotherNew = 'test';
+\\ No newline at end of file
+`;
+ const { lastFrame } = await renderWithProviders(
+
+
+ ,
+ {
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ await waitFor(() => expect(lastFrame()).toContain('newVar'));
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('should correctly render a new file with no file extension correctly', async () => {
+ const newFileDiff = `
+
+fileDiff Index: Dockerfile
+===================================================================
+--- Dockerfile Current
++++ Dockerfile Proposed
+@@ -0,0 +1,3 @@
++FROM node:14
++RUN npm install
++RUN npm run build
+\\ No newline at end of file
+`;
+ const { lastFrame } = await renderWithProviders(
+
+
+ ,
+ {
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ await waitFor(() => expect(lastFrame()).toContain('RUN npm run build'));
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('should disable truncation and render all diff lines without hidden lines indicator when disableTruncation is true', async () => {
+ const longDiff = `
+diff --git a/test.txt b/test.txt
+--- a/test.txt
++++ b/test.txt
+@@ -1,10 +1,10 @@
+-old line 1
+-old line 2
+-old line 3
+-old line 4
+-old line 5
++new line 1
++new line 2
++new line 3
++new line 4
++new line 5
+`;
+ const { lastFrame } = await renderWithProviders(
+
+
+ ,
+ );
+ await waitFor(() => {
+ expect(lastFrame()).toContain('new line 1');
+ expect(lastFrame()).toContain('new line 5');
+ expect(lastFrame()).not.toContain('hidden');
+ });
+ });
+ },
+ );
+});
diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..42b248901ea2f899d848cf7bfc870516e731aa3d
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx
@@ -0,0 +1,426 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useMemo } from 'react';
+import { Box, Text, useIsScreenReaderEnabled } from 'ink';
+import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
+import { MaxSizedBox } from '../shared/MaxSizedBox.js';
+import { theme as semanticTheme } from '../../semantic-colors.js';
+import type { Theme } from '../../themes/theme.js';
+import { useSettings } from '../../contexts/SettingsContext.js';
+import { getFileExtension } from '../../utils/fileUtils.js';
+
+export interface DiffLine {
+ type: 'add' | 'del' | 'context' | 'hunk' | 'other';
+ oldLine?: number;
+ newLine?: number;
+ content: string;
+}
+
+export function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
+ const lines = diffContent.split(/\r?\n/);
+ const result: DiffLine[] = [];
+ let currentOldLine = 0;
+ let currentNewLine = 0;
+ let inHunk = false;
+ const hunkHeaderRegex = /^@@ -(\d+),?\d* \+(\d+),?\d* @@/;
+
+ for (const line of lines) {
+ const hunkMatch = line.match(hunkHeaderRegex);
+ if (hunkMatch) {
+ currentOldLine = parseInt(hunkMatch[1], 10);
+ currentOldLine = parseInt(hunkMatch[1], 10);
+ currentNewLine = parseInt(hunkMatch[2], 10);
+ inHunk = true;
+ result.push({ type: 'hunk', content: line });
+ // We need to adjust the starting point because the first line number applies to the *first* actual line change/context,
+ // but we increment *before* pushing that line. So decrement here.
+ currentOldLine--;
+ currentNewLine--;
+ continue;
+ }
+ if (!inHunk) {
+ // Skip standard Git header lines more robustly
+ if (line.startsWith('--- ')) {
+ continue;
+ }
+ // If it's not a hunk or header, skip (or handle as 'other' if needed)
+ continue;
+ }
+ if (line.startsWith('+')) {
+ currentNewLine++; // Increment before pushing
+ result.push({
+ type: 'add',
+ newLine: currentNewLine,
+ content: line.substring(1),
+ });
+ } else if (line.startsWith('-')) {
+ currentOldLine++; // Increment before pushing
+ result.push({
+ type: 'del',
+ oldLine: currentOldLine,
+ content: line.substring(1),
+ });
+ } else if (line.startsWith(' ')) {
+ currentOldLine++; // Increment before pushing
+ currentNewLine++;
+ result.push({
+ type: 'context',
+ oldLine: currentOldLine,
+ newLine: currentNewLine,
+ content: line.substring(1),
+ });
+ } else if (line.startsWith('\\')) {
+ // Handle "\ No newline at end of file"
+ result.push({ type: 'other', content: line });
+ }
+ }
+ return result;
+}
+
+interface DiffRendererProps {
+ diffContent: string;
+ filename?: string;
+ tabWidth?: number;
+ availableTerminalHeight?: number;
+ terminalWidth: number;
+ theme?: Theme;
+ disableColor?: boolean;
+ paddingX?: number;
+ disableTruncation?: boolean;
+}
+
+const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
+
+export const DiffRenderer: React.FC = ({
+ diffContent,
+ filename,
+ tabWidth = DEFAULT_TAB_WIDTH,
+ availableTerminalHeight,
+ terminalWidth,
+ theme,
+ disableColor = false,
+ paddingX = 0,
+ disableTruncation = false,
+}) => {
+ const settings = useSettings();
+
+ const screenReaderEnabled = useIsScreenReaderEnabled();
+
+ const parsedLines = useMemo(() => {
+ if (!diffContent || typeof diffContent !== 'string') {
+ return [];
+ }
+ return parseDiffWithLineNumbers(diffContent);
+ }, [diffContent]);
+
+ const isNewFileResult = useMemo(() => isNewFile(parsedLines), [parsedLines]);
+
+ const renderedOutput = useMemo(() => {
+ if (!diffContent || typeof diffContent !== 'string') {
+ return No diff content.;
+ }
+
+ if (parsedLines.length === 0) {
+ return (
+
+ No changes detected.
+
+ );
+ }
+ if (screenReaderEnabled) {
+ return (
+
+ {parsedLines.map((line, index) => (
+
+ {line.type}: {line.content}
+
+ ))}
+
+ );
+ }
+
+ if (isNewFileResult) {
+ // Extract only the added lines' content
+ const addedContent = parsedLines
+ .filter((line) => line.type === 'add')
+ .map((line) => line.content)
+ .join('\n');
+ // Attempt to infer language from filename, default to plain text if no filename
+ const fileExtension = getFileExtension(filename);
+ const language = fileExtension
+ ? getLanguageFromExtension(fileExtension)
+ : null;
+ return colorizeCode({
+ code: addedContent,
+ language,
+ availableHeight: disableTruncation
+ ? undefined
+ : availableTerminalHeight,
+ maxWidth: terminalWidth,
+ theme,
+ settings,
+ disableColor,
+ paddingX,
+ disableTruncation,
+ });
+ } else {
+ const key = filename ? `diff-box-${filename}` : undefined;
+
+ return (
+
+ {renderDiffLines({
+ parsedLines,
+ filename,
+ tabWidth,
+ terminalWidth,
+ disableColor,
+ })}
+
+ );
+ }
+ }, [
+ diffContent,
+ parsedLines,
+ screenReaderEnabled,
+ isNewFileResult,
+ filename,
+ availableTerminalHeight,
+ terminalWidth,
+ theme,
+ settings,
+ tabWidth,
+ disableColor,
+ paddingX,
+ disableTruncation,
+ ]);
+
+ return renderedOutput;
+};
+
+export const isNewFile = (parsedLines: DiffLine[]): boolean => {
+ if (parsedLines.length === 0) return false;
+ return parsedLines.every(
+ (line) =>
+ line.type === 'add' ||
+ line.type === 'hunk' ||
+ line.type === 'other' ||
+ line.content.startsWith('diff --git') ||
+ line.content.startsWith('new file mode'),
+ );
+};
+
+export interface RenderDiffLinesOptions {
+ parsedLines: DiffLine[];
+ filename?: string;
+ tabWidth?: number;
+ terminalWidth: number;
+ disableColor?: boolean;
+}
+
+export const renderDiffLines = ({
+ parsedLines,
+ filename,
+ tabWidth = DEFAULT_TAB_WIDTH,
+ terminalWidth,
+ disableColor = false,
+}: RenderDiffLinesOptions): React.ReactNode[] => {
+ // 1. Normalize whitespace (replace tabs with spaces) *before* further processing
+ const normalizedLines = parsedLines.map((line) => ({
+ ...line,
+ content: line.content.replace(/\t/g, ' '.repeat(tabWidth)),
+ }));
+
+ // Filter out non-displayable lines (hunks, potentially 'other') using the normalized list
+ const displayableLines = normalizedLines.filter(
+ (l) => l.type !== 'hunk' && l.type !== 'other',
+ );
+
+ if (displayableLines.length === 0) {
+ return [
+
+ No changes detected.
+ ,
+ ];
+ }
+
+ const maxLineNumber = Math.max(
+ 0,
+ ...displayableLines.map((l) => l.oldLine ?? 0),
+ ...displayableLines.map((l) => l.newLine ?? 0),
+ );
+ const gutterWidth = Math.max(1, maxLineNumber.toString().length);
+
+ const fileExtension = getFileExtension(filename);
+ const language = fileExtension
+ ? getLanguageFromExtension(fileExtension)
+ : null;
+
+ // Calculate the minimum indentation across all displayable lines
+ let baseIndentation = Infinity; // Start high to find the minimum
+ for (const line of displayableLines) {
+ // Only consider lines with actual content for indentation calculation
+ if (line.content.trim() === '') continue;
+
+ const firstCharIndex = line.content.search(/\S/); // Find index of first non-whitespace char
+ const currentIndent = firstCharIndex === -1 ? 0 : firstCharIndex; // Indent is 0 if no non-whitespace found
+ baseIndentation = Math.min(baseIndentation, currentIndent);
+ }
+ // If baseIndentation remained Infinity (e.g., no displayable lines with content), default to 0
+ if (!isFinite(baseIndentation)) {
+ baseIndentation = 0;
+ }
+
+ let lastLineNumber: number | null = null;
+ const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
+
+ const content = displayableLines.reduce(
+ (acc, line, index) => {
+ // Determine the relevant line number for gap calculation based on type
+ let relevantLineNumberForGapCalc: number | null = null;
+ if (line.type === 'add' || line.type === 'context') {
+ relevantLineNumberForGapCalc = line.newLine ?? null;
+ } else if (line.type === 'del') {
+ // For deletions, the gap is typically in relation to the original file's line numbering
+ relevantLineNumberForGapCalc = line.oldLine ?? null;
+ }
+
+ if (
+ lastLineNumber !== null &&
+ relevantLineNumberForGapCalc !== null &&
+ relevantLineNumberForGapCalc >
+ lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1
+ ) {
+ acc.push(
+
+
+ ,
+ );
+ }
+
+ const lineKey = `diff-line-${index}`;
+ let gutterNumStr = '';
+ let prefixSymbol = ' ';
+
+ switch (line.type) {
+ case 'add':
+ gutterNumStr = (line.newLine ?? '').toString();
+ prefixSymbol = '+';
+ lastLineNumber = line.newLine ?? null;
+ break;
+ case 'del':
+ gutterNumStr = (line.oldLine ?? '').toString();
+ prefixSymbol = '-';
+ // For deletions, update lastLineNumber based on oldLine if it's advancing.
+ // This helps manage gaps correctly if there are multiple consecutive deletions
+ // or if a deletion is followed by a context line far away in the original file.
+ if (line.oldLine !== undefined) {
+ lastLineNumber = line.oldLine;
+ }
+ break;
+ case 'context':
+ gutterNumStr = (line.newLine ?? '').toString();
+ prefixSymbol = ' ';
+ lastLineNumber = line.newLine ?? null;
+ break;
+ default:
+ return acc;
+ }
+
+ const displayContent = line.content.substring(baseIndentation);
+
+ const backgroundColor = disableColor
+ ? undefined
+ : line.type === 'add'
+ ? semanticTheme.background.diff.added
+ : line.type === 'del'
+ ? semanticTheme.background.diff.removed
+ : undefined;
+
+ const gutterColor = disableColor
+ ? undefined
+ : semanticTheme.text.secondary;
+
+ const symbolColor = disableColor
+ ? undefined
+ : line.type === 'add'
+ ? semanticTheme.status.success
+ : line.type === 'del'
+ ? semanticTheme.status.error
+ : undefined;
+
+ acc.push(
+
+
+ {gutterNumStr}
+
+ {line.type === 'context' ? (
+ <>
+ {prefixSymbol}
+
+ {colorizeLine(
+ displayContent,
+ language,
+ undefined,
+ disableColor,
+ )}
+
+ >
+ ) : (
+
+ {prefixSymbol}{' '}
+ {colorizeLine(displayContent, language, undefined, disableColor)}
+
+ )}
+ ,
+ );
+ return acc;
+ },
+ [],
+ );
+
+ return content;
+};
+
+const getLanguageFromExtension = (extension: string): string | null => {
+ const languageMap: { [key: string]: string } = {
+ js: 'javascript',
+ ts: 'typescript',
+ py: 'python',
+ json: 'json',
+ css: 'css',
+ html: 'html',
+ sh: 'bash',
+ md: 'markdown',
+ yaml: 'yaml',
+ yml: 'yaml',
+ txt: 'plaintext',
+ java: 'java',
+ c: 'c',
+ cpp: 'cpp',
+ rb: 'ruby',
+ };
+ return languageMap[extension] || null; // Return null if extension not found
+};
diff --git a/packages/cli/src/ui/components/messages/ErrorMessage.test.tsx b/packages/cli/src/ui/components/messages/ErrorMessage.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f0df1d173d4bf9004e1ae199a5bc68e2554fd9d2
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/ErrorMessage.test.tsx
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../../test-utils/render.js';
+import { ErrorMessage } from './ErrorMessage.js';
+import { describe, it, expect } from 'vitest';
+
+describe('ErrorMessage', () => {
+ it('renders with the correct prefix and text', async () => {
+ const { lastFrame, unmount } = await render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders multiline error messages', async () => {
+ const message = 'Error line 1\nError line 2';
+ const { lastFrame, unmount } = await render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/ErrorMessage.tsx b/packages/cli/src/ui/components/messages/ErrorMessage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..71794ee41c9d752b377bae0f32cc17bdcdd73697
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/ErrorMessage.tsx
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Text, Box } from 'ink';
+import { theme } from '../../semantic-colors.js';
+
+interface ErrorMessageProps {
+ text: string;
+}
+
+export const ErrorMessage: React.FC = ({ text }) => {
+ const prefix = '✕ ';
+ const prefixWidth = prefix.length;
+
+ return (
+
+
+ {prefix}
+
+
+
+ {text}
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/messages/ExportSessionMessage.test.tsx b/packages/cli/src/ui/components/messages/ExportSessionMessage.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..24a53a39c2943ed62a40010949cd32f7740b6f17
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/ExportSessionMessage.test.tsx
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as path from 'node:path';
+import { render } from '../../../test-utils/render.js';
+import { Text } from 'ink';
+import { describe, it, expect, vi } from 'vitest';
+import { ExportSessionMessage } from './ExportSessionMessage.js';
+
+vi.mock('../CliSpinner.js', () => ({
+ CliSpinner: () => [spinner],
+}));
+
+describe('ExportSessionMessage', () => {
+ it('renders pending state correctly', async () => {
+ const { lastFrame } = await render(
+ ,
+ );
+ expect(lastFrame()).toContain('[spinner]');
+ expect(lastFrame()).toContain('Exporting session...');
+ });
+
+ it('renders success state correctly', async () => {
+ const testPath = path.join(path.sep, 'path', 'to', 'session.json');
+ const { lastFrame } = await render(
+ ,
+ );
+ expect(lastFrame()).toContain('✓');
+ expect(lastFrame()).toContain(
+ `Successfully exported session to ${testPath}`,
+ );
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/ExportSessionMessage.tsx b/packages/cli/src/ui/components/messages/ExportSessionMessage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fdc49a86b693999ad5db07cbed3cc8ed798441a8
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/ExportSessionMessage.tsx
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { JSX } from 'react';
+import { Box, Text } from 'ink';
+import type { ExportSessionProps } from '../../types.js';
+import { CliSpinner } from '../CliSpinner.js';
+import { theme } from '../../semantic-colors.js';
+
+export interface ExportSessionDisplayProps {
+ exportSession: ExportSessionProps;
+}
+
+/*
+ * Export session messages appear when the /export-session command is run, and show a loading spinner
+ * while export is in progress, followed by a success message.
+ */
+export function ExportSessionMessage({
+ exportSession,
+}: ExportSessionDisplayProps): JSX.Element {
+ const { isPending, targetPath } = exportSession;
+
+ return (
+
+
+ {isPending ? (
+
+ ) : (
+ ✓
+ )}
+
+
+
+ {isPending
+ ? 'Exporting session...'
+ : `Successfully exported session to ${targetPath}`}
+
+
+
+ );
+}
diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.test.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b02eab67babf98894a99613920efd061103dcd02
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/GeminiMessage.test.tsx
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { GeminiMessage } from './GeminiMessage.js';
+import { StreamingState } from '../../types.js';
+import { renderWithProviders } from '../../../test-utils/render.js';
+
+describe(' - Raw Markdown Display Snapshots', () => {
+ const baseProps = {
+ text: 'Test **bold** and `code` markdown\n\n```javascript\nconst x = 1;\n```',
+ isPending: false,
+ terminalWidth: 80,
+ };
+
+ it.each([
+ { renderMarkdown: true, description: '(default)' },
+ {
+ renderMarkdown: false,
+ description: '(raw markdown with syntax highlighting, no line numbers)',
+ },
+ ])(
+ 'renders with renderMarkdown=$renderMarkdown $description',
+ async ({ renderMarkdown }) => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { renderMarkdown, streamingState: StreamingState.Idle },
+ },
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ },
+ );
+
+ it.each([{ renderMarkdown: true }, { renderMarkdown: false }])(
+ 'renders pending state with renderMarkdown=$renderMarkdown',
+ async ({ renderMarkdown }) => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { renderMarkdown, streamingState: StreamingState.Idle },
+ },
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ },
+ );
+
+ it('wraps long lines correctly in raw markdown mode', async () => {
+ const terminalWidth = 20;
+ const text =
+ 'This is a long line that should wrap correctly without truncation';
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { renderMarkdown: false, streamingState: StreamingState.Idle },
+ },
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bc2246d23f4b42627ac976b44fcc1924d6d28995
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Text, Box } from 'ink';
+import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
+import { theme } from '../../semantic-colors.js';
+import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
+import { useUIState } from '../../contexts/UIStateContext.js';
+
+interface GeminiMessageProps {
+ text: string;
+ isPending: boolean;
+ availableTerminalHeight?: number;
+ terminalWidth: number;
+}
+
+export const GeminiMessage: React.FC = ({
+ text,
+ isPending,
+ availableTerminalHeight,
+ terminalWidth,
+}) => {
+ const { renderMarkdown } = useUIState();
+ const prefix = '✦ ';
+ const prefixWidth = prefix.length;
+
+ return (
+
+
+
+ {prefix}
+
+
+
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1aed5e1950ea8a5a6bd8b16f5be654ab16cfb88c
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box } from 'ink';
+import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
+import { useUIState } from '../../contexts/UIStateContext.js';
+
+interface GeminiMessageContentProps {
+ text: string;
+ isPending: boolean;
+ availableTerminalHeight?: number;
+ terminalWidth: number;
+}
+
+/*
+ * Gemini message content is a semi-hacked component. The intention is to represent a partial
+ * of GeminiMessage and is only used when a response gets too long. In that instance messages
+ * are split into multiple GeminiMessageContent's to enable the root component in
+ * App.tsx to be as performant as humanly possible.
+ */
+export const GeminiMessageContent: React.FC = ({
+ text,
+ isPending,
+ availableTerminalHeight,
+ terminalWidth,
+}) => {
+ const { renderMarkdown } = useUIState();
+ const originalPrefix = '✦ ';
+ const prefixWidth = originalPrefix.length;
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/messages/HintMessage.test.tsx b/packages/cli/src/ui/components/messages/HintMessage.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..528b2211b6bdb7779b2e1641adcf946a95243dc6
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/HintMessage.test.tsx
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderWithProviders } from '../../../test-utils/render.js';
+import { HintMessage } from './HintMessage.js';
+import { describe, it, expect, vi } from 'vitest';
+import { makeFakeConfig } from '@google/gemini-cli-core';
+
+describe('HintMessage', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+
+ it('renders normal hint message with correct prefix', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ { width: 80 },
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('💡');
+ expect(output).toContain('Steering Hint: Try this instead');
+ unmount();
+ });
+
+ describe('with NO_COLOR set', () => {
+ beforeEach(() => {
+ vi.stubEnv('NO_COLOR', '1');
+ });
+
+ it('uses margins instead of background blocks when NO_COLOR is set', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ { width: 80, config: makeFakeConfig({ useBackgroundColor: true }) },
+ );
+ const output = lastFrame();
+
+ // In NO_COLOR mode, the block characters (▄/▀) should NOT be present.
+ expect(output).not.toContain('▄');
+ expect(output).not.toContain('▀');
+
+ const lines = output.split('\n').filter((l) => l.trim() !== '');
+ expect(lines).toHaveLength(1);
+ expect(lines[0]).toContain('💡');
+ expect(lines[0]).toContain('Steering Hint: Try this instead');
+
+ expect(output).toMatchSnapshot();
+
+ unmount();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/HintMessage.tsx b/packages/cli/src/ui/components/messages/HintMessage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e0b8699d6b2a579e63782e007bfe6fe6281f7eda
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/HintMessage.tsx
@@ -0,0 +1,55 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Text, Box } from 'ink';
+import { theme } from '../../semantic-colors.js';
+import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js';
+import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
+import { useConfig } from '../../contexts/ConfigContext.js';
+
+interface HintMessageProps {
+ text: string;
+}
+
+export const HintMessage: React.FC = ({ text }) => {
+ const prefix = '💡 ';
+ const prefixWidth = prefix.length;
+ const config = useConfig();
+ const useBackgroundColorSetting = config.getUseBackgroundColor();
+ const useBackgroundColor =
+ useBackgroundColorSetting && !!theme.background.message;
+
+ return (
+
+
+
+
+ {prefix}
+
+
+
+
+ {`Steering Hint: ${text}`}
+
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/messages/InfoMessage.test.tsx b/packages/cli/src/ui/components/messages/InfoMessage.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..80a0c1a11c7ac153d1661faaf95854bfc2bc2aeb
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/InfoMessage.test.tsx
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../../test-utils/render.js';
+import { InfoMessage } from './InfoMessage.js';
+import { describe, it, expect } from 'vitest';
+
+describe('InfoMessage', () => {
+ it('renders with the correct default prefix and text', async () => {
+ const { lastFrame, unmount } = await render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders with a custom icon', async () => {
+ const { lastFrame, unmount } = await render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders multiline info messages', async () => {
+ const message = 'Info line 1\nInfo line 2';
+ const { lastFrame, unmount } = await render();
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/InfoMessage.tsx b/packages/cli/src/ui/components/messages/InfoMessage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..126e3b1f2a363da8061d04b9ef5664b64140f495
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/InfoMessage.tsx
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Text, Box } from 'ink';
+import { theme } from '../../semantic-colors.js';
+import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
+
+interface InfoMessageProps {
+ text: string;
+ secondaryText?: string;
+ source?: string;
+ icon?: string;
+ color?: string;
+ marginBottom?: number;
+}
+
+export const InfoMessage: React.FC = ({
+ text,
+ secondaryText,
+ source,
+ icon,
+ color,
+ marginBottom,
+}) => {
+ color ??= theme.status.warning;
+ const prefix = icon ?? 'ℹ ';
+ const prefixWidth = prefix.length;
+
+ return (
+
+
+ {prefix}
+
+
+ {text.split('\n').map((line, index) => (
+
+
+ {index === text.split('\n').length - 1 && secondaryText && (
+ {secondaryText}
+ )}
+ {index === text.split('\n').length - 1 && source && (
+ [{source}]
+ )}
+
+ ))}
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/messages/ModelMessage.tsx b/packages/cli/src/ui/components/messages/ModelMessage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b313dab6f123ca38409dfd56519b861c9bef6291
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/ModelMessage.tsx
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Text, Box } from 'ink';
+import { theme } from '../../semantic-colors.js';
+import { getDisplayString } from '@google/gemini-cli-core';
+
+interface ModelMessageProps {
+ model: string;
+}
+
+export const ModelMessage: React.FC = ({ model }) => (
+
+
+ Responding with {getDisplayString(model)}
+
+
+);
diff --git a/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2b09401e55f65cbc3e8577a8279ad2a6c122b98a
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
+import type {
+ SerializableConfirmationDetails,
+ Config,
+} from '@google/gemini-cli-core';
+import { initializeShellParsers } from '@google/gemini-cli-core';
+import { renderWithProviders } from '../../../test-utils/render.js';
+
+describe('ToolConfirmationMessage Redirection', () => {
+ beforeAll(async () => {
+ await initializeShellParsers();
+ });
+
+ const mockConfig = {
+ isTrustedFolder: () => true,
+ getIdeMode: () => false,
+ getDisableAlwaysAllow: () => false,
+ getApprovalMode: () => 'default',
+ } as unknown as Config;
+
+ it('should display redirection warning and tip for redirected commands', async () => {
+ const confirmationDetails: SerializableConfirmationDetails = {
+ type: 'exec',
+ title: 'Confirm Shell Command',
+ command: 'echo "hello" > test.txt',
+ rootCommand: 'echo, redirection (>)',
+ rootCommands: ['echo'],
+ };
+
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ const output = lastFrame();
+ expect(output).toMatchSnapshot();
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..09906495dd1e3887603112a4f9d55228ba87db4e
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
@@ -0,0 +1,420 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { act } from 'react';
+import {
+ ShellToolMessage,
+ type ShellToolMessageProps,
+} from './ShellToolMessage.js';
+import { StreamingState } from '../../types.js';
+import {
+ type Config,
+ SHELL_TOOL_NAME,
+ CoreToolCallStatus,
+} from '@google/gemini-cli-core';
+import { renderWithProviders } from '../../../test-utils/render.js';
+import { createMockSettings } from '../../../test-utils/settings.js';
+import { makeFakeConfig } from '@google/gemini-cli-core';
+import { waitFor } from '../../../test-utils/async.js';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
+import {
+ SHELL_CONTENT_OVERHEAD,
+ TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
+} from '../../utils/toolLayoutUtils.js';
+
+describe('', () => {
+ const baseProps: ShellToolMessageProps = {
+ callId: 'tool-123',
+ name: SHELL_COMMAND_NAME,
+ description: 'A shell command',
+ resultDisplay: 'Test result',
+ status: CoreToolCallStatus.Executing,
+ terminalWidth: 80,
+ confirmationDetails: undefined,
+ emphasis: 'medium',
+ isFirst: true,
+ borderColor: 'green',
+ borderDimColor: false,
+ isExpandable: false,
+ config: {
+ getEnableInteractiveShell: () => true,
+ } as unknown as Config,
+ };
+
+ const LONG_OUTPUT = Array.from(
+ { length: 100 },
+ (_, i) => `Line ${i + 1}`,
+ ).join('\n');
+
+ const mockSetEmbeddedShellFocused = vi.fn();
+ const uiActions = {
+ setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ describe('interactive shell focus', () => {
+ it.each([
+ ['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME],
+ ['SHELL_TOOL_NAME', SHELL_TOOL_NAME],
+ ])('clicks inside the shell area sets focus for %s', async (_, name) => {
+ const { lastFrame, simulateClick, unmount, waitUntilReady } =
+ await renderWithProviders(
+ ,
+ { uiActions, mouseEventsEnabled: true },
+ );
+
+ await waitUntilReady();
+ expect(lastFrame()).toContain('A shell command');
+
+ await simulateClick(2, 2);
+
+ await waitFor(() => {
+ expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true);
+ });
+ unmount();
+ });
+
+ it('resets focus when shell finishes', async () => {
+ let updateStatus: (s: CoreToolCallStatus) => void = () => {};
+
+ const Wrapper = () => {
+ const [status, setStatus] = React.useState(
+ CoreToolCallStatus.Executing,
+ );
+ updateStatus = setStatus;
+ return ;
+ };
+
+ const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ uiState: {
+ streamingState: StreamingState.Idle,
+ embeddedShellFocused: true,
+ activePtyId: 1,
+ },
+ },
+ );
+
+ // Verify it is initially focused
+ await waitUntilReady();
+ expect(lastFrame()).toContain('(Shift+Tab to unfocus)');
+
+ // Now update status to Success
+ await act(async () => {
+ updateStatus(CoreToolCallStatus.Success);
+ });
+
+ // Should call setEmbeddedShellFocused(false) because isThisShellFocused became false
+ await waitFor(() => {
+ expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
+ expect(lastFrame()).not.toContain('(Shift+Tab to unfocus)');
+ });
+ unmount();
+ });
+ });
+
+ describe('Snapshots', () => {
+ it.each([
+ [
+ 'renders in Executing state',
+ { status: CoreToolCallStatus.Executing },
+ undefined,
+ ],
+ [
+ 'renders in Success state (history mode)',
+ { status: CoreToolCallStatus.Success },
+ undefined,
+ ],
+ [
+ 'renders in Error state',
+ { status: CoreToolCallStatus.Error, resultDisplay: 'Error output' },
+ undefined,
+ ],
+ [
+ 'renders in Cancelled state with partial output',
+ {
+ status: CoreToolCallStatus.Cancelled,
+ resultDisplay: 'Partial output before cancellation',
+ },
+ undefined,
+ ],
+ [
+ 'renders in Alternate Buffer mode while focused',
+ {
+ status: CoreToolCallStatus.Executing,
+ ptyId: 1,
+ },
+ {
+ config: makeFakeConfig({ useAlternateBuffer: true }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
+ uiState: {
+ embeddedShellFocused: true,
+ activePtyId: 1,
+ },
+ },
+ ],
+ [
+ 'renders in Alternate Buffer mode while unfocused',
+ {
+ status: CoreToolCallStatus.Executing,
+ ptyId: 1,
+ },
+ {
+ config: makeFakeConfig({ useAlternateBuffer: true }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
+ uiState: {
+ embeddedShellFocused: false,
+ activePtyId: 1,
+ },
+ },
+ ],
+ ])('%s', async (_, props, options) => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ { uiActions, ...options },
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+ });
+
+ describe('Height Constraints', () => {
+ it.each([
+ [
+ 'respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES',
+ 10,
+ 10 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, // 7 (Header height is 3, but calculation uses reserved=3)
+ false,
+ true,
+ false,
+ ],
+ [
+ 'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
+ 100,
+ ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, // 11
+ false,
+ true,
+ false,
+ ],
+ [
+ 'uses full availableTerminalHeight when focused in alternate buffer mode',
+ 100,
+ 100 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, // 97
+ true,
+ false,
+ false,
+ ],
+ [
+ 'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
+ undefined,
+ ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, // 11
+ false,
+ true,
+ false,
+ ],
+ ])(
+ '%s',
+ async (
+ _,
+ availableTerminalHeight,
+ expectedMaxLines,
+ focused,
+ constrainHeight,
+ isExpandable,
+ ) => {
+ const { lastFrame, waitUntilReady, unmount } =
+ await renderWithProviders(
+ ,
+ {
+ uiActions,
+ config: makeFakeConfig({ useAlternateBuffer: true }),
+ settings: createMockSettings({
+ ui: { useAlternateBuffer: true },
+ }),
+ uiState: {
+ activePtyId: focused ? 1 : 2,
+ embeddedShellFocused: focused,
+ constrainHeight,
+ },
+ },
+ );
+
+ await waitUntilReady();
+
+ const frame = lastFrame();
+ expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
+ expect(frame).toMatchSnapshot();
+ unmount();
+ },
+ );
+
+ it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
+ const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ config: makeFakeConfig({ useAlternateBuffer: false }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
+ uiState: {
+ constrainHeight: false,
+ terminalHeight: 200,
+ },
+ },
+ );
+
+ await waitUntilReady();
+ const frame = lastFrame();
+ // Since it's Executing, it might still constrain to ACTIVE_SHELL_MAX_LINES (10)
+ // Actually let's just assert on the behaviour that happens right now (which is 100 lines because we removed the terminalBuffer check)
+ expect(frame.match(/Line \d+/g)?.length).toBe(100);
+ unmount();
+ });
+
+ it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
+ const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ config: makeFakeConfig({ useAlternateBuffer: true }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
+ uiState: {
+ constrainHeight: false,
+ },
+ },
+ );
+
+ await waitUntilReady();
+ const frame = lastFrame();
+ // Should show all 100 lines because constrainHeight is false and isExpandable is true
+ expect(frame.match(/Line \d+/g)?.length).toBe(100);
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
+ const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ config: makeFakeConfig({ useAlternateBuffer: true }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
+ uiState: {
+ constrainHeight: false,
+ },
+ },
+ );
+
+ await waitUntilReady();
+ const frame = lastFrame();
+ // Should still be constrained to 11 (15 - 4) because isExpandable is false
+ expect(frame.match(/Line \d+/g)?.length).toBe(
+ ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+ });
+
+ describe('Header Expansion', () => {
+ const LONG_DESCRIPTION = 'very long '.repeat(20);
+
+ it('truncates header by default', async () => {
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ { uiActions },
+ );
+
+ await waitUntilReady();
+ const output = lastFrame();
+ // Should be a single line header
+ expect(output.split('\n')[1]).toContain(SHELL_COMMAND_NAME); // name
+ // We check if it's truncated. In our ToolInfo, it's height 1.
+ // The StickyHeader adds some structure, but the ToolInfo Box is inside.
+ });
+
+ it('expands header when availableTerminalHeight is undefined', async () => {
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ { uiActions },
+ );
+
+ await waitUntilReady();
+ const output = lastFrame();
+ // When expanded, the header (ToolInfo) should wrap and take multiple lines.
+ // Since it's at the top, we check if the first few lines contain parts of the description.
+ const lines = output.split('\n');
+ expect(lines.length).toBeGreaterThan(5);
+ });
+
+ it('expands header when isExpanded is true in context', async () => {
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ toolActions: {
+ isExpanded: (id: string) => id === baseProps.callId,
+ },
+ },
+ );
+
+ await waitUntilReady();
+ const output = lastFrame();
+ // Should be expanded due to context
+ expect(output.split('\n').length).toBeGreaterThan(5);
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..db950a6e51089897c2643ce0ea7bc47e38c4f2fa
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
@@ -0,0 +1,222 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { Box, type DOMElement } from 'ink';
+import { ShellInputPrompt } from '../ShellInputPrompt.js';
+import { StickyHeader } from '../StickyHeader.js';
+import { useUIActions } from '../../contexts/UIActionsContext.js';
+import { useMouseClick } from '../../hooks/useMouseClick.js';
+import { ToolResultDisplay } from './ToolResultDisplay.js';
+import {
+ ToolStatusIndicator,
+ ToolInfo,
+ TrailingIndicator,
+ isThisShellFocusable as checkIsShellFocusable,
+ isThisShellFocused as checkIsShellFocused,
+ useFocusHint,
+ FocusHint,
+} from './ToolShared.js';
+import type { ToolMessageProps } from './ToolMessage.js';
+import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
+import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
+import { useUIState } from '../../contexts/UIStateContext.js';
+import { useToolActions } from '../../contexts/ToolActionsContext.js';
+import {
+ type Config,
+ ShellExecutionService,
+ CoreToolCallStatus,
+} from '@google/gemini-cli-core';
+import {
+ calculateShellMaxLines,
+ calculateToolContentMaxLines,
+ SHELL_CONTENT_OVERHEAD,
+} from '../../utils/toolLayoutUtils.js';
+
+export interface ShellToolMessageProps extends ToolMessageProps {
+ config?: Config;
+ isExpandable?: boolean;
+}
+
+export const ShellToolMessage: React.FC = ({
+ callId,
+ name,
+ description,
+ resultDisplay,
+ status,
+ availableTerminalHeight,
+ terminalWidth,
+ emphasis = 'medium',
+ renderOutputAsMarkdown = true,
+ ptyId,
+ config,
+ isFirst,
+ borderColor,
+ borderDimColor,
+ isExpandable,
+ originalRequestName,
+}) => {
+ const { isExpanded: isExpandedInContext } = useToolActions();
+
+ const isExpanded =
+ (isExpandedInContext ? isExpandedInContext(callId) : false) ||
+ availableTerminalHeight === undefined;
+
+ const {
+ activePtyId: activeShellPtyId,
+ embeddedShellFocused,
+ constrainHeight,
+ } = useUIState();
+ const isAlternateBuffer = useAlternateBuffer();
+
+ const isThisShellFocused = checkIsShellFocused(
+ name,
+ status,
+ ptyId,
+ activeShellPtyId,
+ embeddedShellFocused,
+ );
+
+ const maxLines = calculateShellMaxLines({
+ status,
+ isAlternateBuffer,
+ isThisShellFocused,
+ availableTerminalHeight,
+ constrainHeight,
+ isExpandable,
+ });
+
+ const availableHeight = calculateToolContentMaxLines({
+ availableTerminalHeight,
+ isAlternateBuffer,
+ maxLinesLimit: maxLines,
+ });
+
+ React.useEffect(() => {
+ const isExecuting = status === CoreToolCallStatus.Executing;
+ if (isExecuting && ptyId) {
+ try {
+ const childWidth = terminalWidth - 4; // account for padding and borders
+ const finalHeight =
+ availableHeight ?? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD;
+
+ ShellExecutionService.resizePty(
+ ptyId,
+ Math.max(1, childWidth),
+ Math.max(1, finalHeight),
+ );
+ } catch (e) {
+ if (
+ !(
+ e instanceof Error &&
+ e.message.includes('Cannot resize a pty that has already exited')
+ )
+ ) {
+ throw e;
+ }
+ }
+ }
+ }, [ptyId, status, terminalWidth, availableHeight]);
+
+ const { setEmbeddedShellFocused } = useUIActions();
+ const wasFocusedRef = React.useRef(false);
+
+ React.useEffect(() => {
+ if (isThisShellFocused) {
+ wasFocusedRef.current = true;
+ } else if (wasFocusedRef.current) {
+ if (embeddedShellFocused) {
+ setEmbeddedShellFocused(false);
+ }
+ wasFocusedRef.current = false;
+ }
+ }, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
+
+ const headerRef = React.useRef(null);
+ const contentRef = React.useRef(null);
+
+ // The shell is focusable if it's the shell command, it's executing, and the interactive shell is enabled.
+ const isThisShellFocusable = checkIsShellFocusable(name, status, config);
+
+ const handleFocus = () => {
+ if (isThisShellFocusable) {
+ setEmbeddedShellFocused(true);
+ }
+ };
+
+ useMouseClick(headerRef, handleFocus, { isActive: !!isThisShellFocusable });
+ useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
+
+ const { shouldShowFocusHint } = useFocusHint(
+ isThisShellFocusable,
+ isThisShellFocused,
+ resultDisplay,
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {emphasis === 'high' && }
+
+
+
+
+ {isThisShellFocused && config && (
+
+ )}
+
+ >
+ );
+};
diff --git a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1a3572a82aef76a85b87e8282f8df91b8c266a7d
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx
@@ -0,0 +1,131 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import { waitFor } from '../../../test-utils/async.js';
+import { renderWithProviders } from '../../../test-utils/render.js';
+import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
+import {
+ Kind,
+ CoreToolCallStatus,
+ SubagentState,
+} from '@google/gemini-cli-core';
+import type { IndividualToolCallDisplay } from '../../types.js';
+import { describe, it, expect, vi } from 'vitest';
+import { Text } from 'ink';
+
+vi.mock('../../utils/MarkdownDisplay.js', () => ({
+ MarkdownDisplay: ({ text }: { text: string }) => {text},
+}));
+
+describe('', () => {
+ const mockToolCalls: IndividualToolCallDisplay[] = [
+ {
+ callId: 'call-1',
+ name: 'agent_1',
+ description: 'Test agent 1',
+ confirmationDetails: undefined,
+ status: CoreToolCallStatus.Executing,
+ kind: Kind.Agent,
+ resultDisplay: {
+ isSubagentProgress: true,
+ agentName: 'api-monitor',
+ state: SubagentState.RUNNING,
+ recentActivity: [
+ {
+ id: 'act-1',
+ type: 'tool_call',
+ status: SubagentState.RUNNING,
+ content: '',
+ displayName: 'Action Required',
+ description: 'Verify server is running',
+ },
+ ],
+ },
+ },
+ {
+ callId: 'call-2',
+ name: 'agent_2',
+ description: 'Test agent 2',
+ confirmationDetails: undefined,
+ status: CoreToolCallStatus.Success,
+ kind: Kind.Agent,
+ resultDisplay: {
+ isSubagentProgress: true,
+ agentName: 'db-manager',
+ state: SubagentState.COMPLETED,
+ result: 'Database schema validated',
+ recentActivity: [
+ {
+ id: 'act-2',
+ type: 'thought',
+ status: SubagentState.COMPLETED,
+ content: 'Database schema validated',
+ },
+ ],
+ },
+ },
+ ];
+
+ const renderSubagentGroup = async (
+ toolCallsToRender: IndividualToolCallDisplay[],
+ height?: number,
+ ) =>
+ renderWithProviders(
+ ,
+ );
+
+ it('renders nothing if there are no agent tool calls', async () => {
+ const { lastFrame } = await renderSubagentGroup([], 40);
+ expect(lastFrame({ allowEmpty: true })).toBe('');
+ });
+
+ it('renders collapsed view by default with correct agent counts and states', async () => {
+ const { lastFrame } = await renderSubagentGroup(mockToolCalls, 40);
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('expands when availableTerminalHeight is undefined', async () => {
+ const { lastFrame, rerender } = await renderSubagentGroup(
+ mockToolCalls,
+ 40,
+ );
+
+ // Default collapsed view
+ await waitFor(() => {
+ expect(lastFrame()).toContain('(ctrl+o to expand)');
+ });
+
+ // Expand view
+ rerender(
+ ,
+ );
+ await waitFor(() => {
+ expect(lastFrame()).toContain('(ctrl+o to collapse)');
+ });
+
+ // Collapse view
+ rerender(
+ ,
+ );
+ await waitFor(() => {
+ expect(lastFrame()).toContain('(ctrl+o to expand)');
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..02ff8d461bc0a59f3380629386dc07d81e61fc9c
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx
@@ -0,0 +1,272 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useEffect, useId } from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../../semantic-colors.js';
+import type { IndividualToolCallDisplay } from '../../types.js';
+import {
+ isSubagentProgress,
+ checkExhaustive,
+ type SubagentActivityItem,
+ SubagentState,
+} from '@google/gemini-cli-core';
+import {
+ SubagentProgressDisplay,
+ formatToolArgs,
+} from './SubagentProgressDisplay.js';
+import { useOverflowActions } from '../../contexts/OverflowContext.js';
+
+export interface SubagentGroupDisplayProps {
+ toolCalls: IndividualToolCallDisplay[];
+ availableTerminalHeight?: number;
+ terminalWidth: number;
+ borderColor?: string;
+ borderDimColor?: boolean;
+ isFirst?: boolean;
+ isExpandable?: boolean;
+}
+
+export const SubagentGroupDisplay: React.FC = ({
+ toolCalls,
+ availableTerminalHeight,
+ terminalWidth,
+ borderColor,
+ borderDimColor,
+ isFirst,
+ isExpandable = true,
+}) => {
+ const isExpanded = availableTerminalHeight === undefined;
+ const overflowActions = useOverflowActions();
+ const uniqueId = useId();
+ const overflowId = `subagent-${uniqueId}`;
+
+ useEffect(() => {
+ if (isExpandable && overflowActions) {
+ // Register with the global overflow system so "ctrl+o to expand" shows in the sticky footer
+ // and AppContainer passes the shortcut through.
+ overflowActions.addOverflowingId(overflowId);
+ }
+ return () => {
+ if (overflowActions) {
+ overflowActions.removeOverflowingId(overflowId);
+ }
+ };
+ }, [isExpandable, overflowActions, overflowId]);
+
+ if (toolCalls.length === 0) {
+ return null;
+ }
+
+ let headerText = '';
+ if (toolCalls.length === 1) {
+ const singleAgent = toolCalls[0].resultDisplay;
+ if (isSubagentProgress(singleAgent)) {
+ switch (singleAgent.state) {
+ case SubagentState.COMPLETED:
+ headerText = 'Agent Completed';
+ break;
+ case SubagentState.CANCELLED:
+ headerText = 'Agent Cancelled';
+ break;
+ case SubagentState.ERROR:
+ headerText = 'Agent Error';
+ break;
+ default:
+ headerText = 'Running Agent...';
+ break;
+ }
+ } else {
+ headerText = 'Running Agent...';
+ }
+ } else {
+ let completedCount = 0;
+ let runningCount = 0;
+ for (const tc of toolCalls) {
+ const progress = tc.resultDisplay;
+ if (isSubagentProgress(progress)) {
+ if (progress.state === SubagentState.COMPLETED) completedCount++;
+ else if (progress.state === SubagentState.RUNNING) runningCount++;
+ } else {
+ // It hasn't emitted progress yet, but it is "running"
+ runningCount++;
+ }
+ }
+
+ if (completedCount === toolCalls.length) {
+ headerText = `${toolCalls.length} Agents Completed`;
+ } else if (completedCount > 0) {
+ headerText = `${toolCalls.length} Agents (${runningCount} running, ${completedCount} completed)...`;
+ } else {
+ headerText = `Running ${toolCalls.length} Agents...`;
+ }
+ }
+ const toggleText = `(ctrl+o to ${isExpanded ? 'collapse' : 'expand'})`;
+
+ const renderCollapsedRow = (
+ key: string,
+ agentName: string,
+ icon: React.ReactNode,
+ content: string,
+ displayArgs?: string,
+ ) => (
+
+