Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 39 additions & 30 deletions packages/cli/src/ui/components/Composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from '../../test-utils/render.js';
import { Box, Text } from 'ink';
import { Composer } from './Composer.js';
Expand All @@ -26,6 +26,7 @@ vi.mock('../contexts/VimModeContext.js', () => ({
import { ApprovalMode } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { StreamingState, ToolCallStatus } from '../types.js';
import { TransientMessageType } from '../../utils/events.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';

Expand All @@ -45,6 +46,21 @@ vi.mock('./LoadingIndicator.js', () => ({
},
}));

vi.mock('./StatusDisplay.js', () => ({
StatusDisplay: () => <Text>StatusDisplay</Text>,
}));

vi.mock('./ToastDisplay.js', () => ({
ToastDisplay: () => <Text>ToastDisplay</Text>,
shouldShowToast: (uiState: UIState) =>
uiState.ctrlCPressedOnce ||
Boolean(uiState.transientMessage) ||
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage),
}));

vi.mock('./ContextSummaryDisplay.js', () => ({
ContextSummaryDisplay: () => <Text>ContextSummaryDisplay</Text>,
}));
Expand Down Expand Up @@ -216,6 +232,10 @@ const renderComposer = (
);

describe('Composer', () => {
afterEach(() => {
vi.restoreAllMocks();
});

describe('Footer Display Settings', () => {
it('renders Footer by default when hideFooter is false', () => {
const uiState = createMockUIState();
Expand Down Expand Up @@ -448,7 +468,7 @@ describe('Composer', () => {
});

describe('Context and Status Display', () => {
it('shows ContextSummaryDisplay in normal state', () => {
it('shows StatusDisplay and ApprovalModeIndicator in normal state', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
Expand All @@ -457,49 +477,38 @@ describe('Composer', () => {

const { lastFrame } = renderComposer(uiState);

expect(lastFrame()).toContain('ContextSummaryDisplay');
});

it('renders HookStatusDisplay instead of ContextSummaryDisplay with active hooks', () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'test-hook', eventName: 'before-agent' }],
});

const { lastFrame } = renderComposer(uiState);

expect(lastFrame()).toContain('HookStatusDisplay');
expect(lastFrame()).not.toContain('ContextSummaryDisplay');
const output = lastFrame();
expect(output).toContain('StatusDisplay');
expect(output).toContain('ApprovalModeIndicator');
expect(output).not.toContain('ToastDisplay');
});

it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
});

const { lastFrame } = renderComposer(uiState);

expect(lastFrame()).toContain('Press Ctrl+C again to exit');
});

it('shows Ctrl+D exit prompt when ctrlDPressedOnce is true', () => {
const uiState = createMockUIState({
ctrlDPressedOnce: true,
});

const { lastFrame } = renderComposer(uiState);

expect(lastFrame()).toContain('Press Ctrl+D again to exit');
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ApprovalModeIndicator');
expect(output).toContain('StatusDisplay');
});

it('shows escape prompt when showEscapePrompt is true', () => {
it('shows ToastDisplay for other toast types', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'test' }],
transientMessage: {
text: 'Warning',
type: TransientMessageType.Warning,
},
});

const { lastFrame } = renderComposer(uiState);

expect(lastFrame()).toContain('Press Esc again to rewind');
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ApprovalModeIndicator');
});
});

Expand Down
84 changes: 45 additions & 39 deletions packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useState } from 'react';
import { Box, useIsScreenReaderEnabled } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StatusDisplay } from './StatusDisplay.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
Expand Down Expand Up @@ -40,7 +41,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const uiActions = useUIActions();
const { vimEnabled, vimMode } = useVimMode();
const inlineThinkingMode = getInlineThinkingMode(settings);
const terminalWidth = process.stdout.columns;
const terminalWidth = uiState.terminalWidth;
const isNarrow = isNarrowWidth(terminalWidth);
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
Expand All @@ -64,6 +65,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
Boolean(uiState.quota.proQuotaRequest) ||
Boolean(uiState.quota.validationRequest) ||
Boolean(uiState.customDialog);
const hasToast = shouldShowToast(uiState);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
uiState.streamingState === StreamingState.Responding &&
Expand Down Expand Up @@ -153,44 +155,48 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
alignItems="center"
flexGrow={1}
>
{!showLoadingIndicator && (
<Box
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
isPlanEnabled={config.isPlanEnabled()}
/>
)}
{uiState.shellModeActive && (
<Box
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
>
<ShellModeIndicator />
</Box>
)}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator || uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator || uiState.shellModeActive) &&
isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
</Box>
{hasToast ? (
<ToastDisplay />
) : (
!showLoadingIndicator && (
<Box
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
isPlanEnabled={config.isPlanEnabled()}
/>
)}
{uiState.shellModeActive && (
<Box
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
>
<ShellModeIndicator />
</Box>
)}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator || uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator || uiState.shellModeActive) &&
isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
</Box>
)
)}
</Box>

Expand Down
107 changes: 1 addition & 106 deletions packages/cli/src/ui/components/StatusDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { render } from '../../test-utils/render.js';
import { Text } from 'ink';
import { StatusDisplay } from './StatusDisplay.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { TransientMessageType } from '../../utils/events.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { Config } from '@google/gemini-cli-core';
Expand Down Expand Up @@ -92,6 +91,7 @@ describe('StatusDisplay', () => {
afterEach(() => {
process.env = { ...originalEnv };
delete process.env['GEMINI_SYSTEM_MD'];
vi.restoreAllMocks();
});

it('renders nothing by default if context summary is hidden via props', () => {
Expand All @@ -110,111 +110,6 @@ describe('StatusDisplay', () => {
expect(lastFrame()).toMatchSnapshot();
});

it('prioritizes Ctrl+C prompt over everything else (except system md)', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
transientMessage: {
text: 'Warning',
type: TransientMessageType.Warning,
},
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});

it('renders warning message', () => {
const uiState = createMockUIState({
transientMessage: {
text: 'This is a warning',
type: TransientMessageType.Warning,
},
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});

it('renders hint message', () => {
const uiState = createMockUIState({
transientMessage: {
text: 'This is a hint',
type: TransientMessageType.Hint,
},
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});

it('prioritizes warning over Ctrl+D', () => {
const uiState = createMockUIState({
transientMessage: {
text: 'Warning',
type: TransientMessageType.Warning,
},
ctrlDPressedOnce: true,
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});

it('renders Ctrl+D prompt', () => {
const uiState = createMockUIState({
ctrlDPressedOnce: true,
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});

it('renders Escape prompt when buffer is empty', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
buffer: { text: '' },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});

it('renders Escape prompt when buffer is NOT empty', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
buffer: { text: 'some text' },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});

it('renders Queue Error Message', () => {
const uiState = createMockUIState({
queueErrorMessage: 'Queue Error',
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});

it('renders HookStatusDisplay when hooks are active', () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'hook', eventName: 'event' }],
Expand Down
Loading
Loading