fix: move toasts location to left side (#18705)

This commit is contained in:
Jack Wotherspoon
2026-02-10 08:39:28 -05:00
committed by GitHub
parent e151b4890b
commit 79753ec5ec
8 changed files with 342 additions and 241 deletions

View File

@@ -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';
@@ -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';
@@ -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>,
}));
@@ -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();
@@ -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,
@@ -457,49 +477,38 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ContextSummaryDisplay');
const output = lastFrame();
expect(output).toContain('StatusDisplay');
expect(output).toContain('ApprovalModeIndicator');
expect(output).not.toContain('ToastDisplay');
});
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');
});
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');
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ApprovalModeIndicator');
expect(output).toContain('StatusDisplay');
});
it('shows Ctrl+D exit prompt when ctrlDPressedOnce is true', () => {
it('shows ToastDisplay for other toast types', () => {
const uiState = createMockUIState({
ctrlDPressedOnce: true,
transientMessage: {
text: 'Warning',
type: TransientMessageType.Warning,
},
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
});
it('shows escape prompt when showEscapePrompt is true', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'test' }],
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Esc again to rewind');
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ApprovalModeIndicator');
});
});

View File

@@ -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';
@@ -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);
@@ -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 &&
@@ -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>

View File

@@ -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';
@@ -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', () => {
@@ -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' }],

View File

