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"`;