diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx
index 72f1bc784b..da7b866391 100644
--- a/packages/cli/src/ui/components/Composer.test.tsx
+++ b/packages/cli/src/ui/components/Composer.test.tsx
@@ -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: () => StatusDisplay,
+}));
+
+vi.mock('./ToastDisplay.js', () => ({
+ ToastDisplay: () => ToastDisplay,
+ 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: () => ContextSummaryDisplay,
}));
@@ -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');
});
});
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index fb9a274cd0..84001056a8 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -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 && (
-
- {showApprovalIndicator && (
-
- )}
- {uiState.shellModeActive && (
-
-
-
- )}
- {showRawMarkdownIndicator && (
-
-
-
- )}
-
+ {hasToast ? (
+
+ ) : (
+ !showLoadingIndicator && (
+
+ {showApprovalIndicator && (
+
+ )}
+ {uiState.shellModeActive && (
+
+
+
+ )}
+ {showRawMarkdownIndicator && (
+
+
+
+ )}
+
+ )
)}
diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx
index 251f339a52..809e7fb5d9 100644
--- a/packages/cli/src/ui/components/StatusDisplay.test.tsx
+++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx
@@ -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' }],
diff --git a/packages/cli/src/ui/components/StatusDisplay.tsx b/packages/cli/src/ui/components/StatusDisplay.tsx
index 5bc9896bd7..223340c039 100644
--- a/packages/cli/src/ui/components/StatusDisplay.tsx
+++ b/packages/cli/src/ui/components/StatusDisplay.tsx
@@ -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 = ({
return |⌐■_■|;
}
- if (uiState.ctrlCPressedOnce) {
- return (
- Press Ctrl+C again to exit.
- );
- }
-
- if (
- uiState.transientMessage?.type === TransientMessageType.Warning &&
- uiState.transientMessage.text
- ) {
- return (
- {uiState.transientMessage.text}
- );
- }
-
- if (uiState.ctrlDPressedOnce) {
- return (
- Press Ctrl+D again to exit.
- );
- }
-
- if (uiState.showEscapePrompt) {
- const isPromptEmpty = uiState.buffer.text.length === 0;
- const hasHistory = uiState.history.length > 0;
-
- if (isPromptEmpty && !hasHistory) {
- return null;
- }
-
- return (
-
- Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
-
- );
- }
-
- if (
- uiState.transientMessage?.type === TransientMessageType.Hint &&
- uiState.transientMessage.text
- ) {
- return (
- {uiState.transientMessage.text}
- );
- }
-
- if (uiState.queueErrorMessage) {
- return {uiState.queueErrorMessage};
- }
-
if (
uiState.activeHooks.length > 0 &&
settings.merged.hooksConfig.notifications
diff --git a/packages/cli/src/ui/components/ToastDisplay.test.tsx b/packages/cli/src/ui/components/ToastDisplay.test.tsx
new file mode 100644
index 0000000000..5f48392749
--- /dev/null
+++ b/packages/cli/src/ui/components/ToastDisplay.test.tsx
@@ -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 = {}) =>
+ renderWithProviders(, {
+ uiState: {
+ buffer: { text: '' } as TextBuffer,
+ history: [] as HistoryItem[],
+ ...uiState,
+ },
+ });
+
+describe('ToastDisplay', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('shouldShowToast', () => {
+ const baseState: Partial = {
+ 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();
+ });
+});
diff --git a/packages/cli/src/ui/components/ToastDisplay.tsx b/packages/cli/src/ui/components/ToastDisplay.tsx
new file mode 100644
index 0000000000..37d2997e33
--- /dev/null
+++ b/packages/cli/src/ui/components/ToastDisplay.tsx
@@ -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 (
+ Press Ctrl+C again to exit.
+ );
+ }
+
+ if (
+ uiState.transientMessage?.type === TransientMessageType.Warning &&
+ uiState.transientMessage.text
+ ) {
+ return (
+ {uiState.transientMessage.text}
+ );
+ }
+
+ if (uiState.ctrlDPressedOnce) {
+ return (
+ Press Ctrl+D again to exit.
+ );
+ }
+
+ if (uiState.showEscapePrompt) {
+ const isPromptEmpty = uiState.buffer.text.length === 0;
+ const hasHistory = uiState.history.length > 0;
+
+ if (isPromptEmpty && !hasHistory) {
+ return null;
+ }
+
+ return (
+
+ Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
+
+ );
+ }
+
+ if (
+ uiState.transientMessage?.type === TransientMessageType.Hint &&
+ uiState.transientMessage.text
+ ) {
+ return (
+ {uiState.transientMessage.text}
+ );
+ }
+
+ if (uiState.queueErrorMessage) {
+ return {uiState.queueErrorMessage};
+ }
+
+ return null;
+};
diff --git a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap
index ff25546002..f602388346 100644
--- a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap
@@ -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"`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ToastDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToastDisplay.test.tsx.snap
new file mode 100644
index 0000000000..e1c2605cfd
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ToastDisplay.test.tsx.snap
@@ -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"`;