@@ -8,7 +8,6 @@ import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { TransientMessageType } from '../../utils/events.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
@@ -29,55 +28,6 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
return <Text color={theme.status.error}>|_|</Text>;
}
if (uiState.ctrlCPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Warning &&
uiState.transientMessage.text
) {
return (
<Text color={theme.status.warning}>{uiState.transientMessage.text}</Text>
);
}
if (uiState.ctrlDPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
);
}
if (uiState.showEscapePrompt) {
const isPromptEmpty = uiState.buffer.text.length === 0;
const hasHistory = uiState.history.length > 0;
if (isPromptEmpty && !hasHistory) {
return null;
}
return (
<Text color={theme.text.secondary}>
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
</Text>
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Hint &&
uiState.transientMessage.text
) {
return (
<Text color={theme.text.secondary}>{uiState.transientMessage.text}</Text>
);
}
if (uiState.queueErrorMessage) {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (
uiState.activeHooks.length > 0 &&
settings.merged.hooksConfig.notifications

View File

@@ -0,0 +1,165 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { TransientMessageType } from '../../utils/events.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { type TextBuffer } from './shared/text-buffer.js';
import { type HistoryItem } from '../types.js';
const renderToastDisplay = (uiState: Partial<UIState> = {}) =>
renderWithProviders(<ToastDisplay />, {
uiState: {
buffer: { text: '' } as TextBuffer,
history: [] as HistoryItem[],
...uiState,
},
});
describe('ToastDisplay', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('shouldShowToast', () => {
const baseState: Partial<UIState> = {
ctrlCPressedOnce: false,
transientMessage: null,
ctrlDPressedOnce: false,
showEscapePrompt: false,
buffer: { text: '' } as TextBuffer,
history: [] as HistoryItem[],
queueErrorMessage: null,
};
it('returns false for default state', () => {
expect(shouldShowToast(baseState as UIState)).toBe(false);
});
it('returns true when ctrlCPressedOnce is true', () => {
expect(
shouldShowToast({ ...baseState, ctrlCPressedOnce: true } as UIState),
).toBe(true);
});
it('returns true when transientMessage is present', () => {
expect(
shouldShowToast({
...baseState,
transientMessage: { text: 'test', type: TransientMessageType.Hint },
} as UIState),
).toBe(true);
});
it('returns true when ctrlDPressedOnce is true', () => {
expect(
shouldShowToast({ ...baseState, ctrlDPressedOnce: true } as UIState),
).toBe(true);
});
it('returns true when showEscapePrompt is true and buffer is NOT empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
} as UIState),
).toBe(true);
});
it('returns true when showEscapePrompt is true and history is NOT empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
history: [{ id: '1' } as unknown as HistoryItem],
} as UIState),
).toBe(true);
});
it('returns false when showEscapePrompt is true but buffer and history are empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
} as UIState),
).toBe(false);
});
it('returns true when queueErrorMessage is present', () => {
expect(
shouldShowToast({
...baseState,
queueErrorMessage: 'error',
} as UIState),
).toBe(true);
});
});
it('renders nothing by default', () => {
const { lastFrame } = renderToastDisplay();
expect(lastFrame()).toBe('');
});
it('renders Ctrl+C prompt', () => {
const { lastFrame } = renderToastDisplay({
ctrlCPressedOnce: true,
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders warning message', () => {
const { lastFrame } = renderToastDisplay({
transientMessage: {
text: 'This is a warning',
type: TransientMessageType.Warning,
},
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders hint message', () => {
const { lastFrame } = renderToastDisplay({
transientMessage: {
text: 'This is a hint',
type: TransientMessageType.Hint,
},
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders Ctrl+D prompt', () => {
const { lastFrame } = renderToastDisplay({
ctrlDPressedOnce: true,
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is empty', () => {
const { lastFrame } = renderToastDisplay({
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'test' }] as HistoryItem[],
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is NOT empty', () => {
const { lastFrame } = renderToastDisplay({
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders Queue Error Message', () => {
const { lastFrame } = renderToastDisplay({
queueErrorMessage: 'Queue Error',
});
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,77 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { TransientMessageType } from '../../utils/events.js';
export function shouldShowToast(uiState: UIState): boolean {
return (
uiState.ctrlCPressedOnce ||
Boolean(uiState.transientMessage) ||
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage)
);
}
export const ToastDisplay: React.FC = () => {
const uiState = useUIState();
if (uiState.ctrlCPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Warning &&
uiState.transientMessage.text
) {
return (
<Text color={theme.status.warning}>{uiState.transientMessage.text}</Text>
);
}
if (uiState.ctrlDPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
);
}
if (uiState.showEscapePrompt) {
const isPromptEmpty = uiState.buffer.text.length === 0;
const hasHistory = uiState.history.length > 0;
if (isPromptEmpty && !hasHistory) {
return null;
}
return (
<Text color={theme.text.secondary}>
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
</Text>
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Hint &&
uiState.transientMessage.text
) {
return (
<Text color={theme.text.secondary}>{uiState.transientMessage.text}</Text>
);
}
if (uiState.queueErrorMessage) {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
return null;
};

View File

@@ -2,24 +2,8 @@
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`;
exports[`StatusDisplay > prioritizes Ctrl+C prompt over everything else (except system md) 1`] = `"Press Ctrl+C again to exit."`;
exports[`StatusDisplay > prioritizes warning over Ctrl+D 1`] = `"Warning"`;
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`;
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
exports[`StatusDisplay > renders Escape prompt when buffer is NOT empty 1`] = `"Press Esc again to clear prompt."`;
exports[`StatusDisplay > renders Escape prompt when buffer is empty 1`] = `"Press Esc again to rewind."`;
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;
exports[`StatusDisplay > renders Queue Error Message 1`] = `"Queue Error"`;
exports[`StatusDisplay > renders hint message 1`] = `"This is a hint"`;
exports[`StatusDisplay > renders system md indicator if env var is set 1`] = `"|⌐■_■|"`;
exports[`StatusDisplay > renders warning message 1`] = `"This is a warning"`;

View File

@@ -0,0 +1,15 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToastDisplay > renders Ctrl+C prompt 1`] = `"Press Ctrl+C again to exit."`;
exports[`ToastDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
exports[`ToastDisplay > renders Escape prompt when buffer is NOT empty 1`] = `"Press Esc again to clear prompt."`;
exports[`ToastDisplay > renders Escape prompt when buffer is empty 1`] = `"Press Esc again to rewind."`;
exports[`ToastDisplay > renders Queue Error Message 1`] = `"Queue Error"`;
exports[`ToastDisplay > renders hint message 1`] = `"This is a hint"`;
exports[`ToastDisplay > renders warning message 1`] = `"This is a warning"`;