Download packages/cli/src/ui/components/Composer.test.tsx from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 35.5 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/components/Composer.test.tsx
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/ui/components/Composer.test.tsx
-
curl -L -o Composer.test.tsx https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/components/Composer.test.tsx
35.5 kB
| /** | |
| * @license | |
| * Copyright 2026 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; | |
| import { render } from '../../test-utils/render.js'; | |
| import { act, useEffect } from 'react'; | |
| import { Box, Text } from 'ink'; | |
| import { Composer } from './Composer.js'; | |
| import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; | |
| import { | |
| UIActionsContext, | |
| type UIActions, | |
| } from '../contexts/UIActionsContext.js'; | |
| import { ConfigContext } from '../contexts/ConfigContext.js'; | |
| import { SettingsContext } from '../contexts/SettingsContext.js'; | |
| import { createMockSettings } from '../../test-utils/settings.js'; | |
| import { | |
| ApprovalMode, | |
| tokenLimit, | |
| CoreToolCallStatus, | |
| } from '@google/gemini-cli-core'; | |
| import type { Config } from '@google/gemini-cli-core'; | |
| import { StreamingState } from '../types.js'; | |
| import { TransientMessageType } from '../../utils/events.js'; | |
| import type { LoadedSettings } from '../../config/settings.js'; | |
| import type { SessionMetrics } from '../contexts/SessionContext.js'; | |
| import type { TextBuffer } from './shared/text-buffer.js'; | |
| // Mock VimModeContext hook | |
| vi.mock('../contexts/VimModeContext.js', () => ({ | |
| useVimMode: vi.fn(() => ({ | |
| vimEnabled: false, | |
| vimMode: 'INSERT', | |
| })), | |
| })); | |
| vi.mock('../hooks/useTerminalSize.js', () => ({ | |
| useTerminalSize: vi.fn(() => ({ | |
| columns: 100, | |
| rows: 24, | |
| })), | |
| })); | |
| const composerTestControls = vi.hoisted(() => ({ | |
| suggestionsVisible: false, | |
| isAlternateBuffer: false, | |
| })); | |
| // Mock child components | |
| vi.mock('./LoadingIndicator.js', () => ({ | |
| LoadingIndicator: ({ | |
| thought, | |
| thoughtLabel, | |
| }: { | |
| thought?: { subject?: string } | string; | |
| thoughtLabel?: string; | |
| }) => { | |
| const fallbackText = | |
| typeof thought === 'string' ? thought : thought?.subject; | |
| const text = thoughtLabel ?? fallbackText; | |
| return <Text>LoadingIndicator{text ? `: ${text}` : ''}</Text>; | |
| }, | |
| })); | |
| vi.mock('./StatusDisplay.js', () => ({ | |
| StatusDisplay: ({ hideContextSummary }: { hideContextSummary: boolean }) => ( | |
| <Text>StatusDisplay{hideContextSummary ? ' (hidden summary)' : ''}</Text> | |
| ), | |
| })); | |
| vi.mock('./ContextSummaryDisplay.js', () => ({ | |
| ContextSummaryDisplay: () => <Text>ContextSummaryDisplay</Text>, | |
| })); | |
| vi.mock('./HookStatusDisplay.js', () => ({ | |
| HookStatusDisplay: () => <Text>HookStatusDisplay</Text>, | |
| })); | |
| vi.mock('./ApprovalModeIndicator.js', () => ({ | |
| ApprovalModeIndicator: ({ approvalMode }: { approvalMode: ApprovalMode }) => ( | |
| <Text>ApprovalModeIndicator: {approvalMode}</Text> | |
| ), | |
| })); | |
| vi.mock('./ShellModeIndicator.js', () => ({ | |
| ShellModeIndicator: () => <Text>ShellModeIndicator</Text>, | |
| })); | |
| vi.mock('./ShortcutsHelp.js', () => ({ | |
| ShortcutsHelp: () => <Text>ShortcutsHelp</Text>, | |
| })); | |
| vi.mock('./DetailedMessagesDisplay.js', () => ({ | |
| DetailedMessagesDisplay: () => <Text>DetailedMessagesDisplay</Text>, | |
| })); | |
| vi.mock('./InputPrompt.js', () => ({ | |
| InputPrompt: ({ | |
| placeholder, | |
| onSuggestionsVisibilityChange, | |
| }: { | |
| placeholder?: string; | |
| onSuggestionsVisibilityChange?: (visible: boolean) => void; | |
| }) => { | |
| useEffect(() => { | |
| onSuggestionsVisibilityChange?.(composerTestControls.suggestionsVisible); | |
| }, [onSuggestionsVisibilityChange]); | |
| return <Text>InputPrompt: {placeholder}</Text>; | |
| }, | |
| calculatePromptWidths: vi.fn(() => ({ | |
| inputWidth: 80, | |
| suggestionsWidth: 40, | |
| containerWidth: 84, | |
| })), | |
| })); | |
| vi.mock('../hooks/useAlternateBuffer.js', () => ({ | |
| useAlternateBuffer: () => composerTestControls.isAlternateBuffer, | |
| })); | |
| vi.mock('./Footer.js', () => ({ | |
| Footer: () => <Text>Footer</Text>, | |
| })); | |
| vi.mock('./ShowMoreLines.js', () => ({ | |
| ShowMoreLines: () => <Text>ShowMoreLines</Text>, | |
| })); | |
| vi.mock('./QueuedMessageDisplay.js', () => ({ | |
| QueuedMessageDisplay: ({ messageQueue }: { messageQueue: string[] }) => { | |
| if (messageQueue.length === 0) { | |
| return null; | |
| } | |
| return ( | |
| <> | |
| {messageQueue.map((message, index) => ( | |
| <Text key={index}>{message}</Text> | |
| ))} | |
| </> | |
| ); | |
| }, | |
| })); | |
| // Mock contexts | |
| vi.mock('../contexts/OverflowContext.js', () => ({ | |
| OverflowProvider: ({ children }: { children: React.ReactNode }) => children, | |
| })); | |
| // Create mock context providers | |
| const createMockUIState = (overrides: Partial<UIState> = {}): UIState => | |
| ({ | |
| streamingState: StreamingState.Idle, | |
| isConfigInitialized: true, | |
| contextFileNames: [], | |
| showApprovalModeIndicator: ApprovalMode.DEFAULT, | |
| messageQueue: [], | |
| showErrorDetails: false, | |
| constrainHeight: false, | |
| isInputActive: true, | |
| buffer: { text: '' }, | |
| inputWidth: 80, | |
| suggestionsWidth: 40, | |
| userMessages: [], | |
| slashCommands: [], | |
| commandContext: null, | |
| shellModeActive: false, | |
| isFocused: true, | |
| thought: '', | |
| currentLoadingPhrase: '', | |
| currentTip: '', | |
| currentWittyPhrase: '', | |
| elapsedTime: 0, | |
| ctrlCPressedOnce: false, | |
| ctrlDPressedOnce: false, | |
| showEscapePrompt: false, | |
| shortcutsHelpVisible: false, | |
| cleanUiDetailsVisible: true, | |
| ideContextState: null, | |
| geminiMdFileCount: 0, | |
| renderMarkdown: true, | |
| history: [], | |
| sessionStats: { | |
| sessionId: 'test-session', | |
| sessionStartTime: new Date(), | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| metrics: {} as any, | |
| lastPromptTokenCount: 0, | |
| promptCount: 0, | |
| }, | |
| branchName: 'main', | |
| debugMessage: '', | |
| corgiMode: false, | |
| errorCount: 0, | |
| nightly: false, | |
| isTrustedFolder: true, | |
| activeHooks: [], | |
| isBackgroundTaskVisible: false, | |
| embeddedShellFocused: false, | |
| showIsExpandableHint: false, | |
| ...overrides, | |
| }) as UIState; | |
| const createMockUIActions = (): UIActions => | |
| ({ | |
| handleFinalSubmit: vi.fn(), | |
| handleClearScreen: vi.fn(), | |
| setShellModeActive: vi.fn(), | |
| setCleanUiDetailsVisible: vi.fn(), | |
| toggleCleanUiDetailsVisible: vi.fn(), | |
| revealCleanUiDetailsTemporarily: vi.fn(), | |
| onEscapePromptChange: vi.fn(), | |
| vimHandleInput: vi.fn(), | |
| setShortcutsHelpVisible: vi.fn(), | |
| }) as Partial<UIActions> as UIActions; | |
| const createMockConfig = (overrides = {}): Config => | |
| ({ | |
| getModel: vi.fn(() => 'gemini-1.5-pro'), | |
| getTargetDir: vi.fn(() => '/test/dir'), | |
| getDebugMode: vi.fn(() => false), | |
| getAccessibility: vi.fn(() => ({})), | |
| getMcpServers: vi.fn(() => ({})), | |
| isPlanEnabled: vi.fn(() => true), | |
| getToolRegistry: () => ({ | |
| getTool: vi.fn(), | |
| }), | |
| getSkillManager: () => ({ | |
| getSkills: () => [], | |
| getDisplayableSkills: () => [], | |
| }), | |
| getMcpClientManager: () => ({ | |
| getMcpServers: () => ({}), | |
| getBlockedMcpServers: () => [], | |
| }), | |
| ...overrides, | |
| }) as unknown as Config; | |
| import { QuotaContext, type QuotaState } from '../contexts/QuotaContext.js'; | |
| import { InputContext, type InputState } from '../contexts/InputContext.js'; | |
| const renderComposer = async ( | |
| uiState: UIState, | |
| settings = createMockSettings({ ui: {} }), | |
| config = createMockConfig(), | |
| uiActions = createMockUIActions(), | |
| inputStateOverrides: Partial<InputState> = {}, | |
| quotaStateOverrides: Partial<QuotaState> = {}, | |
| ) => { | |
| const inputState = { | |
| buffer: { text: '' } as unknown as TextBuffer, | |
| userMessages: [], | |
| shellModeActive: false, | |
| showEscapePrompt: false, | |
| copyModeEnabled: false, | |
| inputWidth: 80, | |
| suggestionsWidth: 40, | |
| ...(uiState as unknown as Partial<InputState>), | |
| ...inputStateOverrides, | |
| }; | |
| const quotaState: QuotaState = { | |
| userTier: undefined, | |
| stats: undefined, | |
| proQuotaRequest: null, | |
| validationRequest: null, | |
| overageMenuRequest: null, | |
| emptyWalletRequest: null, | |
| ...quotaStateOverrides, | |
| }; | |
| const result = await render( | |
| <ConfigContext.Provider value={config as unknown as Config}> | |
| <SettingsContext.Provider value={settings as unknown as LoadedSettings}> | |
| <QuotaContext.Provider value={quotaState}> | |
| <InputContext.Provider value={inputState}> | |
| <UIStateContext.Provider value={uiState}> | |
| <UIActionsContext.Provider value={uiActions}> | |
| <Composer isFocused={true} /> | |
| </UIActionsContext.Provider> | |
| </UIStateContext.Provider> | |
| </InputContext.Provider> | |
| </QuotaContext.Provider> | |
| </SettingsContext.Provider> | |
| </ConfigContext.Provider>, | |
| ); | |
| // Wait for shortcuts hint debounce if using fake timers | |
| if (vi.isFakeTimers()) { | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(250); | |
| }); | |
| } | |
| return result; | |
| }; | |
| describe('Composer', () => { | |
| beforeEach(() => { | |
| vi.useFakeTimers(); | |
| composerTestControls.suggestionsVisible = false; | |
| composerTestControls.isAlternateBuffer = false; | |
| }); | |
| afterEach(() => { | |
| vi.useRealTimers(); | |
| vi.restoreAllMocks(); | |
| }); | |
| describe('Footer Display Settings', () => { | |
| it('renders Footer by default when hideFooter is false', async () => { | |
| const uiState = createMockUIState(); | |
| const settings = createMockSettings({ ui: { hideFooter: false } }); | |
| const { lastFrame } = await renderComposer(uiState, settings); | |
| expect(lastFrame()).toContain('Footer'); | |
| }); | |
| it('does NOT render Footer when hideFooter is true', async () => { | |
| const uiState = createMockUIState(); | |
| const settings = createMockSettings({ ui: { hideFooter: true } }); | |
| const { lastFrame } = await renderComposer(uiState, settings); | |
| // Check for content that only appears IN the Footer component itself | |
| expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator | |
| expect(lastFrame()).not.toContain('(main'); // Branch name with parentheses | |
| }); | |
| it('passes correct props to Footer including vim mode when enabled', async () => { | |
| const uiState = createMockUIState({ | |
| branchName: 'feature-branch', | |
| corgiMode: true, | |
| errorCount: 2, | |
| sessionStats: { | |
| sessionId: 'test-session', | |
| sessionStartTime: new Date(), | |
| metrics: { | |
| models: {}, | |
| tools: {}, | |
| files: {}, | |
| } as SessionMetrics, | |
| lastPromptTokenCount: 150, | |
| promptCount: 5, | |
| }, | |
| }); | |
| const config = createMockConfig({ | |
| getModel: vi.fn(() => 'gemini-1.5-flash'), | |
| getTargetDir: vi.fn(() => '/project/path'), | |
| getDebugMode: vi.fn(() => true), | |
| }); | |
| const settings = createMockSettings({ | |
| ui: { | |
| hideFooter: false, | |
| showMemoryUsage: true, | |
| }, | |
| }); | |
| // Mock vim mode for this test | |
| const { useVimMode } = await import('../contexts/VimModeContext.js'); | |
| vi.mocked(useVimMode).mockReturnValueOnce({ | |
| vimEnabled: true, | |
| vimMode: 'INSERT', | |
| toggleVimEnabled: vi.fn(), | |
| setVimMode: vi.fn(), | |
| } as unknown as ReturnType<typeof useVimMode>); | |
| const { lastFrame } = await renderComposer(uiState, settings, config); | |
| expect(lastFrame()).toContain('Footer'); | |
| // Footer should be rendered with all the state passed through | |
| }); | |
| }); | |
| describe('Loading Indicator', () => { | |
| it('renders LoadingIndicator with thought when streaming', async () => { | |
| const uiState = createMockUIState({ | |
| streamingState: StreamingState.Responding, | |
| thought: { | |
| subject: 'Processing', | |
| description: 'Processing your request...', | |
| }, | |
| currentLoadingPhrase: 'Analyzing', | |
| elapsedTime: 1500, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| const output = lastFrame(); | |
| expect(output).toContain('LoadingIndicator: Processing'); | |
| }); | |
| it('renders generic thinking text in loading indicator when full inline thinking is enabled', async () => { | |
| const uiState = createMockUIState({ | |
| streamingState: StreamingState.Responding, | |
| thought: { | |
| subject: 'Thinking about code', | |
| description: 'Full text is already in history', | |
| }, | |
| }); | |
| const settings = createMockSettings({ | |
| ui: { inlineThinkingMode: 'full' }, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState, settings); | |
| const output = lastFrame(); | |
| // In Refreshed UX, we don't force 'Thinking...' label in renderStatusNode | |
| // It uses the subject directly | |
| expect(output).toContain('LoadingIndicator: Thinking about code'); | |
| }); | |
| it('shows shortcuts hint while loading', async () => { | |
| const uiState = createMockUIState({ | |
| streamingState: StreamingState.Responding, | |
| elapsedTime: 1, | |
| cleanUiDetailsVisible: false, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| const output = lastFrame(); | |
| expect(output).toContain('LoadingIndicator'); | |
| expect(output).toContain('press tab twice for more'); | |
| expect(output).not.toContain('? for shortcuts'); | |
| }); | |
| it('renders LoadingIndicator with thought when loadingPhrases is off', async () => { | |
| const uiState = createMockUIState({ | |
| streamingState: StreamingState.Responding, | |
| thought: { subject: 'Hidden', description: 'Should not show' }, | |
| }); | |
| const settings = createMockSettings({ | |
| ui: { loadingPhrases: 'off' }, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState, settings); | |
| const output = lastFrame(); | |
| expect(output).toContain('LoadingIndicator'); | |
| expect(output).toContain('LoadingIndicator: Hidden'); | |
| }); | |
| it('does not render LoadingIndicator when waiting for confirmation', async () => { | |
| const uiState = createMockUIState({ | |
| streamingState: StreamingState.WaitingForConfirmation, | |
| thought: { | |
| subject: 'Confirmation', | |
| description: 'Should not show during confirmation', | |
| }, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| const output = lastFrame(); | |
| expect(output).not.toContain('LoadingIndicator'); | |
| }); | |
| it('does not render LoadingIndicator when a tool confirmation is pending', async () => { | |
| const uiState = createMockUIState({ | |
| streamingState: StreamingState.Responding, | |
| pendingHistoryItems: [ | |
| { | |
| type: 'tool_group', | |
| tools: [ | |
| { | |
| callId: 'call-1', | |
| name: 'edit', | |
| description: 'edit file', | |
| status: CoreToolCallStatus.AwaitingApproval, | |
| resultDisplay: undefined, | |
| confirmationDetails: undefined, | |
| }, | |
| ], | |
| }, | |
| ], | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| const output = lastFrame({ allowEmpty: true }); | |
| expect(output).toBe(''); | |
| }); | |
| it('renders LoadingIndicator when embedded shell is focused but background shell is visible', async () => { | |
| const uiState = createMockUIState({ | |
| streamingState: StreamingState.Responding, | |
| embeddedShellFocused: true, | |
| isBackgroundTaskVisible: true, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| const output = lastFrame(); | |
| expect(output).toContain('LoadingIndicator'); | |
| }); | |
| it('renders both LoadingIndicator and ApprovalModeIndicator when streaming in full UI mode', async () => { | |
| const uiState = createMockUIState({ | |
| streamingState: StreamingState.Responding, | |
| thought: { | |
| subject: 'Thinking', | |
| description: '', | |
| }, | |
| showApprovalModeIndicator: ApprovalMode.PLAN, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| const output = lastFrame(); | |
| expect(output).toContain('LoadingIndicator: Thinking'); | |
| expect(output).toContain('ApprovalModeIndicator'); | |
| }); | |
| it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', async () => { | |
| const uiState = createMockUIState({ | |
| streamingState: StreamingState.Responding, | |
| embeddedShellFocused: true, | |
| isBackgroundTaskVisible: false, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| const output = lastFrame(); | |
| expect(output).not.toContain('LoadingIndicator'); | |
| }); | |
| }); | |
| describe('Message Queue Display', () => { | |
| it('displays queued messages when present', async () => { | |
| const uiState = createMockUIState({ | |
| messageQueue: [ | |
| 'First queued message', | |
| 'Second queued message', | |
| 'Third queued message', | |
| ], | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| const output = lastFrame(); | |
| expect(output).toContain('First queued message'); | |
| expect(output).toContain('Second queued message'); | |
| expect(output).toContain('Third queued message'); | |
| }); | |
| it('renders QueuedMessageDisplay with empty message queue', async () => { | |
| const uiState = createMockUIState({ | |
| messageQueue: [], | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| // The component should render but return null for empty queue | |
| // This test verifies that the component receives the correct prop | |
| const output = lastFrame(); | |
| expect(output).toContain('InputPrompt'); // Verify basic Composer rendering | |
| }); | |
| }); | |
| describe('Context and Status Display', () => { | |
| it('shows StatusDisplay and ApprovalModeIndicator in normal state', async () => { | |
| const uiState = createMockUIState({ | |
| ctrlCPressedOnce: false, | |
| ctrlDPressedOnce: false, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| const output = lastFrame(); | |
| expect(output).toContain('StatusDisplay'); | |
| expect(output).toContain('ApprovalModeIndicator'); | |
| expect(output).not.toContain('ToastDisplay'); | |
| }); | |
| it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', async () => { | |
| const uiState = createMockUIState({ | |
| ctrlCPressedOnce: true, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| const output = lastFrame(); | |
| expect(output).toContain('Press Ctrl+C again to exit.'); | |
| // In Refreshed UX, Row 1 shows toast, and Row 2 shows ApprovalModeIndicator/StatusDisplay | |
| // They are no longer mutually exclusive. | |
| expect(output).toContain('ApprovalModeIndicator'); | |
| expect(output).toContain('StatusDisplay'); | |
| }); | |
| it('shows ToastDisplay for other toast types', async () => { | |
| const uiState = createMockUIState({ | |
| transientMessage: { | |
| text: 'Warning', | |
| type: TransientMessageType.Warning, | |
| }, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| const output = lastFrame(); | |
| expect(output).toContain('Warning'); | |
| expect(output).toContain('ApprovalModeIndicator'); | |
| }); | |
| }); | |
| describe('Input and Indicators', () => { | |
| it('hides non-essential UI details in clean mode', async () => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| }); | |
| const settings = createMockSettings({ | |
| ui: { showShortcutsHint: false }, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState, settings); | |
| const output = lastFrame(); | |
| expect(output).not.toContain('press tab twice for more'); | |
| expect(output).not.toContain('? for shortcuts'); | |
| expect(output).toContain('InputPrompt'); | |
| expect(output).not.toContain('Footer'); | |
| }); | |
| it('renders InputPrompt when input is active', async () => { | |
| const uiState = createMockUIState({ | |
| isInputActive: true, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).toContain('InputPrompt'); | |
| }); | |
| it('does not render InputPrompt when input is inactive', async () => { | |
| const uiState = createMockUIState({ | |
| isInputActive: false, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).not.toContain('InputPrompt'); | |
| }); | |
| it.each([ | |
| [ApprovalMode.DEFAULT], | |
| [ApprovalMode.AUTO_EDIT], | |
| [ApprovalMode.PLAN], | |
| [ApprovalMode.YOLO], | |
| ])( | |
| 'shows ApprovalModeIndicator when approval mode is %s and shell mode is inactive', | |
| async (mode) => { | |
| const uiState = createMockUIState({ | |
| showApprovalModeIndicator: mode, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/); | |
| }, | |
| ); | |
| it('shows ShellModeIndicator when shell mode is active', async () => { | |
| const uiState = createMockUIState(); | |
| const { lastFrame } = await renderComposer( | |
| uiState, | |
| undefined, | |
| undefined, | |
| undefined, | |
| { shellModeActive: true }, | |
| ); | |
| expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/); | |
| }); | |
| it('shows RawMarkdownIndicator when renderMarkdown is false', async () => { | |
| const uiState = createMockUIState({ | |
| renderMarkdown: false, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).toContain('raw markdown mode'); | |
| }); | |
| it('does not show RawMarkdownIndicator when renderMarkdown is true', async () => { | |
| const uiState = createMockUIState({ | |
| renderMarkdown: true, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).not.toContain('raw markdown mode'); | |
| }); | |
| it.each([ | |
| { mode: ApprovalMode.YOLO, label: '● YOLO' }, | |
| { mode: ApprovalMode.PLAN, label: '● plan' }, | |
| { | |
| mode: ApprovalMode.AUTO_EDIT, | |
| label: '● auto edit', | |
| }, | |
| ])( | |
| 'shows minimal mode badge "$mode" when clean UI details are hidden', | |
| async ({ mode, label }) => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| showApprovalModeIndicator: mode, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).toContain(label); | |
| }, | |
| ); | |
| it('hides minimal mode badge while loading in clean mode', async () => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| streamingState: StreamingState.Responding, | |
| elapsedTime: 1, | |
| showApprovalModeIndicator: ApprovalMode.PLAN, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| const output = lastFrame(); | |
| expect(output).toContain('LoadingIndicator'); | |
| expect(output).not.toContain('plan'); | |
| expect(output).toContain('press tab twice for more'); | |
| expect(output).not.toContain('? for shortcuts'); | |
| }); | |
| it('hides minimal mode badge while action-required state is active', async () => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| showApprovalModeIndicator: ApprovalMode.PLAN, | |
| customDialog: ( | |
| <Box> | |
| <Text>Prompt</Text> | |
| </Box> | |
| ), | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame({ allowEmpty: true })).toBe(''); | |
| }); | |
| it('shows Esc rewind prompt in minimal mode without showing full UI', async () => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| history: [{ id: 1, type: 'user', text: 'msg' }], | |
| }); | |
| const { lastFrame } = await renderComposer( | |
| uiState, | |
| undefined, | |
| undefined, | |
| undefined, | |
| { showEscapePrompt: true }, | |
| ); | |
| const output = lastFrame(); | |
| expect(output).toContain('Press Esc again to rewind.'); | |
| expect(output).not.toContain('ContextSummaryDisplay'); | |
| }); | |
| it('shows context usage bleed-through when over 60%', async () => { | |
| const model = 'gemini-2.5-pro'; | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| currentModel: model, | |
| sessionStats: { | |
| sessionId: 'test-session', | |
| sessionStartTime: new Date(), | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| metrics: {} as any, | |
| lastPromptTokenCount: Math.floor(tokenLimit(model) * 0.7), | |
| promptCount: 0, | |
| }, | |
| }); | |
| const settings = createMockSettings({ | |
| ui: { | |
| footer: { hideContextPercentage: false }, | |
| }, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState, settings); | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(250); | |
| }); | |
| // StatusDisplay (which contains ContextUsageDisplay) should bleed through in minimal mode | |
| expect(lastFrame()).toContain('StatusDisplay'); | |
| expect(lastFrame()).toContain('70% used'); | |
| }); | |
| }); | |
| describe('Error Details Display', () => { | |
| it('shows DetailedMessagesDisplay when showErrorDetails is true', async () => { | |
| const uiState = createMockUIState({ | |
| showErrorDetails: true, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).toContain('DetailedMessagesDisplay'); | |
| expect(lastFrame()).toContain('ShowMoreLines'); | |
| }); | |
| it('does not show error details when showErrorDetails is false', async () => { | |
| const uiState = createMockUIState({ | |
| showErrorDetails: false, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).not.toContain('DetailedMessagesDisplay'); | |
| }); | |
| }); | |
| describe('Vim Mode Placeholders', () => { | |
| it('shows correct placeholder in INSERT mode', async () => { | |
| const uiState = createMockUIState({ isInputActive: true }); | |
| const { useVimMode } = await import('../contexts/VimModeContext.js'); | |
| vi.mocked(useVimMode).mockReturnValue({ | |
| vimEnabled: true, | |
| vimMode: 'INSERT', | |
| toggleVimEnabled: vi.fn(), | |
| setVimMode: vi.fn(), | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).toContain( | |
| "InputPrompt: Press 'Esc' for NORMAL mode.", | |
| ); | |
| }); | |
| it('shows correct placeholder in NORMAL mode', async () => { | |
| const uiState = createMockUIState({ isInputActive: true }); | |
| const { useVimMode } = await import('../contexts/VimModeContext.js'); | |
| vi.mocked(useVimMode).mockReturnValue({ | |
| vimEnabled: true, | |
| vimMode: 'NORMAL', | |
| toggleVimEnabled: vi.fn(), | |
| setVimMode: vi.fn(), | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).toContain( | |
| "InputPrompt: Press 'i' for INSERT mode.", | |
| ); | |
| }); | |
| }); | |
| describe('Shortcuts Hint', () => { | |
| it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| }); | |
| const { lastFrame } = await renderComposer( | |
| uiState, | |
| undefined, | |
| undefined, | |
| undefined, | |
| { buffer: { text: '' } as unknown as TextBuffer }, | |
| ); | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(250); | |
| }); | |
| expect(lastFrame({ allowEmpty: true })).toContain( | |
| 'press tab twice for more', | |
| ); | |
| }); | |
| it('hides shortcuts hint when text is typed in buffer', async () => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| }); | |
| const { lastFrame } = await renderComposer( | |
| uiState, | |
| undefined, | |
| undefined, | |
| undefined, | |
| { buffer: { text: 'hello' } as unknown as TextBuffer }, | |
| ); | |
| expect(lastFrame()).not.toContain('press tab twice for more'); | |
| expect(lastFrame()).not.toContain('? for shortcuts'); | |
| }); | |
| it('hides shortcuts hint when showShortcutsHint setting is false', async () => { | |
| const uiState = createMockUIState(); | |
| const settings = createMockSettings({ | |
| ui: { | |
| showShortcutsHint: false, | |
| }, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState, settings); | |
| expect(lastFrame()).not.toContain('? for shortcuts'); | |
| }); | |
| it('hides shortcuts hint when a action is required (e.g. dialog is open)', async () => { | |
| const uiState = createMockUIState({ | |
| customDialog: ( | |
| <Box> | |
| <Text>Test Dialog</Text> | |
| <Text>Test Content</Text> | |
| </Box> | |
| ), | |
| }); | |
| const { lastFrame, unmount } = await renderComposer(uiState); | |
| expect(lastFrame({ allowEmpty: true })).toBe(''); | |
| unmount(); | |
| }); | |
| it('keeps shortcuts hint visible when no action is required', async () => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(250); | |
| }); | |
| expect(lastFrame()).toContain('press tab twice for more'); | |
| }); | |
| it('shows shortcuts hint when full UI details are visible', async () => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: true, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(250); | |
| }); | |
| // In Refreshed UX, shortcuts hint is in the top multipurpose status row | |
| expect(lastFrame()).toContain('? for shortcuts'); | |
| }); | |
| it('shows shortcuts hint while loading when full UI details are visible', async () => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: true, | |
| streamingState: StreamingState.Responding, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(250); | |
| }); | |
| // In experimental layout, status row is visible during loading | |
| expect(lastFrame()).toContain('LoadingIndicator'); | |
| expect(lastFrame()).toContain('? for shortcuts'); | |
| expect(lastFrame()).not.toContain('press tab twice for more'); | |
| }); | |
| it('shows shortcuts hint while loading in minimal mode', async () => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| streamingState: StreamingState.Responding, | |
| elapsedTime: 1, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(250); | |
| }); | |
| // In experimental layout, status row is visible in clean mode while busy | |
| expect(lastFrame()).toContain('LoadingIndicator'); | |
| expect(lastFrame()).toContain('press tab twice for more'); | |
| expect(lastFrame()).not.toContain('? for shortcuts'); | |
| }); | |
| it('shows shortcuts help in minimal mode when toggled on', async () => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| shortcutsHelpVisible: true, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).toContain('ShortcutsHelp'); | |
| }); | |
| it('hides shortcuts hint when suggestions are visible above input in alternate buffer', async () => { | |
| composerTestControls.isAlternateBuffer = true; | |
| composerTestControls.suggestionsVisible = true; | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| showApprovalModeIndicator: ApprovalMode.PLAN, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).not.toContain('press tab twice for more'); | |
| expect(lastFrame()).not.toContain('? for shortcuts'); | |
| expect(lastFrame()).not.toContain('plan'); | |
| }); | |
| it('hides approval mode indicator when suggestions are visible above input in alternate buffer', async () => { | |
| composerTestControls.isAlternateBuffer = true; | |
| composerTestControls.suggestionsVisible = true; | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: true, | |
| showApprovalModeIndicator: ApprovalMode.YOLO, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).not.toContain('ApprovalModeIndicator'); | |
| }); | |
| it('keeps shortcuts hint when suggestions are visible below input in regular buffer', async () => { | |
| composerTestControls.isAlternateBuffer = false; | |
| composerTestControls.suggestionsVisible = true; | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(250); | |
| }); | |
| // In Refreshed UX, shortcuts hint is in the top status row and doesn't collide with suggestions below | |
| expect(lastFrame()).toContain('press tab twice for more'); | |
| }); | |
| }); | |
| describe('Shortcuts Help', () => { | |
| it('shows shortcuts help in passive state', async () => { | |
| const uiState = createMockUIState({ | |
| shortcutsHelpVisible: true, | |
| streamingState: StreamingState.Idle, | |
| }); | |
| const { lastFrame, unmount } = await renderComposer(uiState); | |
| expect(lastFrame()).toContain('ShortcutsHelp'); | |
| unmount(); | |
| }); | |
| it('hides shortcuts help while streaming', async () => { | |
| const uiState = createMockUIState({ | |
| shortcutsHelpVisible: true, | |
| streamingState: StreamingState.Responding, | |
| }); | |
| const { lastFrame, unmount } = await renderComposer(uiState); | |
| expect(lastFrame()).not.toContain('ShortcutsHelp'); | |
| unmount(); | |
| }); | |
| it('hides shortcuts help when action is required', async () => { | |
| const uiState = createMockUIState({ | |
| shortcutsHelpVisible: true, | |
| customDialog: ( | |
| <Box> | |
| <Text>Test Dialog</Text> | |
| </Box> | |
| ), | |
| }); | |
| const { lastFrame, unmount } = await renderComposer(uiState); | |
| expect(lastFrame({ allowEmpty: true })).toBe(''); | |
| unmount(); | |
| }); | |
| }); | |
| describe('Snapshots', () => { | |
| it('matches snapshot in idle state', async () => { | |
| const uiState = createMockUIState(); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| }); | |
| it('matches snapshot while streaming', async () => { | |
| const uiState = createMockUIState({ | |
| streamingState: StreamingState.Responding, | |
| thought: { | |
| subject: 'Thinking', | |
| description: 'Thinking about the meaning of life...', | |
| }, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| }); | |
| it('matches snapshot in narrow view', async () => { | |
| const uiState = createMockUIState({ | |
| terminalWidth: 40, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| }); | |
| it('matches snapshot in minimal UI mode', async () => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| }); | |
| it('matches snapshot in minimal UI mode while loading', async () => { | |
| const uiState = createMockUIState({ | |
| cleanUiDetailsVisible: false, | |
| streamingState: StreamingState.Responding, | |
| elapsedTime: 1000, | |
| }); | |
| const { lastFrame } = await renderComposer(uiState); | |
| expect(lastFrame()).toMatchSnapshot(); | |
| }); | |
| }); | |
| }); | |