diff --git a/packages/cli/src/ui/components/AboutBox.test.tsx b/packages/cli/src/ui/components/AboutBox.test.tsx
new file mode 100644
index 0000000000..b6e5968e53
--- /dev/null
+++ b/packages/cli/src/ui/components/AboutBox.test.tsx
@@ -0,0 +1,57 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { AboutBox } from './AboutBox.js';
+import { describe, it, expect, vi } from 'vitest';
+
+// Mock GIT_COMMIT_INFO
+vi.mock('../../generated/git-commit.js', () => ({
+ GIT_COMMIT_INFO: 'mock-commit-hash',
+}));
+
+describe('AboutBox', () => {
+ const defaultProps = {
+ cliVersion: '1.0.0',
+ osVersion: 'macOS',
+ sandboxEnv: 'default',
+ modelVersion: 'gemini-pro',
+ selectedAuthType: 'oauth',
+ gcpProject: '',
+ ideClient: '',
+ };
+
+ it('renders with required props', () => {
+ const { lastFrame } = render();
+ const output = lastFrame();
+ expect(output).toContain('About Gemini CLI');
+ expect(output).toContain('1.0.0');
+ expect(output).toContain('mock-commit-hash');
+ expect(output).toContain('gemini-pro');
+ expect(output).toContain('default');
+ expect(output).toContain('macOS');
+ expect(output).toContain('OAuth');
+ });
+
+ it.each([
+ ['userEmail', 'test@example.com', 'User Email'],
+ ['gcpProject', 'my-project', 'GCP Project'],
+ ['ideClient', 'vscode', 'IDE Client'],
+ ])('renders optional prop %s', (prop, value, label) => {
+ const props = { ...defaultProps, [prop]: value };
+ const { lastFrame } = render();
+ const output = lastFrame();
+ expect(output).toContain(label);
+ expect(output).toContain(value);
+ });
+
+ it('renders Auth Method correctly when not oauth', () => {
+ const props = { ...defaultProps, selectedAuthType: 'api-key' };
+ const { lastFrame } = render();
+ const output = lastFrame();
+ expect(output).toContain('api-key');
+ });
+});
diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx b/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx
new file mode 100644
index 0000000000..d71d49d2f1
--- /dev/null
+++ b/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
+import { describe, it, expect } from 'vitest';
+import { ApprovalMode } from '@google/gemini-cli-core';
+
+describe('AutoAcceptIndicator', () => {
+ it('renders correctly for AUTO_EDIT mode', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('accepting edits');
+ expect(output).toContain('(shift + tab to toggle)');
+ });
+
+ it('renders correctly for YOLO mode', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('YOLO mode');
+ expect(output).toContain('(ctrl + y to toggle)');
+ });
+
+ it('renders nothing for DEFAULT mode', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).not.toContain('accepting edits');
+ expect(output).not.toContain('YOLO mode');
+ });
+});
diff --git a/packages/cli/src/ui/components/Banner.test.tsx b/packages/cli/src/ui/components/Banner.test.tsx
new file mode 100644
index 0000000000..ec50b25510
--- /dev/null
+++ b/packages/cli/src/ui/components/Banner.test.tsx
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { Banner } from './Banner.js';
+import { describe, it, expect } from 'vitest';
+
+describe('Banner', () => {
+ it.each([
+ ['warning mode', true, 'Warning Message'],
+ ['info mode', false, 'Info Message'],
+ ])('renders in %s', (_, isWarning, text) => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('handles newlines in text', () => {
+ const text = 'Line 1\\nLine 2';
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx
new file mode 100644
index 0000000000..ed4a60a1de
--- /dev/null
+++ b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { act } from 'react';
+import { render } from '../../test-utils/render.js';
+import { ConfigInitDisplay } from './ConfigInitDisplay.js';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { AppEvent } from '../../utils/events.js';
+import { MCPServerStatus, type McpClient } from '@google/gemini-cli-core';
+import { Text } from 'ink';
+
+// Mock GeminiSpinner
+vi.mock('./GeminiRespondingSpinner.js', () => ({
+ GeminiSpinner: () => Spinner,
+}));
+
+// Mock appEvents
+const { mockOn, mockOff, mockEmit } = vi.hoisted(() => ({
+ mockOn: vi.fn(),
+ mockOff: vi.fn(),
+ mockEmit: vi.fn(),
+}));
+
+vi.mock('../../utils/events.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ appEvents: {
+ on: mockOn,
+ off: mockOff,
+ emit: mockEmit,
+ },
+ };
+});
+
+describe('ConfigInitDisplay', () => {
+ beforeEach(() => {
+ mockOn.mockClear();
+ mockOff.mockClear();
+ mockEmit.mockClear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders initial state', () => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('updates message on McpClientUpdate event', async () => {
+ let listener: ((clients?: Map) => void) | undefined;
+ mockOn.mockImplementation((event, fn) => {
+ if (event === AppEvent.McpClientUpdate) {
+ listener = fn;
+ }
+ });
+
+ const { lastFrame } = render();
+
+ // Wait for listener to be registered
+ await vi.waitFor(() => {
+ if (!listener) throw new Error('Listener not registered yet');
+ });
+
+ const mockClient1 = {
+ getStatus: () => MCPServerStatus.CONNECTED,
+ } as McpClient;
+ const mockClient2 = {
+ getStatus: () => MCPServerStatus.CONNECTING,
+ } as McpClient;
+ const clients = new Map([
+ ['server1', mockClient1],
+ ['server2', mockClient2],
+ ]);
+
+ // Trigger the listener manually since we mocked the event emitter
+ act(() => {
+ listener!(clients);
+ });
+
+ // Wait for the UI to update
+ await vi.waitFor(() => {
+ expect(lastFrame()).toMatchSnapshot();
+ });
+ });
+
+ it('handles empty clients map', async () => {
+ let listener: ((clients?: Map) => void) | undefined;
+ mockOn.mockImplementation((event, fn) => {
+ if (event === AppEvent.McpClientUpdate) {
+ listener = fn;
+ }
+ });
+
+ const { lastFrame } = render();
+
+ await vi.waitFor(() => {
+ if (!listener) throw new Error('Listener not registered yet');
+ });
+
+ if (listener) {
+ const safeListener = listener;
+ act(() => {
+ safeListener(new Map());
+ });
+ }
+
+ await vi.waitFor(() => {
+ expect(lastFrame()).toMatchSnapshot();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx
new file mode 100644
index 0000000000..aacb90e1e3
--- /dev/null
+++ b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx
@@ -0,0 +1,27 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
+import { describe, it, expect } from 'vitest';
+
+describe('ConsoleSummaryDisplay', () => {
+ it('renders nothing when errorCount is 0', () => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toBe('');
+ });
+
+ it.each([
+ [1, '1 error'],
+ [5, '5 errors'],
+ ])('renders correct message for %i errors', (count, expectedText) => {
+ const { lastFrame } = render();
+ const output = lastFrame();
+ expect(output).toContain(expectedText);
+ expect(output).toContain('โ');
+ expect(output).toContain('(F12 for details)');
+ });
+});
diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx
new file mode 100644
index 0000000000..caa81ee968
--- /dev/null
+++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { ContextUsageDisplay } from './ContextUsageDisplay.js';
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('@google/gemini-cli-core', () => ({
+ tokenLimit: () => 10000,
+}));
+
+vi.mock('../../config/settings.js', () => ({
+ DEFAULT_MODEL_CONFIGS: {},
+ LoadedSettings: class {
+ constructor() {
+ // this.merged = {};
+ }
+ },
+}));
+
+describe('ContextUsageDisplay', () => {
+ it('renders correct percentage left', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('50% context left');
+ });
+
+ it('renders short label when terminal width is small', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('80%');
+ expect(output).not.toContain('context left');
+ });
+
+ it('renders 0% when full', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('0% context left');
+ });
+});
diff --git a/packages/cli/src/ui/components/CopyModeWarning.test.tsx b/packages/cli/src/ui/components/CopyModeWarning.test.tsx
new file mode 100644
index 0000000000..63ca84369f
--- /dev/null
+++ b/packages/cli/src/ui/components/CopyModeWarning.test.tsx
@@ -0,0 +1,37 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { CopyModeWarning } from './CopyModeWarning.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { useUIState, type UIState } from '../contexts/UIStateContext.js';
+
+vi.mock('../contexts/UIStateContext.js');
+
+describe('CopyModeWarning', () => {
+ const mockUseUIState = vi.mocked(useUIState);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders nothing when copy mode is disabled', () => {
+ mockUseUIState.mockReturnValue({
+ copyModeEnabled: false,
+ } as unknown as UIState);
+ const { lastFrame } = render();
+ expect(lastFrame()).toBe('');
+ });
+
+ it('renders warning when copy mode is enabled', () => {
+ mockUseUIState.mockReturnValue({
+ copyModeEnabled: true,
+ } as unknown as UIState);
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('In Copy Mode');
+ expect(lastFrame()).toContain('Press any key to exit');
+ });
+});
diff --git a/packages/cli/src/ui/components/DebugProfiler.test.tsx b/packages/cli/src/ui/components/DebugProfiler.test.tsx
index 164b90962a..b60419be8a 100644
--- a/packages/cli/src/ui/components/DebugProfiler.test.tsx
+++ b/packages/cli/src/ui/components/DebugProfiler.test.tsx
@@ -8,12 +8,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { appEvents, AppEvent } from '../../utils/events.js';
import {
profiler,
+ DebugProfiler,
ACTION_TIMESTAMP_CAPACITY,
FRAME_TIMESTAMP_CAPACITY,
} from './DebugProfiler.js';
+import { render } from '../../test-utils/render.js';
+import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { FixedDeque } from 'mnemonist';
import { debugState } from '../debug.js';
+vi.mock('../contexts/UIStateContext.js', () => ({
+ useUIState: vi.fn(),
+}));
+
describe('DebugProfiler', () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -214,3 +221,49 @@ describe('DebugProfiler', () => {
expect(profiler.totalIdleFrames).toBe(0);
});
});
+
+describe('DebugProfiler Component', () => {
+ beforeEach(() => {
+ // Reset the mock implementation before each test
+ vi.mocked(useUIState).mockReturnValue({
+ showDebugProfiler: false,
+ constrainHeight: false,
+ } as unknown as UIState);
+
+ // Mock process.stdin and stdout
+ // We need to be careful not to break the test runner's own output
+ // So we might want to skip mocking them if they are not strictly needed for the simple render test
+ // or mock them safely.
+ // For now, let's assume the component uses them in useEffect.
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return null when showDebugProfiler is false', () => {
+ vi.mocked(useUIState).mockReturnValue({
+ showDebugProfiler: false,
+ constrainHeight: false,
+ } as unknown as UIState);
+ const { lastFrame } = render();
+ expect(lastFrame()).toBe('');
+ });
+
+ it('should render stats when showDebugProfiler is true', () => {
+ vi.mocked(useUIState).mockReturnValue({
+ showDebugProfiler: true,
+ constrainHeight: false,
+ } as unknown as UIState);
+ profiler.numFrames = 10;
+ profiler.totalIdleFrames = 5;
+ profiler.totalFlickerFrames = 2;
+
+ const { lastFrame } = render();
+ const output = lastFrame();
+
+ expect(output).toContain('Renders: 10 (total)');
+ expect(output).toContain('5 (idle)');
+ expect(output).toContain('2 (flicker)');
+ });
+});
diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx
new file mode 100644
index 0000000000..203b2364c7
--- /dev/null
+++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
+import { describe, it, expect, vi } from 'vitest';
+import type { ConsoleMessageItem } from '../types.js';
+import { Box } from 'ink';
+import type React from 'react';
+
+vi.mock('./shared/ScrollableList.js', () => ({
+ ScrollableList: ({
+ data,
+ renderItem,
+ }: {
+ data: unknown[];
+ renderItem: (props: { item: unknown }) => React.ReactNode;
+ }) => (
+
+ {data.map((item: unknown, index: number) => (
+ {renderItem({ item })}
+ ))}
+
+ ),
+}));
+
+describe('DetailedMessagesDisplay', () => {
+ it('renders nothing when messages are empty', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toBe('');
+ });
+
+ it('renders messages correctly', () => {
+ const messages: ConsoleMessageItem[] = [
+ { type: 'log', content: 'Log message', count: 1 },
+ { type: 'warn', content: 'Warning message', count: 1 },
+ { type: 'error', content: 'Error message', count: 1 },
+ { type: 'debug', content: 'Debug message', count: 1 },
+ ];
+
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('Debug Console');
+ expect(output).toContain('Log message');
+ expect(output).toContain('Warning message');
+ expect(output).toContain('Error message');
+ expect(output).toContain('Debug message');
+
+ // Check for icons
+ expect(output).toContain('โน');
+ expect(output).toContain('โ ');
+ expect(output).toContain('โ');
+ expect(output).toContain('๐');
+ });
+
+ it('renders message counts', () => {
+ const messages: ConsoleMessageItem[] = [
+ { type: 'log', content: 'Repeated message', count: 5 },
+ ];
+
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('Repeated message');
+ expect(output).toContain('(x5)');
+ });
+});
diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx
new file mode 100644
index 0000000000..7477cc3cf4
--- /dev/null
+++ b/packages/cli/src/ui/components/DialogManager.test.tsx
@@ -0,0 +1,180 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderWithProviders } from '../../test-utils/render.js';
+import { DialogManager } from './DialogManager.js';
+import { describe, it, expect, vi } from 'vitest';
+import { Text } from 'ink';
+import { type UIState } from '../contexts/UIStateContext.js';
+import { type RestartReason } from '../hooks/useIdeTrustListener.js';
+import { type IdeInfo } from '@google/gemini-cli-core';
+import { type ShellConfirmationRequest } from '../types.js';
+
+// Mock child components
+vi.mock('../IdeIntegrationNudge.js', () => ({
+ IdeIntegrationNudge: () => IdeIntegrationNudge,
+}));
+vi.mock('./LoopDetectionConfirmation.js', () => ({
+ LoopDetectionConfirmation: () => LoopDetectionConfirmation,
+}));
+vi.mock('./FolderTrustDialog.js', () => ({
+ FolderTrustDialog: () => FolderTrustDialog,
+}));
+vi.mock('./ShellConfirmationDialog.js', () => ({
+ ShellConfirmationDialog: () => ShellConfirmationDialog,
+}));
+vi.mock('./ConsentPrompt.js', () => ({
+ ConsentPrompt: () => ConsentPrompt,
+}));
+vi.mock('./ThemeDialog.js', () => ({
+ ThemeDialog: () => ThemeDialog,
+}));
+vi.mock('./SettingsDialog.js', () => ({
+ SettingsDialog: () => SettingsDialog,
+}));
+vi.mock('../auth/AuthInProgress.js', () => ({
+ AuthInProgress: () => AuthInProgress,
+}));
+vi.mock('../auth/AuthDialog.js', () => ({
+ AuthDialog: () => AuthDialog,
+}));
+vi.mock('../auth/ApiAuthDialog.js', () => ({
+ ApiAuthDialog: () => ApiAuthDialog,
+}));
+vi.mock('./EditorSettingsDialog.js', () => ({
+ EditorSettingsDialog: () => EditorSettingsDialog,
+}));
+vi.mock('../privacy/PrivacyNotice.js', () => ({
+ PrivacyNotice: () => PrivacyNotice,
+}));
+vi.mock('./ProQuotaDialog.js', () => ({
+ ProQuotaDialog: () => ProQuotaDialog,
+}));
+vi.mock('./PermissionsModifyTrustDialog.js', () => ({
+ PermissionsModifyTrustDialog: () => PermissionsModifyTrustDialog,
+}));
+vi.mock('./ModelDialog.js', () => ({
+ ModelDialog: () => ModelDialog,
+}));
+vi.mock('./IdeTrustChangeDialog.js', () => ({
+ IdeTrustChangeDialog: () => IdeTrustChangeDialog,
+}));
+
+describe('DialogManager', () => {
+ const defaultProps = {
+ addItem: vi.fn(),
+ terminalWidth: 100,
+ };
+
+ const baseUiState = {
+ constrainHeight: false,
+ terminalHeight: 24,
+ staticExtraHeight: 0,
+ mainAreaWidth: 80,
+ confirmUpdateExtensionRequests: [],
+ showIdeRestartPrompt: false,
+ proQuotaRequest: null,
+ shouldShowIdePrompt: false,
+ isFolderTrustDialogOpen: false,
+ shellConfirmationRequest: null,
+ loopDetectionConfirmationRequest: null,
+ confirmationRequest: null,
+ isThemeDialogOpen: false,
+ isSettingsDialogOpen: false,
+ isModelDialogOpen: false,
+ isAuthenticating: false,
+ isAwaitingApiKeyInput: false,
+ isAuthDialogOpen: false,
+ isEditorDialogOpen: false,
+ showPrivacyNotice: false,
+ isPermissionsDialogOpen: false,
+ };
+
+ it('renders nothing by default', () => {
+ const { lastFrame } = renderWithProviders(
+ ,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ { uiState: baseUiState as any },
+ );
+ expect(lastFrame()).toBe('');
+ });
+
+ const testCases: Array<[Partial, string]> = [
+ [
+ {
+ showIdeRestartPrompt: true,
+ ideTrustRestartReason: 'update' as RestartReason,
+ },
+ 'IdeTrustChangeDialog',
+ ],
+ [
+ {
+ proQuotaRequest: {
+ failedModel: 'a',
+ fallbackModel: 'b',
+ message: 'c',
+ isTerminalQuotaError: false,
+ resolve: vi.fn(),
+ },
+ },
+ 'ProQuotaDialog',
+ ],
+ [
+ {
+ shouldShowIdePrompt: true,
+ currentIDE: { name: 'vscode', version: '1.0' } as unknown as IdeInfo,
+ },
+ 'IdeIntegrationNudge',
+ ],
+ [{ isFolderTrustDialogOpen: true }, 'FolderTrustDialog'],
+ [
+ {
+ shellConfirmationRequest: {
+ commands: [],
+ onConfirm: vi.fn(),
+ } as unknown as ShellConfirmationRequest,
+ },
+ 'ShellConfirmationDialog',
+ ],
+ [
+ { loopDetectionConfirmationRequest: { onComplete: vi.fn() } },
+ 'LoopDetectionConfirmation',
+ ],
+ [
+ { confirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
+ 'ConsentPrompt',
+ ],
+ [
+ {
+ confirmUpdateExtensionRequests: [{ prompt: 'foo', onConfirm: vi.fn() }],
+ },
+ 'ConsentPrompt',
+ ],
+ [{ isThemeDialogOpen: true }, 'ThemeDialog'],
+ [{ isSettingsDialogOpen: true }, 'SettingsDialog'],
+ [{ isModelDialogOpen: true }, 'ModelDialog'],
+ [{ isAuthenticating: true }, 'AuthInProgress'],
+ [{ isAwaitingApiKeyInput: true }, 'ApiAuthDialog'],
+ [{ isAuthDialogOpen: true }, 'AuthDialog'],
+ [{ isEditorDialogOpen: true }, 'EditorSettingsDialog'],
+ [{ showPrivacyNotice: true }, 'PrivacyNotice'],
+ [{ isPermissionsDialogOpen: true }, 'PermissionsModifyTrustDialog'],
+ ];
+
+ it.each(testCases)(
+ 'renders %s when state is %o',
+ (uiStateOverride, expectedComponent) => {
+ const { lastFrame } = renderWithProviders(
+ ,
+ {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ uiState: { ...baseUiState, ...uiStateOverride } as any,
+ },
+ );
+ expect(lastFrame()).toContain(expectedComponent);
+ },
+ );
+});
diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx
new file mode 100644
index 0000000000..56638a17ba
--- /dev/null
+++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx
@@ -0,0 +1,167 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { EditorSettingsDialog } from './EditorSettingsDialog.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { SettingScope } from '../../config/settings.js';
+import type { LoadedSettings } from '../../config/settings.js';
+import { KeypressProvider } from '../contexts/KeypressContext.js';
+import { act } from 'react';
+import { waitFor } from '../../test-utils/async.js';
+
+// Mock editorSettingsManager
+vi.mock('../editors/editorSettingsManager.js', () => ({
+ editorSettingsManager: {
+ getAvailableEditorDisplays: () => [
+ { name: 'VS Code', type: 'vscode', disabled: false },
+ { name: 'Vim', type: 'vim', disabled: false },
+ ],
+ },
+}));
+
+describe('EditorSettingsDialog', () => {
+ const mockSettings = {
+ forScope: (scope: string) => ({
+ settings: {
+ general: {
+ preferredEditor: scope === SettingScope.User ? 'vscode' : undefined,
+ },
+ },
+ }),
+ merged: {
+ general: {
+ preferredEditor: 'vscode',
+ },
+ },
+ } as unknown as LoadedSettings;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const renderWithProvider = (ui: React.ReactNode) =>
+ render({ui});
+
+ it('renders correctly', () => {
+ const { lastFrame } = renderWithProvider(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('calls onSelect when an editor is selected', () => {
+ const onSelect = vi.fn();
+ const { lastFrame } = renderWithProvider(
+ ,
+ );
+
+ expect(lastFrame()).toContain('VS Code');
+ });
+
+ it('switches focus between editor and scope sections on Tab', async () => {
+ const { lastFrame, stdin } = renderWithProvider(
+ ,
+ );
+
+ // Initial focus on editor
+ expect(lastFrame()).toContain('> Select Editor');
+ expect(lastFrame()).not.toContain('> Apply To');
+
+ // Press Tab
+ await act(async () => {
+ stdin.write('\t');
+ });
+
+ // Focus should be on scope
+ await waitFor(() => {
+ const frame = lastFrame() || '';
+ if (!frame.includes('> Apply To')) {
+ console.log(
+ 'Waiting for scope focus. Current frame:',
+ JSON.stringify(frame),
+ );
+ }
+ expect(frame).toContain('> Apply To');
+ });
+ expect(lastFrame()).toContain(' Select Editor');
+
+ // Press Tab again
+ await act(async () => {
+ stdin.write('\t');
+ });
+
+ // Focus should be back on editor
+ await waitFor(() => {
+ expect(lastFrame()).toContain('> Select Editor');
+ });
+ });
+
+ it('calls onExit when Escape is pressed', async () => {
+ const onExit = vi.fn();
+ const { stdin } = renderWithProvider(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\u001B'); // Escape
+ });
+
+ await waitFor(() => {
+ expect(onExit).toHaveBeenCalled();
+ });
+ });
+
+ it('shows modified message when setting exists in other scope', () => {
+ const settingsWithOtherScope = {
+ forScope: (_scope: string) => ({
+ settings: {
+ general: {
+ preferredEditor: 'vscode', // Both scopes have it set
+ },
+ },
+ }),
+ merged: {
+ general: {
+ preferredEditor: 'vscode',
+ },
+ },
+ } as unknown as LoadedSettings;
+
+ const { lastFrame } = renderWithProvider(
+ ,
+ );
+
+ const frame = lastFrame() || '';
+ if (!frame.includes('(Also modified')) {
+ console.log(
+ 'Modified message test failure. Frame:',
+ JSON.stringify(frame),
+ );
+ }
+ expect(frame).toContain('(Also modified');
+ });
+});
diff --git a/packages/cli/src/ui/components/ExitWarning.test.tsx b/packages/cli/src/ui/components/ExitWarning.test.tsx
new file mode 100644
index 0000000000..df91560cf0
--- /dev/null
+++ b/packages/cli/src/ui/components/ExitWarning.test.tsx
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { ExitWarning } from './ExitWarning.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { useUIState, type UIState } from '../contexts/UIStateContext.js';
+
+vi.mock('../contexts/UIStateContext.js');
+
+describe('ExitWarning', () => {
+ const mockUseUIState = vi.mocked(useUIState);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders nothing by default', () => {
+ mockUseUIState.mockReturnValue({
+ dialogsVisible: false,
+ ctrlCPressedOnce: false,
+ ctrlDPressedOnce: false,
+ } as unknown as UIState);
+ const { lastFrame } = render();
+ expect(lastFrame()).toBe('');
+ });
+
+ it('renders Ctrl+C warning when pressed once and dialogs visible', () => {
+ mockUseUIState.mockReturnValue({
+ dialogsVisible: true,
+ ctrlCPressedOnce: true,
+ ctrlDPressedOnce: false,
+ } as unknown as UIState);
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('Press Ctrl+C again to exit');
+ });
+
+ it('renders Ctrl+D warning when pressed once and dialogs visible', () => {
+ mockUseUIState.mockReturnValue({
+ dialogsVisible: true,
+ ctrlCPressedOnce: false,
+ ctrlDPressedOnce: true,
+ } as unknown as UIState);
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('Press Ctrl+D again to exit');
+ });
+
+ it('renders nothing if dialogs are not visible', () => {
+ mockUseUIState.mockReturnValue({
+ dialogsVisible: false,
+ ctrlCPressedOnce: true,
+ ctrlDPressedOnce: true,
+ } as unknown as UIState);
+ const { lastFrame } = render();
+ expect(lastFrame()).toBe('');
+ });
+});
diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx
new file mode 100644
index 0000000000..88f6c3fd13
--- /dev/null
+++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { useStreamingContext } from '../contexts/StreamingContext.js';
+import { useIsScreenReaderEnabled } from 'ink';
+import { StreamingState } from '../types.js';
+import {
+ SCREEN_READER_LOADING,
+ SCREEN_READER_RESPONDING,
+} from '../textConstants.js';
+
+vi.mock('../contexts/StreamingContext.js');
+vi.mock('ink', async () => {
+ const actual = await vi.importActual('ink');
+ return {
+ ...actual,
+ useIsScreenReaderEnabled: vi.fn(),
+ };
+});
+
+describe('GeminiRespondingSpinner', () => {
+ const mockUseStreamingContext = vi.mocked(useStreamingContext);
+ const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseIsScreenReaderEnabled.mockReturnValue(false);
+ });
+
+ it('renders spinner when responding', () => {
+ mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
+ const { lastFrame } = render();
+ // Spinner output varies, but it shouldn't be empty
+ expect(lastFrame()).not.toBe('');
+ });
+
+ it('renders screen reader text when responding and screen reader enabled', () => {
+ mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
+ mockUseIsScreenReaderEnabled.mockReturnValue(true);
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain(SCREEN_READER_RESPONDING);
+ });
+
+ it('renders nothing when not responding and no non-responding display', () => {
+ mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
+ const { lastFrame } = render();
+ expect(lastFrame()).toBe('');
+ });
+
+ it('renders non-responding display when provided', () => {
+ mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toContain('Waiting...');
+ });
+
+ it('renders screen reader loading text when non-responding display provided and screen reader enabled', () => {
+ mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
+ mockUseIsScreenReaderEnabled.mockReturnValue(true);
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toContain(SCREEN_READER_LOADING);
+ });
+});
diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx
new file mode 100644
index 0000000000..4bd823503c
--- /dev/null
+++ b/packages/cli/src/ui/components/MainContent.test.tsx
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { MainContent } from './MainContent.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { Box, Text } from 'ink';
+import type React from 'react';
+
+// Mock dependencies
+vi.mock('../contexts/AppContext.js', () => ({
+ useAppContext: () => ({
+ version: '1.0.0',
+ }),
+}));
+
+vi.mock('../contexts/UIStateContext.js', () => ({
+ useUIState: () => ({
+ history: [
+ { id: 1, role: 'user', content: 'Hello' },
+ { id: 2, role: 'model', content: 'Hi there' },
+ ],
+ pendingHistoryItems: [],
+ mainAreaWidth: 80,
+ staticAreaMaxItemHeight: 20,
+ availableTerminalHeight: 24,
+ slashCommands: [],
+ constrainHeight: false,
+ isEditorDialogOpen: false,
+ activePtyId: undefined,
+ embeddedShellFocused: false,
+ historyRemountKey: 0,
+ }),
+}));
+
+vi.mock('../hooks/useAlternateBuffer.js', () => ({
+ useAlternateBuffer: vi.fn(),
+}));
+
+vi.mock('./HistoryItemDisplay.js', () => ({
+ HistoryItemDisplay: ({ item }: { item: { content: string } }) => (
+
+ HistoryItem: {item.content}
+
+ ),
+}));
+
+vi.mock('./AppHeader.js', () => ({
+ AppHeader: () => AppHeader,
+}));
+
+vi.mock('./ShowMoreLines.js', () => ({
+ ShowMoreLines: () => ShowMoreLines,
+}));
+
+vi.mock('./shared/ScrollableList.js', () => ({
+ ScrollableList: ({
+ data,
+ renderItem,
+ }: {
+ data: unknown[];
+ renderItem: (props: { item: unknown }) => React.JSX.Element;
+ }) => (
+
+ ScrollableList
+ {data.map((item: unknown, index: number) => (
+ {renderItem({ item })}
+ ))}
+
+ ),
+ SCROLL_TO_ITEM_END: 0,
+}));
+
+import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
+
+describe('MainContent', () => {
+ beforeEach(() => {
+ vi.mocked(useAlternateBuffer).mockReturnValue(false);
+ });
+
+ it('renders in normal buffer mode', () => {
+ const { lastFrame } = render();
+ const output = lastFrame();
+
+ expect(output).toContain('AppHeader');
+ expect(output).toContain('HistoryItem: Hello');
+ expect(output).toContain('HistoryItem: Hi there');
+ });
+
+ it('renders in alternate buffer mode', () => {
+ vi.mocked(useAlternateBuffer).mockReturnValue(true);
+ const { lastFrame } = render();
+ const output = lastFrame();
+
+ expect(output).toContain('ScrollableList');
+ expect(output).toContain('AppHeader');
+ expect(output).toContain('HistoryItem: Hello');
+ expect(output).toContain('HistoryItem: Hi there');
+ });
+});
diff --git a/packages/cli/src/ui/components/MemoryUsageDisplay.test.tsx b/packages/cli/src/ui/components/MemoryUsageDisplay.test.tsx
new file mode 100644
index 0000000000..de61fe22fa
--- /dev/null
+++ b/packages/cli/src/ui/components/MemoryUsageDisplay.test.tsx
@@ -0,0 +1,55 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import process from 'node:process';
+import { act } from 'react';
+
+describe('MemoryUsageDisplay', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ // Mock process.memoryUsage
+ vi.spyOn(process, 'memoryUsage').mockReturnValue({
+ rss: 1024 * 1024 * 50, // 50MB
+ heapTotal: 0,
+ heapUsed: 0,
+ external: 0,
+ arrayBuffers: 0,
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('renders memory usage', () => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('50.0 MB');
+ });
+
+ it('updates memory usage over time', async () => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('50.0 MB');
+
+ vi.mocked(process.memoryUsage).mockReturnValue({
+ rss: 1024 * 1024 * 100, // 100MB
+ heapTotal: 0,
+ heapUsed: 0,
+ external: 0,
+ arrayBuffers: 0,
+ });
+
+ await act(async () => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(lastFrame()).toContain('100.0 MB');
+ });
+});
diff --git a/packages/cli/src/ui/components/Notifications.test.tsx b/packages/cli/src/ui/components/Notifications.test.tsx
new file mode 100644
index 0000000000..6e0c178e86
--- /dev/null
+++ b/packages/cli/src/ui/components/Notifications.test.tsx
@@ -0,0 +1,183 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { Notifications } from './Notifications.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { useAppContext, type AppState } from '../contexts/AppContext.js';
+import { useUIState, type UIState } from '../contexts/UIStateContext.js';
+import { useIsScreenReaderEnabled } from 'ink';
+import * as fs from 'node:fs/promises';
+import { act } from 'react';
+
+// Mock dependencies
+vi.mock('../contexts/AppContext.js');
+vi.mock('../contexts/UIStateContext.js');
+vi.mock('ink', async () => {
+ const actual = await vi.importActual('ink');
+ return {
+ ...actual,
+ useIsScreenReaderEnabled: vi.fn(),
+ };
+});
+vi.mock('node:fs/promises', async () => {
+ const actual = await vi.importActual('node:fs/promises');
+ return {
+ ...actual,
+ access: vi.fn(),
+ writeFile: vi.fn(),
+ mkdir: vi.fn().mockResolvedValue(undefined),
+ };
+});
+vi.mock('node:os', () => ({
+ default: {
+ homedir: () => '/mock/home',
+ },
+}));
+
+vi.mock('node:path', async () => {
+ const actual = await vi.importActual('node:path');
+ return {
+ ...actual,
+ default: actual.posix,
+ };
+});
+
+vi.mock('@google/gemini-cli-core', () => ({
+ GEMINI_DIR: '.gemini',
+ Storage: {
+ getGlobalTempDir: () => '/mock/temp',
+ },
+}));
+
+vi.mock('../../config/settings.js', () => ({
+ DEFAULT_MODEL_CONFIGS: {},
+ LoadedSettings: class {
+ constructor() {
+ // this.merged = {};
+ }
+ },
+}));
+
+describe('Notifications', () => {
+ const mockUseAppContext = vi.mocked(useAppContext);
+ const mockUseUIState = vi.mocked(useUIState);
+ const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
+ const mockFsAccess = vi.mocked(fs.access);
+ const mockFsWriteFile = vi.mocked(fs.writeFile);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseAppContext.mockReturnValue({
+ startupWarnings: [],
+ version: '1.0.0',
+ } as AppState);
+ mockUseUIState.mockReturnValue({
+ initError: null,
+ streamingState: 'idle',
+ updateInfo: null,
+ } as unknown as UIState);
+ mockUseIsScreenReaderEnabled.mockReturnValue(false);
+ });
+
+ it('renders nothing when no notifications', () => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toBe('');
+ });
+
+ it.each([[['Warning 1']], [['Warning 1', 'Warning 2']]])(
+ 'renders startup warnings: %s',
+ (warnings) => {
+ mockUseAppContext.mockReturnValue({
+ startupWarnings: warnings,
+ version: '1.0.0',
+ } as AppState);
+ const { lastFrame } = render();
+ const output = lastFrame();
+ warnings.forEach((warning) => {
+ expect(output).toContain(warning);
+ });
+ },
+ );
+
+ it('renders init error', () => {
+ mockUseUIState.mockReturnValue({
+ initError: 'Something went wrong',
+ streamingState: 'idle',
+ updateInfo: null,
+ } as unknown as UIState);
+ const { lastFrame } = render();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('does not render init error when streaming', () => {
+ mockUseUIState.mockReturnValue({
+ initError: 'Something went wrong',
+ streamingState: 'responding',
+ updateInfo: null,
+ } as unknown as UIState);
+ const { lastFrame } = render();
+ expect(lastFrame()).toBe('');
+ });
+
+ it('renders update notification', () => {
+ mockUseUIState.mockReturnValue({
+ initError: null,
+ streamingState: 'idle',
+ updateInfo: { message: 'Update available' },
+ } as unknown as UIState);
+ const { lastFrame } = render();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders screen reader nudge when enabled and not seen', async () => {
+ mockUseIsScreenReaderEnabled.mockReturnValue(true);
+
+ let rejectAccess: (err: Error) => void;
+ mockFsAccess.mockImplementation(
+ () =>
+ new Promise((_, reject) => {
+ rejectAccess = reject;
+ }),
+ );
+
+ const { lastFrame } = render();
+
+ // Trigger rejection inside act
+ await act(async () => {
+ rejectAccess(new Error('File not found'));
+ });
+
+ // Wait for effect to propagate
+ await vi.waitFor(() => {
+ expect(mockFsWriteFile).toHaveBeenCalled();
+ });
+
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('does not render screen reader nudge when already seen', async () => {
+ mockUseIsScreenReaderEnabled.mockReturnValue(true);
+
+ let resolveAccess: (val: undefined) => void;
+ mockFsAccess.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveAccess = resolve;
+ }),
+ );
+
+ const { lastFrame } = render();
+
+ // Trigger resolution inside act
+ await act(async () => {
+ resolveAccess(undefined);
+ });
+
+ expect(lastFrame()).toBe('');
+ expect(mockFsWriteFile).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/cli/src/ui/components/Notifications.tsx b/packages/cli/src/ui/components/Notifications.tsx
index 05e00ed479..d956afac0d 100644
--- a/packages/cli/src/ui/components/Notifications.tsx
+++ b/packages/cli/src/ui/components/Notifications.tsx
@@ -39,7 +39,7 @@ export const Notifications = () => {
>(undefined);
useEffect(() => {
- const checkScreenReaderNudge = async () => {
+ const checkScreenReader = async () => {
try {
await fs.access(screenReaderNudgeFilePath);
setHasSeenScreenReaderNudge(true);
@@ -47,8 +47,11 @@ export const Notifications = () => {
setHasSeenScreenReaderNudge(false);
}
};
- checkScreenReaderNudge();
- }, []);
+
+ if (isScreenReaderEnabled) {
+ checkScreenReader();
+ }
+ }, [isScreenReaderEnabled]);
const showScreenReaderNudge =
isScreenReaderEnabled && hasSeenScreenReaderNudge === false;
diff --git a/packages/cli/src/ui/components/QuittingDisplay.test.tsx b/packages/cli/src/ui/components/QuittingDisplay.test.tsx
new file mode 100644
index 0000000000..177d469d8e
--- /dev/null
+++ b/packages/cli/src/ui/components/QuittingDisplay.test.tsx
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { QuittingDisplay } from './QuittingDisplay.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import React from 'react';
+import { useUIState, type UIState } from '../contexts/UIStateContext.js';
+import { useTerminalSize } from '../hooks/useTerminalSize.js';
+
+vi.mock('../contexts/UIStateContext.js');
+vi.mock('../hooks/useTerminalSize.js');
+vi.mock('./HistoryItemDisplay.js', async () => {
+ const { Text } = await vi.importActual('ink');
+ return {
+ HistoryItemDisplay: ({ item }: { item: { content: string } }) =>
+ React.createElement(Text as unknown as React.FC, null, item.content),
+ };
+});
+
+describe('QuittingDisplay', () => {
+ const mockUseUIState = vi.mocked(useUIState);
+ const mockUseTerminalSize = vi.mocked(useTerminalSize);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseTerminalSize.mockReturnValue({ rows: 20, columns: 80 });
+ });
+
+ it('renders nothing when no quitting messages', () => {
+ mockUseUIState.mockReturnValue({
+ quittingMessages: null,
+ } as unknown as UIState);
+ const { lastFrame } = render();
+ expect(lastFrame()).toBe('');
+ });
+
+ it('renders quitting messages', () => {
+ const mockMessages = [
+ { id: '1', type: 'user', content: 'Goodbye' },
+ { id: '2', type: 'model', content: 'See you later' },
+ ];
+ mockUseUIState.mockReturnValue({
+ quittingMessages: mockMessages,
+ constrainHeight: false,
+ } as unknown as UIState);
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('Goodbye');
+ expect(lastFrame()).toContain('See you later');
+ });
+});
diff --git a/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx b/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx
new file mode 100644
index 0000000000..30eaf90c25
--- /dev/null
+++ b/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx
@@ -0,0 +1,37 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
+import { describe, it, expect, afterEach } from 'vitest';
+
+describe('RawMarkdownIndicator', () => {
+ const originalPlatform = process.platform;
+
+ afterEach(() => {
+ Object.defineProperty(process, 'platform', {
+ value: originalPlatform,
+ });
+ });
+
+ it('renders correct key binding for darwin', () => {
+ Object.defineProperty(process, 'platform', {
+ value: 'darwin',
+ });
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('raw markdown mode');
+ expect(lastFrame()).toContain('option+m to toggle');
+ });
+
+ it('renders correct key binding for other platforms', () => {
+ Object.defineProperty(process, 'platform', {
+ value: 'linux',
+ });
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('raw markdown mode');
+ expect(lastFrame()).toContain('alt+m to toggle');
+ });
+});
diff --git a/packages/cli/src/ui/components/SessionBrowser.test.tsx b/packages/cli/src/ui/components/SessionBrowser.test.tsx
index e063af40aa..2a2f239d11 100644
--- a/packages/cli/src/ui/components/SessionBrowser.test.tsx
+++ b/packages/cli/src/ui/components/SessionBrowser.test.tsx
@@ -157,8 +157,7 @@ describe('SessionBrowser component', () => {
/>,
);
- expect(lastFrame()).toContain('No auto-saved conversations found.');
- expect(lastFrame()).toContain('Press q to exit');
+ expect(lastFrame()).toMatchSnapshot();
});
it('renders a list of sessions and marks current session as disabled', () => {
@@ -193,11 +192,7 @@ describe('SessionBrowser component', () => {
/>,
);
- const output = lastFrame();
- expect(output).toContain('Chat Sessions (2 total');
- expect(output).toContain('First conversation about cats');
- expect(output).toContain('Second conversation about dogs');
- expect(output).toContain('(current)');
+ expect(lastFrame()).toMatchSnapshot();
});
it('enters search mode, filters sessions, and renders match snippets', async () => {
@@ -214,6 +209,7 @@ describe('SessionBrowser component', () => {
},
],
index: 0,
+ lastUpdated: '2025-01-01T12:00:00Z',
});
const otherSession = createSession({
@@ -229,6 +225,7 @@ describe('SessionBrowser component', () => {
},
],
index: 1,
+ lastUpdated: '2025-01-01T10:00:00Z',
});
const config = createMockConfig();
@@ -259,15 +256,9 @@ describe('SessionBrowser component', () => {
}
await waitFor(() => {
- const output = lastFrame();
- expect(output).toContain('Chat Sessions (1 total, filtered');
- expect(output).toContain('Query is here');
- expect(output).not.toContain('Nothing interesting here.');
-
- expect(output).toContain('You:');
- expect(output).toContain('query');
- expect(output).toContain('(+1 more)');
+ expect(lastFrame()).toContain('Chat Sessions (1 total, filtered');
});
+ expect(lastFrame()).toMatchSnapshot();
});
it('handles keyboard navigation and resumes the selected session', () => {
@@ -276,12 +267,14 @@ describe('SessionBrowser component', () => {
file: 'one',
displayName: 'First session',
index: 0,
+ lastUpdated: '2025-01-02T12:00:00Z',
});
const session2 = createSession({
id: 'two',
file: 'two',
displayName: 'Second session',
index: 1,
+ lastUpdated: '2025-01-01T12:00:00Z',
});
const config = createMockConfig();
@@ -317,6 +310,7 @@ describe('SessionBrowser component', () => {
displayName: 'Current session',
isCurrentSession: true,
index: 0,
+ lastUpdated: '2025-01-02T12:00:00Z',
});
const otherSession = createSession({
id: 'other',
@@ -324,6 +318,7 @@ describe('SessionBrowser component', () => {
displayName: 'Other session',
isCurrentSession: false,
index: 1,
+ lastUpdated: '2025-01-01T12:00:00Z',
});
const config = createMockConfig();
@@ -364,8 +359,6 @@ describe('SessionBrowser component', () => {
/>,
);
- const output = lastFrame();
- expect(output).toContain('Error: storage failure');
- expect(output).toContain('Press q to exit');
+ expect(lastFrame()).toMatchSnapshot();
});
});
diff --git a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx
new file mode 100644
index 0000000000..815cfcadf7
--- /dev/null
+++ b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx
@@ -0,0 +1,105 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { ShellInputPrompt } from './ShellInputPrompt.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ShellExecutionService } from '@google/gemini-cli-core';
+
+// Mock useKeypress
+const mockUseKeypress = vi.fn();
+vi.mock('../hooks/useKeypress.js', () => ({
+ useKeypress: (handler: (input: unknown) => void, options?: unknown) =>
+ mockUseKeypress(handler, options),
+}));
+
+// Mock ShellExecutionService
+vi.mock('@google/gemini-cli-core', async () => {
+ const actual = await vi.importActual('@google/gemini-cli-core');
+ return {
+ ...actual,
+ ShellExecutionService: {
+ writeToPty: vi.fn(),
+ scrollPty: vi.fn(),
+ },
+ };
+});
+
+describe('ShellInputPrompt', () => {
+ const mockWriteToPty = vi.mocked(ShellExecutionService.writeToPty);
+ const mockScrollPty = vi.mocked(ShellExecutionService.scrollPty);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders nothing', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toBe('');
+ });
+
+ it.each([
+ ['a', 'a'],
+ ['b', 'b'],
+ ])('handles keypress input: %s', (name, sequence) => {
+ render();
+
+ // Get the registered handler
+ const handler = mockUseKeypress.mock.calls[0][0];
+
+ // Simulate keypress
+ handler({ name, sequence, ctrl: false, shift: false, meta: false });
+
+ expect(mockWriteToPty).toHaveBeenCalledWith(1, sequence);
+ });
+
+ it.each([
+ ['up', -1],
+ ['down', 1],
+ ])('handles scroll %s (Ctrl+Shift+%s)', (key, direction) => {
+ render();
+
+ const handler = mockUseKeypress.mock.calls[0][0];
+
+ handler({ name: key, ctrl: true, shift: true, meta: false });
+
+ expect(mockScrollPty).toHaveBeenCalledWith(1, direction);
+ });
+
+ it('does not handle input when not focused', () => {
+ render();
+
+ const handler = mockUseKeypress.mock.calls[0][0];
+
+ handler({
+ name: 'a',
+ sequence: 'a',
+ ctrl: false,
+ shift: false,
+ meta: false,
+ });
+
+ expect(mockWriteToPty).not.toHaveBeenCalled();
+ });
+
+ it('does not handle input when no active shell', () => {
+ render();
+
+ const handler = mockUseKeypress.mock.calls[0][0];
+
+ handler({
+ name: 'a',
+ sequence: 'a',
+ ctrl: false,
+ shift: false,
+ meta: false,
+ });
+
+ expect(mockWriteToPty).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/cli/src/ui/components/ShellModeIndicator.test.tsx b/packages/cli/src/ui/components/ShellModeIndicator.test.tsx
new file mode 100644
index 0000000000..73c2b5d03a
--- /dev/null
+++ b/packages/cli/src/ui/components/ShellModeIndicator.test.tsx
@@ -0,0 +1,17 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { ShellModeIndicator } from './ShellModeIndicator.js';
+import { describe, it, expect } from 'vitest';
+
+describe('ShellModeIndicator', () => {
+ it('renders correctly', () => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('shell mode enabled');
+ expect(lastFrame()).toContain('esc to disable');
+ });
+});
diff --git a/packages/cli/src/ui/components/ShowMoreLines.test.tsx b/packages/cli/src/ui/components/ShowMoreLines.test.tsx
new file mode 100644
index 0000000000..beec038bbe
--- /dev/null
+++ b/packages/cli/src/ui/components/ShowMoreLines.test.tsx
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { ShowMoreLines } from './ShowMoreLines.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { useOverflowState } from '../contexts/OverflowContext.js';
+import { useStreamingContext } from '../contexts/StreamingContext.js';
+import { StreamingState } from '../types.js';
+
+vi.mock('../contexts/OverflowContext.js');
+vi.mock('../contexts/StreamingContext.js');
+
+describe('ShowMoreLines', () => {
+ const mockUseOverflowState = vi.mocked(useOverflowState);
+ const mockUseStreamingContext = vi.mocked(useStreamingContext);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it.each([
+ [new Set(), StreamingState.Idle, true], // No overflow
+ [new Set(['1']), StreamingState.Idle, false], // Not constraining height
+ [new Set(['1']), StreamingState.Responding, true], // Streaming
+ ])(
+ 'renders nothing when: overflow=%s, streaming=%s, constrain=%s',
+ (overflowingIds, streamingState, constrainHeight) => {
+ mockUseOverflowState.mockReturnValue({ overflowingIds } as NonNullable<
+ ReturnType
+ >);
+ mockUseStreamingContext.mockReturnValue(streamingState);
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toBe('');
+ },
+ );
+
+ it.each([[StreamingState.Idle], [StreamingState.WaitingForConfirmation]])(
+ 'renders message when overflowing and state is %s',
+ (streamingState) => {
+ mockUseOverflowState.mockReturnValue({
+ overflowingIds: new Set(['1']),
+ } as NonNullable>);
+ mockUseStreamingContext.mockReturnValue(streamingState);
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('Press ctrl-s to show more lines');
+ },
+ );
+});
diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx
new file mode 100644
index 0000000000..6931268a37
--- /dev/null
+++ b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { SuggestionsDisplay } from './SuggestionsDisplay.js';
+import { describe, it, expect } from 'vitest';
+import { CommandKind } from '../commands/types.js';
+
+describe('SuggestionsDisplay', () => {
+ const mockSuggestions = [
+ { label: 'Command 1', value: 'command1', description: 'Description 1' },
+ { label: 'Command 2', value: 'command2', description: 'Description 2' },
+ { label: 'Command 3', value: 'command3', description: 'Description 3' },
+ ];
+
+ it('renders loading state', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders nothing when empty and not loading', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toBe('');
+ });
+
+ it('renders suggestions list', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('highlights active item', () => {
+ // This test relies on visual inspection or implementation details (colors)
+ // For now, we just ensure it renders without error and contains the item
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('handles scrolling', () => {
+ const manySuggestions = Array.from({ length: 20 }, (_, i) => ({
+ label: `Cmd ${i}`,
+ value: `Cmd ${i}`,
+ description: `Description ${i}`,
+ }));
+
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders MCP tag for MCP prompts', () => {
+ const mcpSuggestions = [
+ {
+ label: 'MCP Tool',
+ value: 'mcp-tool',
+ commandKind: CommandKind.MCP_PROMPT,
+ },
+ ];
+
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/ThemedGradient.test.tsx b/packages/cli/src/ui/components/ThemedGradient.test.tsx
new file mode 100644
index 0000000000..9ea194c9f9
--- /dev/null
+++ b/packages/cli/src/ui/components/ThemedGradient.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 { ThemedGradient } from './ThemedGradient.js';
+import { describe, it, expect, vi } from 'vitest';
+
+// Mock theme to control gradient
+vi.mock('../semantic-colors.js', () => ({
+ theme: {
+ ui: {
+ gradient: ['red', 'blue'],
+ },
+ text: {
+ accent: 'cyan',
+ },
+ },
+}));
+
+describe('ThemedGradient', () => {
+ it('renders children', () => {
+ const { lastFrame } = render(Hello);
+ expect(lastFrame()).toContain('Hello');
+ });
+
+ // Note: Testing actual gradient application is hard with ink-testing-library
+ // as it often renders as plain text or ANSI codes.
+ // We mainly ensure it doesn't crash and renders content.
+});
diff --git a/packages/cli/src/ui/components/Tips.test.tsx b/packages/cli/src/ui/components/Tips.test.tsx
new file mode 100644
index 0000000000..adbedb5326
--- /dev/null
+++ b/packages/cli/src/ui/components/Tips.test.tsx
@@ -0,0 +1,25 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { Tips } from './Tips.js';
+import { describe, it, expect, vi } from 'vitest';
+import type { Config } from '@google/gemini-cli-core';
+
+describe('Tips', () => {
+ it.each([
+ [0, '3. Create GEMINI.md files'],
+ [5, '3. /help for more information'],
+ ])('renders correct tips when file count is %i', (count, expectedText) => {
+ const config = {
+ getGeminiMdFileCount: vi.fn().mockReturnValue(count),
+ } as unknown as Config;
+
+ const { lastFrame } = render();
+ const output = lastFrame();
+ expect(output).toContain(expectedText);
+ });
+});
diff --git a/packages/cli/src/ui/components/UpdateNotification.test.tsx b/packages/cli/src/ui/components/UpdateNotification.test.tsx
new file mode 100644
index 0000000000..fa1632d50c
--- /dev/null
+++ b/packages/cli/src/ui/components/UpdateNotification.test.tsx
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { UpdateNotification } from './UpdateNotification.js';
+import { describe, it, expect } from 'vitest';
+
+describe('UpdateNotification', () => {
+ it('renders message', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toContain('Update available!');
+ });
+});
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 0000000000..07b9ecf5b8
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap
@@ -0,0 +1,20 @@
+// 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 warning mode 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ Warning Message โ
+โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ"
+`;
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 0000000000..9c7b7cc0d7
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap
@@ -0,0 +1,16 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ConfigInitDisplay > handles empty clients map 1`] = `
+"
+Spinner Initializing..."
+`;
+
+exports[`ConfigInitDisplay > renders initial state 1`] = `
+"
+Spinner Initializing..."
+`;
+
+exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
+"
+Spinner Connecting to MCP servers... (1/2)"
+`;
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 0000000000..dd0719a90e
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/EditorSettingsDialog.test.tsx.snap
@@ -0,0 +1,18 @@
+// 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: None. โ
+โ 2. Workspace Settings โ
+โ โ
+โ (Use Enter to select, Tab to change โ
+โ focus, Esc to close) โ
+โ โ
+โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ"
+`;
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 0000000000..32704a9313
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/Notifications.test.tsx.snap
@@ -0,0 +1,22 @@
+// 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 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__/SessionBrowser.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap
new file mode 100644
index 0000000000..efffa48b4e
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap
@@ -0,0 +1,32 @@
+// 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__/SuggestionsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap
new file mode 100644
index 0000000000..ce1640ce25
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap
@@ -0,0 +1,31 @@
+// 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/messages/ErrorMessage.test.tsx b/packages/cli/src/ui/components/messages/ErrorMessage.test.tsx
new file mode 100644
index 0000000000..1c605e0128
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/ErrorMessage.test.tsx
@@ -0,0 +1,26 @@
+/**
+ * @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', () => {
+ const { lastFrame } = render();
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders multiline error messages', () => {
+ const message = 'Error line 1\nError line 2';
+ const { lastFrame } = render();
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+});
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 0000000000..d5c3c78de3
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/InfoMessage.test.tsx
@@ -0,0 +1,35 @@
+/**
+ * @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', () => {
+ const { lastFrame } = render();
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders with a custom icon', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders multiline info messages', () => {
+ const message = 'Info line 1\nInfo line 2';
+ const { lastFrame } = render();
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
index 967a301feb..ef41d5590c 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
@@ -36,7 +36,7 @@ describe('ToolConfirmationMessage', () => {
/>,
);
- expect(lastFrame()).not.toContain('URLs to fetch:');
+ expect(lastFrame()).toMatchSnapshot();
});
it('should display urls if prompt and url are different', () => {
@@ -60,10 +60,7 @@ describe('ToolConfirmationMessage', () => {
/>,
);
- expect(lastFrame()).toContain('URLs to fetch:');
- expect(lastFrame()).toContain(
- '- https://raw.githubusercontent.com/google/gemini-react/main/README.md',
- );
+ expect(lastFrame()).toMatchSnapshot();
});
describe('with folder trust', () => {
@@ -124,7 +121,7 @@ describe('ToolConfirmationMessage', () => {
details: mcpConfirmationDetails,
alwaysAllowText: 'always allow',
},
- ])('$description', ({ details, alwaysAllowText }) => {
+ ])('$description', ({ details }) => {
it('should show "allow always" when folder is trusted', () => {
const mockConfig = {
isTrustedFolder: () => true,
@@ -140,7 +137,7 @@ describe('ToolConfirmationMessage', () => {
/>,
);
- expect(lastFrame()).toContain(alwaysAllowText);
+ expect(lastFrame()).toMatchSnapshot();
});
it('should NOT show "allow always" when folder is untrusted', () => {
@@ -158,7 +155,7 @@ describe('ToolConfirmationMessage', () => {
/>,
);
- expect(lastFrame()).not.toContain(alwaysAllowText);
+ expect(lastFrame()).toMatchSnapshot();
});
});
});
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
index 3d1b641389..983bca8669 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
@@ -107,10 +107,7 @@ describe('', () => {
StreamingState.Idle,
);
const output = lastFrame();
- expect(output).toContain('โ'); // Success indicator
- expect(output).toContain('test-tool');
- expect(output).toContain('A tool for testing');
- expect(output).toContain('MockMarkdown:Test result');
+ expect(output).toMatchSnapshot();
});
describe('ToolStatusIndicator rendering', () => {
@@ -119,7 +116,7 @@ describe('', () => {
,
StreamingState.Idle,
);
- expect(lastFrame()).toContain('โ');
+ expect(lastFrame()).toMatchSnapshot();
});
it('shows o for Pending status', () => {
@@ -127,7 +124,7 @@ describe('', () => {
,
StreamingState.Idle,
);
- expect(lastFrame()).toContain('o');
+ expect(lastFrame()).toMatchSnapshot();
});
it('shows ? for Confirming status', () => {
@@ -135,7 +132,7 @@ describe('', () => {
,
StreamingState.Idle,
);
- expect(lastFrame()).toContain('?');
+ expect(lastFrame()).toMatchSnapshot();
});
it('shows - for Canceled status', () => {
@@ -143,7 +140,7 @@ describe('', () => {
,
StreamingState.Idle,
);
- expect(lastFrame()).toContain('-');
+ expect(lastFrame()).toMatchSnapshot();
});
it('shows x for Error status', () => {
@@ -151,7 +148,7 @@ describe('', () => {
,
StreamingState.Idle,
);
- expect(lastFrame()).toContain('x');
+ expect(lastFrame()).toMatchSnapshot();
});
it('shows paused spinner for Executing status when streamingState is Idle', () => {
@@ -159,9 +156,7 @@ describe('', () => {
,
StreamingState.Idle,
);
- expect(lastFrame()).toContain('โท');
- expect(lastFrame()).not.toContain('MockRespondingSpinner');
- expect(lastFrame()).not.toContain('โ');
+ expect(lastFrame()).toMatchSnapshot();
});
it('shows paused spinner for Executing status when streamingState is WaitingForConfirmation', () => {
@@ -169,9 +164,7 @@ describe('', () => {
,
StreamingState.WaitingForConfirmation,
);
- expect(lastFrame()).toContain('โท');
- expect(lastFrame()).not.toContain('MockRespondingSpinner');
- expect(lastFrame()).not.toContain('โ');
+ expect(lastFrame()).toMatchSnapshot();
});
it('shows MockRespondingSpinner for Executing status when streamingState is Responding', () => {
@@ -179,8 +172,7 @@ describe('', () => {
,
StreamingState.Responding, // Simulate app still responding
);
- expect(lastFrame()).toContain('MockRespondingSpinner');
- expect(lastFrame()).not.toContain('โ');
+ expect(lastFrame()).toMatchSnapshot();
});
});
@@ -196,7 +188,7 @@ describe('', () => {
StreamingState.Idle,
);
// Check that the output contains the MockDiff content as part of the whole message
- expect(lastFrame()).toMatch(/MockDiff:--- a\/file\.txt/);
+ expect(lastFrame()).toMatchSnapshot();
});
it('renders emphasis correctly', () => {
@@ -205,7 +197,7 @@ describe('', () => {
StreamingState.Idle,
);
// Check for trailing indicator or specific color if applicable (Colors are not easily testable here)
- expect(highEmphasisFrame()).toContain('โ'); // Trailing indicator for high emphasis
+ expect(highEmphasisFrame()).toMatchSnapshot();
const { lastFrame: lowEmphasisFrame } = renderWithContext(
,
@@ -214,7 +206,7 @@ describe('', () => {
// For low emphasis, the name and description might be dimmed (check for dimColor if possible)
// This is harder to assert directly in text output without color checks.
// We can at least ensure it doesn't have the high emphasis indicator.
- expect(lowEmphasisFrame()).not.toContain('โ');
+ expect(lowEmphasisFrame()).toMatchSnapshot();
});
it('renders AnsiOutputText for AnsiOutput results', () => {
@@ -236,6 +228,6 @@ describe('', () => {
,
StreamingState.Idle,
);
- expect(lastFrame()).toContain('MockAnsiOutput:hello');
+ expect(lastFrame()).toMatchSnapshot();
});
});
diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx
new file mode 100644
index 0000000000..98b0af9f40
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx
@@ -0,0 +1,191 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../../test-utils/render.js';
+import { ToolResultDisplay } from './ToolResultDisplay.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { Box, Text } from 'ink';
+import type { AnsiOutput } from '@google/gemini-cli-core';
+
+// Mock child components to simplify testing
+vi.mock('./DiffRenderer.js', () => ({
+ DiffRenderer: ({
+ diffContent,
+ filename,
+ }: {
+ diffContent: string;
+ filename: string;
+ }) => (
+
+
+ DiffRenderer: {filename} - {diffContent}
+
+
+ ),
+}));
+
+vi.mock('../../utils/MarkdownDisplay.js', () => ({
+ MarkdownDisplay: ({ text }: { text: string }) => (
+
+ MarkdownDisplay: {text}
+
+ ),
+}));
+
+vi.mock('../AnsiOutput.js', () => ({
+ AnsiOutputText: ({ data }: { data: unknown }) => (
+
+ AnsiOutputText: {JSON.stringify(data)}
+
+ ),
+}));
+
+vi.mock('../shared/MaxSizedBox.js', () => ({
+ MaxSizedBox: ({ children }: { children: React.ReactNode }) => (
+
+ MaxSizedBox:
+ {children}
+
+ ),
+}));
+
+// Mock UIStateContext
+const mockUseUIState = vi.fn();
+vi.mock('../../contexts/UIStateContext.js', () => ({
+ useUIState: () => mockUseUIState(),
+}));
+
+// Mock useAlternateBuffer
+const mockUseAlternateBuffer = vi.fn();
+vi.mock('../../hooks/useAlternateBuffer.js', () => ({
+ useAlternateBuffer: () => mockUseAlternateBuffer(),
+}));
+
+describe('ToolResultDisplay', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseUIState.mockReturnValue({ renderMarkdown: true });
+ mockUseAlternateBuffer.mockReturnValue(false);
+ });
+
+ it('renders string result as markdown by default', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders string result as plain text when renderOutputAsMarkdown is false', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+
+ it('truncates very long string results', { timeout: 20000 }, () => {
+ const longString = 'a'.repeat(1000005);
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders file diff result', () => {
+ const diffResult = {
+ fileDiff: 'diff content',
+ fileName: 'test.ts',
+ };
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders ANSI output result', () => {
+ const ansiResult = {
+ text: 'ansi content',
+ };
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders nothing for todos result', () => {
+ const todoResult = {
+ todos: [],
+ };
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+
+ it('falls back to plain text if availableHeight is set and not in alternate buffer', () => {
+ mockUseAlternateBuffer.mockReturnValue(false);
+ // availableHeight calculation: 20 - 1 - 5 = 14 > 3
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ // Should force renderOutputAsMarkdown to false
+ expect(output).toMatchSnapshot();
+ });
+
+ it('keeps markdown if in alternate buffer even with availableHeight', () => {
+ mockUseAlternateBuffer.mockReturnValue(true);
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
index b02f616eb0..49002d50e9 100644
--- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
+++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
@@ -21,7 +21,7 @@ const MIN_LINES_SHOWN = 2; // show at least this many lines
// Large threshold to ensure we don't cause performance issues for very large
// outputs that will get truncated further MaxSizedBox anyway.
-const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000;
+const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 20000;
export interface ToolResultDisplayProps {
resultDisplay: string | object | undefined;
diff --git a/packages/cli/src/ui/components/messages/UserMessage.test.tsx b/packages/cli/src/ui/components/messages/UserMessage.test.tsx
new file mode 100644
index 0000000000..2f130c5469
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/UserMessage.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 { UserMessage } from './UserMessage.js';
+import { describe, it, expect, vi } from 'vitest';
+
+// Mock the commandUtils to control isSlashCommand behavior
+vi.mock('../../utils/commandUtils.js', () => ({
+ isSlashCommand: vi.fn((text: string) => text.startsWith('/')),
+}));
+
+describe('UserMessage', () => {
+ it('renders normal user message with correct prefix', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders slash command message', () => {
+ const { lastFrame } = render();
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders multiline user message', () => {
+ const message = 'Line 1\nLine 2';
+ const { lastFrame } = render();
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/WarningMessage.test.tsx b/packages/cli/src/ui/components/messages/WarningMessage.test.tsx
new file mode 100644
index 0000000000..fcb635d624
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/WarningMessage.test.tsx
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../../test-utils/render.js';
+import { WarningMessage } from './WarningMessage.js';
+import { describe, it, expect } from 'vitest';
+
+describe('WarningMessage', () => {
+ it('renders with the correct prefix and text', () => {
+ const { lastFrame } = render();
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+
+ it('renders multiline warning messages', () => {
+ const message = 'Warning line 1\nWarning line 2';
+ const { lastFrame } = render();
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ErrorMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ErrorMessage.test.tsx.snap
new file mode 100644
index 0000000000..0f5c270ae4
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ErrorMessage.test.tsx.snap
@@ -0,0 +1,12 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ErrorMessage > renders multiline error messages 1`] = `
+"โ Error line 1
+ Error line 2
+"
+`;
+
+exports[`ErrorMessage > renders with the correct prefix and text 1`] = `
+"โ Something went wrong
+"
+`;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/InfoMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/InfoMessage.test.tsx.snap
new file mode 100644
index 0000000000..47b63b7681
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/InfoMessage.test.tsx.snap
@@ -0,0 +1,17 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`InfoMessage > renders multiline info messages 1`] = `
+"
+โน Info line 1
+ Info line 2"
+`;
+
+exports[`InfoMessage > renders with a custom icon 1`] = `
+"
+โ
Custom icon test"
+`;
+
+exports[`InfoMessage > renders with the correct default prefix and text 1`] = `
+"
+โน Just so you know"
+`;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap
new file mode 100644
index 0000000000..95aa1fca13
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap
@@ -0,0 +1,123 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ToolConfirmationMessage > should display urls if prompt and url are different 1`] = `
+"fetch https://github.com/google/gemini-react/blob/main/README.md
+
+URLs to fetch:
+ - https://raw.githubusercontent.com/google/gemini-react/main/README.md
+
+Do you want to proceed?
+
+โ 1. Yes, allow once
+ 2. Yes, allow always
+ 3. No, suggest changes (esc)
+"
+`;
+
+exports[`ToolConfirmationMessage > should not display urls if prompt and url are the same 1`] = `
+"https://example.com
+
+Do you want to proceed?
+
+โ 1. Yes, allow once
+ 2. Yes, allow always
+ 3. No, suggest changes (esc)
+"
+`;
+
+exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ โ
+โ No changes detected. โ
+โ โ
+โฐโโโโโโโโโโโโโโโโโโโโโโโฏ
+
+Apply this change?
+
+โ 1. Yes, allow once
+ 2. Modify with external editor
+ 3. No, suggest changes (esc)
+"
+`;
+
+exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations' > should show "allow always" when folder is trusted 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ โ
+โ No changes detected. โ
+โ โ
+โฐโโโโโโโโโโโโโโโโโโโโโโโฏ
+
+Apply this change?
+
+โ 1. Yes, allow once
+ 2. Yes, allow always
+ 3. Modify with external editor
+ 4. No, suggest changes (esc)
+"
+`;
+
+exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
+"echo "hello"
+
+Allow execution of: 'echo'?
+
+โ 1. Yes, allow once
+ 2. No, suggest changes (esc)
+"
+`;
+
+exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should show "allow always" when folder is trusted 1`] = `
+"echo "hello"
+
+Allow execution of: 'echo'?
+
+โ 1. Yes, allow once
+ 2. Yes, allow always ...
+ 3. No, suggest changes (esc)
+"
+`;
+
+exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
+"https://example.com
+
+Do you want to proceed?
+
+โ 1. Yes, allow once
+ 2. No, suggest changes (esc)
+"
+`;
+
+exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations' > should show "allow always" when folder is trusted 1`] = `
+"https://example.com
+
+Do you want to proceed?
+
+โ 1. Yes, allow once
+ 2. Yes, allow always
+ 3. No, suggest changes (esc)
+"
+`;
+
+exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
+"MCP Server: test-server
+Tool: test-tool
+
+Allow execution of MCP tool "test-tool" from server "test-server"?
+
+โ 1. Yes, allow once
+ 2. No, suggest changes (esc)
+"
+`;
+
+exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' > should show "allow always" when folder is trusted 1`] = `
+"MCP Server: test-server
+Tool: test-tool
+
+Allow execution of MCP tool "test-tool" from server "test-server"?
+
+โ 1. Yes, allow once
+ 2. Yes, always allow tool "test-tool" from server "test-server"
+ 3. Yes, always allow all tools from server "test-server"
+ 4. No, suggest changes (esc)
+"
+`;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap
new file mode 100644
index 0000000000..fd161ce9a2
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap
@@ -0,0 +1,96 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > ToolStatusIndicator rendering > shows ? for Confirming status 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ ? test-tool A tool for testing โ
+โ โ
+โ MockMarkdown:Test result โ"
+`;
+
+exports[` > ToolStatusIndicator rendering > shows - for Canceled status 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ - test-tool A tool for testing โ
+โ โ
+โ MockMarkdown:Test result โ"
+`;
+
+exports[` > ToolStatusIndicator rendering > shows MockRespondingSpinner for Executing status when streamingState is Responding 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ MockRespondingSpinnertest-tool A tool for testing โ
+โ โ
+โ MockMarkdown:Test result โ"
+`;
+
+exports[` > ToolStatusIndicator rendering > shows o for Pending status 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ o test-tool A tool for testing โ
+โ โ
+โ MockMarkdown:Test result โ"
+`;
+
+exports[` > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is Idle 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ โท test-tool A tool for testing โ
+โ โ
+โ MockMarkdown:Test result โ"
+`;
+
+exports[` > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is WaitingForConfirmation 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ โท test-tool A tool for testing โ
+โ โ
+โ MockMarkdown:Test result โ"
+`;
+
+exports[` > ToolStatusIndicator rendering > shows x for Error status 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ x test-tool A tool for testing โ
+โ โ
+โ MockMarkdown:Test result โ"
+`;
+
+exports[` > ToolStatusIndicator rendering > shows โ for Success status 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ โ test-tool A tool for testing โ
+โ โ
+โ MockMarkdown:Test result โ"
+`;
+
+exports[` > renders AnsiOutputText for AnsiOutput results 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ โ test-tool A tool for testing โ
+โ โ
+โ MockAnsiOutput:hello โ"
+`;
+
+exports[` > renders DiffRenderer for diff results 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ โ test-tool A tool for testing โ
+โ โ
+โ MockDiff:--- a/file.txt โ
+โ +++ b/file.txt โ
+โ @@ -1 +1 @@ โ
+โ -old โ
+โ +new โ"
+`;
+
+exports[` > renders basic tool information 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ โ test-tool A tool for testing โ
+โ โ
+โ MockMarkdown:Test result โ"
+`;
+
+exports[` > renders emphasis correctly 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ โ test-tool A tool for testing โ โ
+โ โ
+โ MockMarkdown:Test result โ"
+`;
+
+exports[` > renders emphasis correctly 2`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ โ test-tool A tool for testing โ
+โ โ
+โ MockMarkdown:Test result โ"
+`;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap
new file mode 100644
index 0000000000..2919124771
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap
@@ -0,0 +1,326 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ToolResultDisplay > falls back to plain text if availableHeight is set and not in alternate buffer 1`] = `"MaxSizedBox:Some result"`;
+
+exports[`ToolResultDisplay > keeps markdown if in alternate buffer even with availableHeight 1`] = `"MarkdownDisplay: Some result"`;
+
+exports[`ToolResultDisplay > renders ANSI output result 1`] = `"AnsiOutputText: {"text":"ansi content"}"`;
+
+exports[`ToolResultDisplay > renders file diff result 1`] = `"DiffRenderer: test.ts - diff content"`;
+
+exports[`ToolResultDisplay > renders nothing for todos result 1`] = `""`;
+
+exports[`ToolResultDisplay > renders string result as markdown by default 1`] = `"MarkdownDisplay: Some result"`;
+
+exports[`ToolResultDisplay > renders string result as plain text when renderOutputAsMarkdown is false 1`] = `"MaxSizedBox:Some result"`;
+
+exports[`ToolResultDisplay > truncates very long string results 1`] = `
+"MaxSizedBo...aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+`;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap
new file mode 100644
index 0000000000..1d1e950bb3
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap
@@ -0,0 +1,20 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`UserMessage > renders multiline user message 1`] = `
+"
+> Line 1
+ Line 2
+"
+`;
+
+exports[`UserMessage > renders normal user message with correct prefix 1`] = `
+"
+> Hello Gemini
+"
+`;
+
+exports[`UserMessage > renders slash command message 1`] = `
+"
+> /help
+"
+`;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/WarningMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/WarningMessage.test.tsx.snap
new file mode 100644
index 0000000000..4ebfdcf4e0
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/WarningMessage.test.tsx.snap
@@ -0,0 +1,12 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`WarningMessage > renders multiline warning messages 1`] = `
+"
+โ Warning line 1
+ Warning line 2"
+`;
+
+exports[`WarningMessage > renders with the correct prefix and text 1`] = `
+"
+โ Watch out!"
+`;