diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index c9982103d3..ff9bbb78a7 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -598,6 +598,9 @@ const mockUIActions: UIActions = { clearAccountSuspension: vi.fn(), }; +import { type TextBuffer } from '../ui/components/shared/text-buffer.js'; +import { InputContext, type InputState } from '../ui/contexts/InputContext.js'; + let capturedOverflowState: OverflowState | undefined; let capturedOverflowActions: OverflowActions | undefined; const ContextCapture: React.FC<{ children: React.ReactNode }> = ({ @@ -614,6 +617,7 @@ export const renderWithProviders = async ( shellFocus = true, settings = mockSettings, uiState: providedUiState, + inputState: providedInputState, width, mouseEventsEnabled = false, config, @@ -625,6 +629,7 @@ export const renderWithProviders = async ( shellFocus?: boolean; settings?: LoadedSettings; uiState?: Partial; + inputState?: Partial; width?: number; mouseEventsEnabled?: boolean; config?: Config; @@ -659,6 +664,18 @@ export const renderWithProviders = async ( }, ) as UIState; + const inputState = { + buffer: { text: '' } as unknown as TextBuffer, + userMessages: [], + shellModeActive: false, + showEscapePrompt: false, + copyModeEnabled: false, + inputWidth: 80, + suggestionsWidth: 40, + ...(providedUiState as unknown as Partial), + ...providedInputState, + }; + if (persistentState?.get) { persistentStateMock.get.mockImplementation(persistentState.get); } @@ -708,63 +725,65 @@ export const renderWithProviders = async ( - - - - - - - - - + + + + + + + + - - - - - - - {comp} - - - - - - - - - - - - - - - + + + + + + + + {comp} + + + + + + + + + + + + + + + + diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 21bd931d8f..d78b56e11d 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -122,13 +122,17 @@ vi.mock('ink', async (importOriginal) => { }; }); +import { InputContext, type InputState } from './contexts/InputContext.js'; + // Helper component will read the context values provided by AppContainer // so we can assert against them in our tests. let capturedUIState: UIState; +let capturedInputState: InputState; let capturedUIActions: UIActions; let capturedOverflowActions: OverflowActions; function TestContextConsumer() { capturedUIState = useContext(UIStateContext)!; + capturedInputState = useContext(InputContext)!; capturedUIActions = useContext(UIActionsContext)!; capturedOverflowActions = useOverflowActions()!; return null; @@ -3036,7 +3040,7 @@ describe('AppContainer State Management', () => { }); const { unmount } = await act(async () => renderAppContainer()); - expect(capturedUIState.userMessages).toContain('previous message'); + expect(capturedInputState.userMessages).toContain('previous message'); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, @@ -3064,8 +3068,8 @@ describe('AppContainer State Management', () => { const { rerender, unmount } = await act(async () => renderAppContainer()); // Verify userMessages is populated from inputHistory - expect(capturedUIState.userMessages).toContain('first prompt'); - expect(capturedUIState.userMessages).toContain('second prompt'); + expect(capturedInputState.userMessages).toContain('first prompt'); + expect(capturedInputState.userMessages).toContain('second prompt'); // Clear the conversation history (simulating /clear command) const mockClearItems = vi.fn(); @@ -3084,8 +3088,8 @@ describe('AppContainer State Management', () => { // Verify that userMessages still contains the input history // (it should not be affected by clearing conversation history) - expect(capturedUIState.userMessages).toContain('first prompt'); - expect(capturedUIState.userMessages).toContain('second prompt'); + expect(capturedInputState.userMessages).toContain('first prompt'); + expect(capturedInputState.userMessages).toContain('second prompt'); unmount(); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index f12d39ea9e..a0d995f323 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -194,6 +194,8 @@ import { } from './hooks/useVisibilityToggle.js'; import { useKeyMatchers } from './hooks/useKeyMatchers.js'; +import { InputContext } from './contexts/InputContext.js'; + /** * The fraction of the terminal width to allocate to the shell. * This provides horizontal padding. @@ -2328,6 +2330,27 @@ Logging in with Google... Restarting Gemini CLI to continue. }; }, [config, refreshStatic]); + const inputState = useMemo( + () => ({ + buffer, + userMessages: inputHistory, + shellModeActive, + showEscapePrompt, + copyModeEnabled, + inputWidth, + suggestionsWidth, + }), + [ + buffer, + inputHistory, + shellModeActive, + showEscapePrompt, + copyModeEnabled, + inputWidth, + suggestionsWidth, + ], + ); + const uiState: UIState = useMemo( () => ({ history: historyManager.history, @@ -2371,11 +2394,6 @@ Logging in with Google... Restarting Gemini CLI to continue. initError, pendingGeminiHistoryItems, thought, - shellModeActive, - userMessages: inputHistory, - buffer, - inputWidth, - suggestionsWidth, isInputActive, isResuming, shouldShowIdePrompt, @@ -2391,7 +2409,6 @@ Logging in with Google... Restarting Gemini CLI to continue. renderMarkdown, ctrlCPressedOnce: ctrlCPressCount >= 1, ctrlDPressedOnce: ctrlDPressCount >= 1, - showEscapePrompt, shortcutsHelpVisible, cleanUiDetailsVisible, isFocused, @@ -2443,7 +2460,6 @@ Logging in with Google... Restarting Gemini CLI to continue. embeddedShellFocused, showDebugProfiler, customDialog, - copyModeEnabled, transientMessage, bannerData, bannerVisible, @@ -2498,11 +2514,6 @@ Logging in with Google... Restarting Gemini CLI to continue. initError, pendingGeminiHistoryItems, thought, - shellModeActive, - inputHistory, - buffer, - inputWidth, - suggestionsWidth, isInputActive, isResuming, shouldShowIdePrompt, @@ -2518,7 +2529,6 @@ Logging in with Google... Restarting Gemini CLI to continue. renderMarkdown, ctrlCPressCount, ctrlDPressCount, - showEscapePrompt, shortcutsHelpVisible, cleanUiDetailsVisible, isFocused, @@ -2570,7 +2580,6 @@ Logging in with Google... Restarting Gemini CLI to continue. customDialog, apiKeyDefaultValue, authState, - copyModeEnabled, transientMessage, bannerData, bannerVisible, @@ -2757,32 +2766,34 @@ Logging in with Google... Restarting Gemini CLI to continue. return ( - - - - + + + - - - - - - - - - - - + + + + + + + + + + + + + ); }; diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 1750536dbe..316b9a1780 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -245,20 +245,37 @@ const createMockConfig = (overrides = {}): Config => ...overrides, }) as unknown as Config; +import { InputContext, type InputState } from '../contexts/InputContext.js'; + const renderComposer = async ( uiState: UIState, settings = createMockSettings({ ui: {} }), config = createMockConfig(), uiActions = createMockUIActions(), + inputStateOverrides: Partial = {}, ) => { + const inputState = { + buffer: { text: '' } as unknown as TextBuffer, + userMessages: [], + shellModeActive: false, + showEscapePrompt: false, + copyModeEnabled: false, + inputWidth: 80, + suggestionsWidth: 40, + ...(uiState as unknown as Partial), + ...inputStateOverrides, + }; + const result = await render( - - - - - + + + + + + + , ); @@ -541,7 +558,6 @@ describe('Composer', () => { const uiState = createMockUIState({ ctrlCPressedOnce: false, ctrlDPressedOnce: false, - showEscapePrompt: false, }); const { lastFrame } = await renderComposer(uiState); @@ -631,7 +647,6 @@ describe('Composer', () => { async (mode) => { const uiState = createMockUIState({ showApprovalModeIndicator: mode, - shellModeActive: false, }); const { lastFrame } = await renderComposer(uiState); @@ -641,11 +656,15 @@ describe('Composer', () => { ); it('shows ShellModeIndicator when shell mode is active', async () => { - const uiState = createMockUIState({ - shellModeActive: true, - }); + const uiState = createMockUIState(); - const { lastFrame } = await renderComposer(uiState); + const { lastFrame } = await renderComposer( + uiState, + undefined, + undefined, + undefined, + { shellModeActive: true }, + ); expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/); }); @@ -724,11 +743,16 @@ describe('Composer', () => { it('shows Esc rewind prompt in minimal mode without showing full UI', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, - showEscapePrompt: true, history: [{ id: 1, type: 'user', text: 'msg' }], }); - const { lastFrame } = await renderComposer(uiState); + 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'); @@ -828,11 +852,16 @@ describe('Composer', () => { describe('Shortcuts Hint', () => { it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => { const uiState = createMockUIState({ - buffer: { text: '' } as unknown as TextBuffer, cleanUiDetailsVisible: false, }); - const { lastFrame } = await renderComposer(uiState); + const { lastFrame } = await renderComposer( + uiState, + undefined, + undefined, + undefined, + { buffer: { text: '' } as unknown as TextBuffer }, + ); await act(async () => { await vi.advanceTimersByTimeAsync(250); @@ -845,11 +874,16 @@ describe('Composer', () => { it('hides shortcuts hint when text is typed in buffer', async () => { const uiState = createMockUIState({ - buffer: { text: 'hello' } as unknown as TextBuffer, cleanUiDetailsVisible: false, }); - const { lastFrame } = await renderComposer(uiState); + 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'); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 4a1647d11b..52bb2b294f 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -9,6 +9,7 @@ import { useState, useEffect } from 'react'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { useInputState } from '../contexts/InputContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; @@ -30,6 +31,7 @@ import { appEvents, AppEvent } from '../../utils/events.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const uiState = useUIState(); + const inputState = useInputState(); const uiActions = useUIActions(); const settings = useSettings(); const config = useConfig(); @@ -81,12 +83,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { return null; } - const hasToast = shouldShowToast(uiState); + const showToast = shouldShowToast(uiState, inputState); const hideUiDetailsForSuggestions = suggestionsVisible && suggestionsPosition === 'above'; // Mini Mode VIP Flags (Pure Content Triggers) - const showMinimalToast = hasToast; + const showMinimalToast = showToast; return ( { {uiState.isInputActive && ( { ? vimMode === 'INSERT' ? " Press 'Esc' for NORMAL mode." : " Press 'i' for INSERT mode." - : uiState.shellModeActive + : inputState.shellModeActive ? ' Type your shell command' : ' Type your message or @path/to/file' } @@ -173,7 +170,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { streamingState={uiState.streamingState} suggestionsPosition={suggestionsPosition} onSuggestionsVisibilityChange={setSuggestionsVisible} - copyModeEnabled={uiState.copyModeEnabled} /> )} diff --git a/packages/cli/src/ui/components/CopyModeWarning.test.tsx b/packages/cli/src/ui/components/CopyModeWarning.test.tsx index cc20a142dd..c1b797ffd5 100644 --- a/packages/cli/src/ui/components/CopyModeWarning.test.tsx +++ b/packages/cli/src/ui/components/CopyModeWarning.test.tsx @@ -4,34 +4,36 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; import { CopyModeWarning } from './CopyModeWarning.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { useUIState, type UIState } from '../contexts/UIStateContext.js'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { useInputState } from '../contexts/InputContext.js'; -vi.mock('../contexts/UIStateContext.js'); +vi.mock('../contexts/InputContext.js'); describe('CopyModeWarning', () => { - const mockUseUIState = vi.mocked(useUIState); - beforeEach(() => { vi.clearAllMocks(); }); it('renders nothing when copy mode is disabled', async () => { - mockUseUIState.mockReturnValue({ + vi.mocked(useInputState).mockReturnValue({ copyModeEnabled: false, - } as unknown as UIState); - const { lastFrame, unmount } = await render(); + } as unknown as ReturnType); + const { lastFrame, unmount } = await renderWithProviders( + , + ); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); it('renders warning when copy mode is enabled', async () => { - mockUseUIState.mockReturnValue({ + vi.mocked(useInputState).mockReturnValue({ copyModeEnabled: true, - } as unknown as UIState); - const { lastFrame, unmount } = await render(); + } as unknown as ReturnType); + const { lastFrame, unmount } = await renderWithProviders( + , + ); expect(lastFrame()).toContain('In Copy Mode'); expect(lastFrame()).toContain('Use Page Up/Down to scroll'); expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit'); diff --git a/packages/cli/src/ui/components/CopyModeWarning.tsx b/packages/cli/src/ui/components/CopyModeWarning.tsx index eb5c1f6d78..2eec1b62ae 100644 --- a/packages/cli/src/ui/components/CopyModeWarning.tsx +++ b/packages/cli/src/ui/components/CopyModeWarning.tsx @@ -6,11 +6,11 @@ import type React from 'react'; import { Box, Text } from 'ink'; -import { useUIState } from '../contexts/UIStateContext.js'; +import { useInputState } from '../contexts/InputContext.js'; import { theme } from '../semantic-colors.js'; export const CopyModeWarning: React.FC = () => { - const { copyModeEnabled } = useUIState(); + const { copyModeEnabled } = useInputState(); return ( diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 523f15516c..18f2f02224 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -169,6 +169,11 @@ Implement a comprehensive authentication system with multiple providers. getUseTerminalBuffer: () => false, } as unknown as import('@google/gemini-cli-core').Config, settings: createMockSettings({ ui: { useAlternateBuffer } }), + inputState: { + buffer: { text: '' } as never, + showEscapePrompt: false, + shellModeActive: false, + }, }, ); }; @@ -472,6 +477,11 @@ Implement a comprehensive authentication system with multiple providers. settings: createMockSettings({ ui: { useAlternateBuffer: useAlternateBuffer ?? true }, }), + inputState: { + buffer: { text: '' } as never, + showEscapePrompt: false, + shellModeActive: false, + }, }, ), ); diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 6719ae7c82..0696334577 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -26,6 +26,7 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; +import { useInputState } from '../contexts/InputContext.js'; import { ALL_ITEMS, type FooterItemId, @@ -173,6 +174,7 @@ interface FooterColumn { export const Footer: React.FC = () => { const uiState = useUIState(); + const { copyModeEnabled } = useInputState(); const config = useConfig(); const settings = useSettings(); const { vimEnabled, vimMode } = useVimMode(); @@ -365,10 +367,7 @@ export const Footer: React.FC = () => { id, header, () => ( - + ), 10, ); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index c9a7cd7f89..4636c6c1bc 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -8,12 +8,13 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { createMockSettings } from '../../test-utils/settings.js'; import { makeFakeConfig } from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; -import { act, useState } from 'react'; +import { act, useState, useMemo } from 'react'; import { InputPrompt, tryTogglePasteExpansion, type InputPromptProps, } from './InputPrompt.js'; +import { InputContext } from '../contexts/InputContext.js'; import { calculateTransformationsForLine, calculateTransformedLine, @@ -154,8 +155,47 @@ const mockSlashCommands: SlashCommand[] = [ }, ]; +export type TestInputPromptProps = InputPromptProps & { + buffer: TextBuffer; + userMessages: string[]; + shellModeActive: boolean; + copyModeEnabled?: boolean; + showEscapePrompt?: boolean; + inputWidth: number; + suggestionsWidth: number; +}; + +const TestInputPrompt = (props: TestInputPromptProps) => { + const contextValue = useMemo( + () => ({ + buffer: props.buffer, + userMessages: props.userMessages, + shellModeActive: props.shellModeActive, + copyModeEnabled: props.copyModeEnabled, + showEscapePrompt: props.showEscapePrompt || false, + inputWidth: props.inputWidth, + suggestionsWidth: props.suggestionsWidth, + }), + [ + props.buffer, + props.userMessages, + props.shellModeActive, + props.copyModeEnabled, + props.showEscapePrompt, + props.inputWidth, + props.suggestionsWidth, + ], + ); + + return ( + + + + ); +}; + describe('InputPrompt', () => { - let props: InputPromptProps; + let props: TestInputPromptProps; let mockShellHistory: UseShellHistoryReturn; let mockCommandCompletion: UseCommandCompletionReturn; let mockInputHistory: UseInputHistoryReturn; @@ -387,7 +427,7 @@ describe('InputPrompt', () => { it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => { props.shellModeActive = true; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -405,7 +445,7 @@ describe('InputPrompt', () => { it('should call shellHistory.getNextCommand on down arrow in shell mode', async () => { props.shellModeActive = true; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -426,7 +466,7 @@ describe('InputPrompt', () => { 'previous command', ); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -446,7 +486,7 @@ describe('InputPrompt', () => { props.shellModeActive = true; props.buffer.setText('ls -l'); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -479,7 +519,7 @@ describe('InputPrompt', () => { }); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -513,7 +553,7 @@ describe('InputPrompt', () => { }); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -540,7 +580,7 @@ describe('InputPrompt', () => { it('should NOT call shell history methods when not in shell mode', async () => { props.buffer.setText('some text'); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -578,7 +618,7 @@ describe('InputPrompt', () => { mockBuffer.visualScrollRow = 0; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -601,7 +641,7 @@ describe('InputPrompt', () => { mockBuffer.visualScrollRow = 0; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -624,7 +664,7 @@ describe('InputPrompt', () => { mockBuffer.visualScrollRow = 0; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -647,7 +687,7 @@ describe('InputPrompt', () => { mockBuffer.visualScrollRow = 0; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -678,7 +718,7 @@ describe('InputPrompt', () => { props.buffer.setText('/mem'); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -715,7 +755,7 @@ describe('InputPrompt', () => { props.buffer.setText('/mem'); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -747,7 +787,7 @@ describe('InputPrompt', () => { }); props.buffer.setText('some text'); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -780,7 +820,7 @@ describe('InputPrompt', () => { it('should clear the buffer and reset completion on Ctrl+C', async () => { mockBuffer.text = 'some text'; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -813,7 +853,7 @@ describe('InputPrompt', () => { ); const { stdin, unmount } = await renderWithProviders( - , + , ); // Send Ctrl+V @@ -837,7 +877,7 @@ describe('InputPrompt', () => { vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -856,7 +896,7 @@ describe('InputPrompt', () => { vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -886,7 +926,7 @@ describe('InputPrompt', () => { mockBuffer.replaceRangeByOffset = vi.fn(); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -917,7 +957,7 @@ describe('InputPrompt', () => { ); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -943,7 +983,7 @@ describe('InputPrompt', () => { vi.mocked(mockBuffer.replaceRangeByOffset).mockClear(); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -967,7 +1007,7 @@ describe('InputPrompt', () => { }); const { stdout, stdin, unmount } = await renderWithProviders( - , + , { settings }, ); @@ -1026,7 +1066,7 @@ describe('InputPrompt', () => { }); props.buffer.setText(bufferText); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1051,7 +1091,7 @@ describe('InputPrompt', () => { props.buffer.setText('/mem'); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1088,7 +1128,7 @@ describe('InputPrompt', () => { props.buffer.setText('/?'); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1108,7 +1148,7 @@ describe('InputPrompt', () => { props.streamingState = StreamingState.Responding; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1130,7 +1170,7 @@ describe('InputPrompt', () => { props.streamingState = StreamingState.Responding; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1155,7 +1195,7 @@ describe('InputPrompt', () => { props.streamingState = StreamingState.Responding; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1177,7 +1217,7 @@ describe('InputPrompt', () => { props.buffer.setText(' '); // Set buffer to whitespace const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1202,7 +1242,7 @@ describe('InputPrompt', () => { props.buffer.setText('/clear'); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1229,7 +1269,7 @@ describe('InputPrompt', () => { props.buffer.text = '/review'; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1259,7 +1299,7 @@ describe('InputPrompt', () => { props.buffer.text = '/review'; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1287,7 +1327,7 @@ describe('InputPrompt', () => { props.buffer.setText('/clear'); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1312,7 +1352,7 @@ describe('InputPrompt', () => { props.buffer.text = '@file.txt'; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1341,7 +1381,7 @@ describe('InputPrompt', () => { props.buffer.text = '@file.txt'; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1391,7 +1431,7 @@ describe('InputPrompt', () => { props.buffer.cursor = [0, 3]; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1435,7 +1475,7 @@ describe('InputPrompt', () => { props.buffer.cursor = [0, 3]; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1478,7 +1518,7 @@ describe('InputPrompt', () => { props.buffer.cursor = [0, 3]; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1512,7 +1552,7 @@ describe('InputPrompt', () => { props.buffer.cursor = [0, 3]; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1554,7 +1594,7 @@ describe('InputPrompt', () => { props.buffer.cursor = [0, 5]; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1606,7 +1646,7 @@ describe('InputPrompt', () => { props.buffer.cursor = [0, 10]; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1663,7 +1703,7 @@ describe('InputPrompt', () => { props.buffer.cursor = [0, 19]; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1715,7 +1755,7 @@ describe('InputPrompt', () => { props.buffer.cursor = [0, 10]; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1743,7 +1783,7 @@ describe('InputPrompt', () => { props.buffer.setText('@src/components/'); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1766,7 +1806,7 @@ describe('InputPrompt', () => { mockBuffer.lines = ['first line\\']; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1789,7 +1829,7 @@ describe('InputPrompt', () => { props.buffer.setText('some text to clear'); }); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1809,7 +1849,7 @@ describe('InputPrompt', () => { it('should render correctly in plan mode', async () => { props.approvalMode = ApprovalMode.PLAN; const { stdout, unmount } = await renderWithProviders( - , + , ); await waitFor(() => { @@ -1826,7 +1866,7 @@ describe('InputPrompt', () => { it('should NOT clear the buffer on Ctrl+C if it is empty', async () => { props.buffer.text = ''; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1844,7 +1884,7 @@ describe('InputPrompt', () => { it('should call setBannerVisible(false) when clear screen key is pressed', async () => { const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -1871,7 +1911,7 @@ describe('InputPrompt', () => { it('should render with background color by default', async () => { const { stdout, unmount } = await renderWithProviders( - , + , ); await waitFor(() => { @@ -1895,7 +1935,7 @@ describe('InputPrompt', () => { vi.mocked(isLowColorDepth).mockReturnValue(true); const { stdout, unmount } = await renderWithProviders( - , + , { uiState: { terminalBackgroundColor: color, @@ -1931,7 +1971,7 @@ describe('InputPrompt', () => { vi.mocked(isLowColorDepth).mockReturnValue(true); const { stdout, unmount } = await renderWithProviders( - , + , { uiState: { terminalBackgroundColor: '#333333', @@ -1954,7 +1994,7 @@ describe('InputPrompt', () => { vi.mocked(isLowColorDepth).mockReturnValue(true); const { stdout, unmount } = await renderWithProviders( - , + , { uiState: { terminalBackgroundColor: 'black', @@ -1977,7 +2017,7 @@ describe('InputPrompt', () => { vi.mocked(isLowColorDepth).mockReturnValue(true); const { stdout, unmount } = await renderWithProviders( - , + , { uiState: { @@ -2012,7 +2052,7 @@ describe('InputPrompt', () => { it('should render with plain borders when useBackgroundColor is false', async () => { props.config.getUseBackgroundColor = () => false; const { stdout, unmount } = await renderWithProviders( - , + , ); await waitFor(() => { @@ -2120,7 +2160,7 @@ describe('InputPrompt', () => { }); const { unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -2163,7 +2203,7 @@ describe('InputPrompt', () => { ])('$name', async ({ vimHandled, expectBufferHandleInput }) => { props.vimHandleInput = vi.fn().mockReturnValue(vimHandled); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => stdin.write('i')); @@ -2183,7 +2223,7 @@ describe('InputPrompt', () => { it('should handle bracketed paste when not focused', async () => { props.focus = false; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -2203,7 +2243,7 @@ describe('InputPrompt', () => { it('should ignore regular keypresses when not focused', async () => { props.focus = false; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -2280,7 +2320,7 @@ describe('InputPrompt', () => { props.config.getUseBackgroundColor = () => false; const renderResult = await renderWithProviders( - , + , ); await renderResult.waitUntilReady(); await expect(renderResult).toMatchSvgSnapshot(); @@ -2333,7 +2373,7 @@ describe('InputPrompt', () => { props.config.getUseBackgroundColor = () => false; const renderResult = await renderWithProviders( - , + , ); await renderResult.waitUntilReady(); await expect(renderResult).toMatchSvgSnapshot(); @@ -2356,7 +2396,7 @@ describe('InputPrompt', () => { props.config.getUseBackgroundColor = () => false; const renderResult = await renderWithProviders( - , + , ); await renderResult.waitUntilReady(); await expect(renderResult).toMatchSvgSnapshot(); @@ -2407,7 +2447,21 @@ describe('InputPrompt', () => { }), } as unknown as TextBuffer; - return ; + const inputState = { + buffer: fakeBuffer, + userMessages: [], + shellModeActive: false, + showEscapePrompt: false, + copyModeEnabled: false, + inputWidth: 80, + suggestionsWidth: 80, + }; + + return ( + + + + ); }; const { stdout, unmount, stdin } = await renderWithProviders( @@ -2469,7 +2523,7 @@ describe('InputPrompt', () => { props.config.getUseBackgroundColor = () => false; const renderResult = await renderWithProviders( - , + , ); await renderResult.waitUntilReady(); @@ -2495,7 +2549,7 @@ describe('InputPrompt', () => { }, ])('should handle multiline paste $description', async ({ pastedText }) => { const { stdin, unmount } = await renderWithProviders( - , + , ); // Simulate a bracketed paste event from the terminal @@ -2524,7 +2578,7 @@ describe('InputPrompt', () => { vi.mocked(clipboardy.read).mockResolvedValue(largeText); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -2547,7 +2601,7 @@ describe('InputPrompt', () => { vi.mocked(clipboardy.read).mockResolvedValue(largeText); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -2570,7 +2624,7 @@ describe('InputPrompt', () => { vi.mocked(clipboardy.read).mockResolvedValue(smallText); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -2595,7 +2649,7 @@ describe('InputPrompt', () => { mockBuffer.pastedContent = { [id]: largeText }; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -2629,7 +2683,7 @@ describe('InputPrompt', () => { props.buffer.text = 'some command'; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { await vi.runAllTimersAsync(); @@ -2665,7 +2719,7 @@ describe('InputPrompt', () => { props.buffer.text = '@file.txt'; const { stdin, unmount } = await renderWithProviders( - , + , ); // Simulate an unsafe paste of a perfect match @@ -2690,7 +2744,7 @@ describe('InputPrompt', () => { props.buffer.text = 'pasted text'; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { await vi.runAllTimersAsync(); @@ -2739,7 +2793,7 @@ describe('InputPrompt', () => { props.buffer.text = 'pasted command'; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { await vi.runAllTimersAsync(); @@ -2772,7 +2826,7 @@ describe('InputPrompt', () => { props.buffer.text = 'normal command'; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { await vi.runAllTimersAsync(); @@ -2803,7 +2857,7 @@ describe('InputPrompt', () => { props.buffer.setText('text to clear'); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -2823,7 +2877,7 @@ describe('InputPrompt', () => { vi.mocked(props.buffer.setText).mockClear(); const { stdin, unmount } = await renderWithProviders( - , + , { uiState: { history: [{ id: 1, type: 'user', text: 'test' }], @@ -2849,7 +2903,7 @@ describe('InputPrompt', () => { vi.mocked(props.buffer.setText).mockClear(); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -2868,7 +2922,7 @@ describe('InputPrompt', () => { props.buffer.setText('some text'); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -2891,7 +2945,7 @@ describe('InputPrompt', () => { props.shellModeActive = true; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -2911,7 +2965,7 @@ describe('InputPrompt', () => { const { stdin, unmount } = await renderWithProviders( <> - + , ); @@ -2935,7 +2989,7 @@ describe('InputPrompt', () => { const { stdin, unmount } = await renderWithProviders( <> - + , ); @@ -2959,7 +3013,7 @@ describe('InputPrompt', () => { }); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -2976,7 +3030,7 @@ describe('InputPrompt', () => { props.buffer.setText('some text'); const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { await vi.runAllTimersAsync(); @@ -2994,7 +3048,7 @@ describe('InputPrompt', () => { it('should not interfere with existing keyboard shortcuts', async () => { const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -3039,7 +3093,7 @@ describe('InputPrompt', () => { }); const { stdin, stdout, unmount } = await renderWithProviders( - , + , ); // Trigger reverse search with Ctrl+R @@ -3065,7 +3119,7 @@ describe('InputPrompt', () => { 'resets reverse search state on Escape ($name)', async ({ escapeSequence }) => { const { stdin, stdout, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -3113,7 +3167,7 @@ describe('InputPrompt', () => { ); const { stdin, stdout, unmount } = await renderWithProviders( - , + , ); // Enter reverse search mode with Ctrl+R @@ -3149,7 +3203,7 @@ describe('InputPrompt', () => { }); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -3183,7 +3237,7 @@ describe('InputPrompt', () => { }); const { stdin, stdout, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -3225,7 +3279,7 @@ describe('InputPrompt', () => { ); const { stdin, stdout, unmount } = await renderWithProviders( - , + , ); // reverse search with Ctrl+R @@ -3259,7 +3313,7 @@ describe('InputPrompt', () => { props.buffer.lines = ['line 1', 'line 2', 'line 3']; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -3278,7 +3332,7 @@ describe('InputPrompt', () => { props.buffer.lines = ['single line text']; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -3311,7 +3365,7 @@ describe('InputPrompt', () => { ); const { stdin, stdout, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -3341,7 +3395,7 @@ describe('InputPrompt', () => { }); const { stdin, stdout, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -3390,7 +3444,7 @@ describe('InputPrompt', () => { }); const { stdin, stdout, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -3431,7 +3485,7 @@ describe('InputPrompt', () => { }); const { stdin, stdout, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -3451,7 +3505,7 @@ describe('InputPrompt', () => { props.shellModeActive = false; props.userMessages = ['oldest', 'middle', 'newest']; - await renderWithProviders(); + await renderWithProviders(); const calls = vi.mocked(useReverseSearchCompletion).mock.calls; const commandSearchCall = calls.find( @@ -3514,7 +3568,7 @@ describe('InputPrompt', () => { }); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, uiState: {}, @@ -3562,7 +3616,7 @@ describe('InputPrompt', () => { }); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, }, @@ -3594,7 +3648,7 @@ describe('InputPrompt', () => { }); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, uiState: { activePtyId: 1, cleanUiDetailsVisible: false }, @@ -3629,7 +3683,7 @@ describe('InputPrompt', () => { }); const { stdin, unmount } = await renderWithProviders( - , + , { uiActions, uiState: {}, @@ -3694,7 +3748,7 @@ describe('InputPrompt', () => { props.buffer.visualScrollRow = 0; const { stdin, stdout, unmount } = await renderWithProviders( - , + , { mouseEventsEnabled: true, uiActions }, ); @@ -3729,7 +3783,7 @@ describe('InputPrompt', () => { props.isEmbeddedShellFocused = true; const { stdin, stdout, unmount } = await renderWithProviders( - , + , { mouseEventsEnabled: true, uiActions }, ); await waitFor(() => { @@ -3798,7 +3852,7 @@ describe('InputPrompt', () => { .mockReturnValue(isExpanded ? id : null), }; - return ; + return ; }; const { stdout, unmount, simulateClick } = await renderWithProviders( @@ -3891,7 +3945,7 @@ describe('InputPrompt', () => { ), }; - return ; + return ; }; const { stdout, unmount, simulateClick } = await renderWithProviders( @@ -3934,7 +3988,7 @@ describe('InputPrompt', () => { props.buffer.visualScrollRow = 0; const { stdin, stdout, unmount } = await renderWithProviders( - , + , { mouseEventsEnabled: true, uiActions }, ); @@ -3964,7 +4018,7 @@ describe('InputPrompt', () => { props.buffer.text = ''; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -3984,7 +4038,7 @@ describe('InputPrompt', () => { props.buffer.text = 'some text'; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -4004,7 +4058,7 @@ describe('InputPrompt', () => { props.buffer.text = ''; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -4026,7 +4080,7 @@ describe('InputPrompt', () => { props.buffer.visualScrollRow = 0; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -4043,7 +4097,7 @@ describe('InputPrompt', () => { props.buffer.text = ''; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -4061,7 +4115,7 @@ describe('InputPrompt', () => { props.buffer.text = ' '; // Whitespace only const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -4076,7 +4130,7 @@ describe('InputPrompt', () => { props.buffer.text = ''; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -4095,7 +4149,7 @@ describe('InputPrompt', () => { props.buffer.text = ''; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { @@ -4114,7 +4168,7 @@ describe('InputPrompt', () => { it('should render correctly in shell mode', async () => { props.shellModeActive = true; const { stdout, unmount } = await renderWithProviders( - , + , ); await waitFor(() => expect(stdout.lastFrame()).toContain('!')); expect(stdout.lastFrame()).toMatchSnapshot(); @@ -4124,7 +4178,7 @@ describe('InputPrompt', () => { it('should render correctly when accepting edits', async () => { props.approvalMode = ApprovalMode.AUTO_EDIT; const { stdout, unmount } = await renderWithProviders( - , + , ); await waitFor(() => expect(stdout.lastFrame()).toContain('>')); expect(stdout.lastFrame()).toMatchSnapshot(); @@ -4134,7 +4188,7 @@ describe('InputPrompt', () => { it('should render correctly in yolo mode', async () => { props.approvalMode = ApprovalMode.YOLO; const { stdout, unmount } = await renderWithProviders( - , + , ); await waitFor(() => expect(stdout.lastFrame()).toContain('*')); expect(stdout.lastFrame()).toMatchSnapshot(); @@ -4144,7 +4198,7 @@ describe('InputPrompt', () => { props.isEmbeddedShellFocused = true; props.focus = false; const renderResult = await renderWithProviders( - , + , ); await renderResult.waitUntilReady(); await expect(renderResult).toMatchSvgSnapshot(); @@ -4154,7 +4208,7 @@ describe('InputPrompt', () => { it('should still allow input when shell is not focused', async () => { const { stdin, unmount } = await renderWithProviders( - , + , { shellFocus: false, }, @@ -4209,7 +4263,7 @@ describe('InputPrompt', () => { props.shellModeActive = shellMode; const { stdin, unmount } = await renderWithProviders( - , + , ); await act(async () => { stdin.write('\r'); @@ -4242,7 +4296,7 @@ describe('InputPrompt', () => { mockBuffer.visualScrollRow = 0; const { stdout, unmount } = await renderWithProviders( - , + , { uiActions }, ); @@ -4273,7 +4327,7 @@ describe('InputPrompt', () => { mockBuffer.visualScrollRow = 0; const { stdout, unmount } = await renderWithProviders( - , + , { uiActions }, ); @@ -4303,7 +4357,7 @@ describe('InputPrompt', () => { mockBuffer.visualScrollRow = 0; const { stdout, unmount } = await renderWithProviders( - , + , { uiActions }, ); @@ -4338,7 +4392,7 @@ describe('InputPrompt', () => { mockBuffer.visualScrollRow = 0; const { stdout, unmount } = await renderWithProviders( - , + , { uiActions }, ); @@ -4377,7 +4431,7 @@ describe('InputPrompt', () => { mockBuffer.visualScrollRow = 0; const { stdout, unmount } = await renderWithProviders( - , + , { uiActions }, ); @@ -4415,7 +4469,7 @@ describe('InputPrompt', () => { mockBuffer.visualScrollRow = 0; const { stdout, unmount } = await renderWithProviders( - , + , { uiActions }, ); @@ -4461,7 +4515,7 @@ describe('InputPrompt', () => { applyVisualState(transformedLine, transformations[0].logEnd + 5); const { stdout, unmount } = await renderWithProviders( - , + , ); await waitFor(() => { expect(stdout.lastFrame()).toContain('[Image'); @@ -4480,7 +4534,7 @@ describe('InputPrompt', () => { applyVisualState(transformedLine, transformations[0].logStart + 1); const { stdout, unmount } = await renderWithProviders( - , + , ); await waitFor(() => { expect(stdout.lastFrame()).toContain('@/path/to/screenshots'); @@ -4521,7 +4575,7 @@ describe('InputPrompt', () => { } as unknown as TextBuffer; const { stdin, unmount } = await renderWithProviders( - , + , { uiActions }, ); @@ -4573,7 +4627,7 @@ describe('InputPrompt', () => { } const { stdin, unmount } = await renderWithProviders( - , @@ -4767,7 +4821,7 @@ describe('InputPrompt', () => { 'should move cursor to $position on $name (older history)', async ({ key, position }) => { const { stdin } = await renderWithProviders( - , + , { uiActions, }, @@ -4793,7 +4847,7 @@ describe('InputPrompt', () => { 'should move cursor to $position on $name (newer history)', async ({ key, position }) => { const { stdin } = await renderWithProviders( - , + , { uiActions, }, @@ -4823,9 +4877,12 @@ describe('InputPrompt', () => { ); it('should suppress completion after history navigation', async () => { - const { stdin } = await renderWithProviders(, { - uiActions, - }); + const { stdin } = await renderWithProviders( + , + { + uiActions, + }, + ); await act(async () => { stdin.write('\u001B[A'); // Up arrow @@ -4856,7 +4913,7 @@ describe('InputPrompt', () => { })); const { stdout, stdin, unmount } = await renderWithProviders( - , + , { uiActions }, ); @@ -4880,9 +4937,12 @@ describe('InputPrompt', () => { }); it('should continue to suppress completion after manual cursor movement', async () => { - const { stdin } = await renderWithProviders(, { - uiActions, - }); + const { stdin } = await renderWithProviders( + , + { + uiActions, + }, + ); // Navigate history (suppresses) await act(async () => { @@ -4923,9 +4983,12 @@ describe('InputPrompt', () => { }); it('should re-enable completion after typing', async () => { - const { stdin } = await renderWithProviders(, { - uiActions, - }); + const { stdin } = await renderWithProviders( + , + { + uiActions, + }, + ); // Navigate history (suppresses) await act(async () => { @@ -4960,7 +5023,7 @@ describe('InputPrompt', () => { }); const { stdin, unmount } = await renderWithProviders( - , + , { settings, uiActions: { setShortcutsHelpVisible }, @@ -5017,7 +5080,7 @@ describe('InputPrompt', () => { setupMocks?.(); const setShortcutsHelpVisible = vi.fn(); const { stdin, unmount } = await renderWithProviders( - , + , { uiState: { shortcutsHelpVisible: true }, uiActions: { setShortcutsHelpVisible }, diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index a8248bdd85..ec5fd8cb5d 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -70,6 +70,7 @@ import { getSafeLowColorBackground } from '../themes/color-utils.js'; import { isLowColorDepth } from '../utils/terminalUtils.js'; import { useShellFocusState } from '../contexts/ShellFocusContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { useInputState } from '../contexts/InputContext.js'; import { appEvents, AppEvent, @@ -104,18 +105,13 @@ export type ScrollableItem = | { type: 'ghostLine'; ghostLine: string; index: number }; export interface InputPromptProps { - buffer: TextBuffer; onSubmit: (value: string) => void; - userMessages: readonly string[]; onClearScreen: () => void; config: Config; slashCommands: readonly SlashCommand[]; commandContext: CommandContext; placeholder?: string; focus?: boolean; - inputWidth: number; - suggestionsWidth: number; - shellModeActive: boolean; setShellModeActive: (value: boolean) => void; approvalMode: ApprovalMode; onEscapePromptChange?: (showPrompt: boolean) => void; @@ -128,7 +124,6 @@ export interface InputPromptProps { onQueueMessage?: (message: string) => void; suggestionsPosition?: 'above' | 'below'; setBannerVisible: (visible: boolean) => void; - copyModeEnabled?: boolean; } // The input content, input container, and input suggestions list may have different widths @@ -199,18 +194,13 @@ export function tryTogglePasteExpansion(buffer: TextBuffer): boolean { } export const InputPrompt: React.FC = ({ - buffer, onSubmit, - userMessages, onClearScreen, config, slashCommands, commandContext, placeholder = ' Type your message or @path/to/file', focus = true, - inputWidth, - suggestionsWidth, - shellModeActive, setShellModeActive, approvalMode, onEscapePromptChange, @@ -223,8 +213,16 @@ export const InputPrompt: React.FC = ({ onQueueMessage, suggestionsPosition = 'below', setBannerVisible, - copyModeEnabled = false, }) => { + const inputState = useInputState(); + const { + buffer, + userMessages, + shellModeActive, + copyModeEnabled, + inputWidth, + suggestionsWidth, + } = inputState; const isHelpDismissKey = useIsHelpDismissKey(); const keyMatchers = useKeyMatchers(); const { stdout } = useStdout(); diff --git a/packages/cli/src/ui/components/StatusRow.test.tsx b/packages/cli/src/ui/components/StatusRow.test.tsx index b80dbacabe..5f14254f4b 100644 --- a/packages/cli/src/ui/components/StatusRow.test.tsx +++ b/packages/cli/src/ui/components/StatusRow.test.tsx @@ -9,7 +9,7 @@ import { StatusRow } from './StatusRow.js'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { useComposerStatus } from '../hooks/useComposerStatus.js'; import { type UIState } from '../contexts/UIStateContext.js'; -import { type TextBuffer } from '../components/shared/text-buffer.js'; + import { type SessionStatsState } from '../contexts/SessionContext.js'; import { type ThoughtSummary } from '../types.js'; import { ApprovalMode } from '@google/gemini-cli-core'; @@ -29,13 +29,11 @@ describe('', () => { elapsedTime: 0, currentWittyPhrase: undefined, activeHooks: [], - buffer: { text: '' } as unknown as TextBuffer, sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState, shortcutsHelpVisible: false, contextFileNames: [], showApprovalModeIndicator: ApprovalMode.DEFAULT, allowPlanMode: false, - shellModeActive: false, renderMarkdown: true, currentModel: 'gemini-3', }; diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx index 2f059086b0..24b5a97d4e 100644 --- a/packages/cli/src/ui/components/StatusRow.tsx +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -153,6 +153,8 @@ export const StatusNode: React.FC<{ ); }; +import { useInputState } from '../contexts/InputContext.js'; + export const StatusRow: React.FC = ({ showUiDetails, isNarrow, @@ -162,6 +164,7 @@ export const StatusRow: React.FC = ({ hasPendingActionRequired, }) => { const uiState = useUIState(); + const inputState = useInputState(); const settings = useSettings(); const { isInteractiveShellWaiting, @@ -225,7 +228,7 @@ export const StatusRow: React.FC = ({ settings.merged.ui.showShortcutsHint && !hideUiDetailsForSuggestions && !hasPendingActionRequired && - uiState.buffer.text.length === 0 + inputState.buffer.text.length === 0 ) { return showUiDetails ? '? for shortcuts' : 'press tab twice for more'; } @@ -391,13 +394,14 @@ export const StatusRow: React.FC = ({ > {showUiDetails ? ( <> - {!hideUiDetailsForSuggestions && !uiState.shellModeActive && ( - - )} - {uiState.shellModeActive && ( + {!hideUiDetailsForSuggestions && + !inputState.shellModeActive && ( + + )} + {inputState.shellModeActive && ( diff --git a/packages/cli/src/ui/components/ToastDisplay.test.tsx b/packages/cli/src/ui/components/ToastDisplay.test.tsx index 9bd2847b3f..477fa47f62 100644 --- a/packages/cli/src/ui/components/ToastDisplay.test.tsx +++ b/packages/cli/src/ui/components/ToastDisplay.test.tsx @@ -9,16 +9,24 @@ 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 InputState } from '../contexts/InputContext.js'; import { type TextBuffer } from './shared/text-buffer.js'; import { type HistoryItem } from '../types.js'; -const renderToastDisplay = async (uiState: Partial = {}) => +const renderToastDisplay = async ( + uiState: Partial = {}, + inputState: Partial = {}, +) => renderWithProviders(, { uiState: { - buffer: { text: '' } as TextBuffer, history: [] as HistoryItem[], ...uiState, }, + inputState: { + buffer: { text: '' } as TextBuffer, + showEscapePrompt: false, + ...inputState, + }, }); describe('ToastDisplay', () => { @@ -27,86 +35,121 @@ describe('ToastDisplay', () => { }); describe('shouldShowToast', () => { - const baseState: Partial = { + const baseUIState: Partial = { ctrlCPressedOnce: false, transientMessage: null, ctrlDPressedOnce: false, - showEscapePrompt: false, - buffer: { text: '' } as TextBuffer, history: [] as HistoryItem[], queueErrorMessage: null, showIsExpandableHint: false, }; + const baseInputState: Partial = { + showEscapePrompt: false, + buffer: { text: '' } as TextBuffer, + }; + it('returns false for default state', () => { - expect(shouldShowToast(baseState as UIState)).toBe(false); + expect( + shouldShowToast(baseUIState as UIState, baseInputState as InputState), + ).toBe(false); }); it('returns true when showIsExpandableHint is true', () => { expect( - shouldShowToast({ - ...baseState, - showIsExpandableHint: true, - } as UIState), + shouldShowToast( + { + ...baseUIState, + showIsExpandableHint: true, + } as UIState, + baseInputState as InputState, + ), ).toBe(true); }); it('returns true when ctrlCPressedOnce is true', () => { expect( - shouldShowToast({ ...baseState, ctrlCPressedOnce: true } as UIState), + shouldShowToast( + { ...baseUIState, ctrlCPressedOnce: true } as UIState, + baseInputState as InputState, + ), ).toBe(true); }); it('returns true when transientMessage is present', () => { expect( - shouldShowToast({ - ...baseState, - transientMessage: { text: 'test', type: TransientMessageType.Hint }, - } as UIState), + shouldShowToast( + { + ...baseUIState, + transientMessage: { text: 'test', type: TransientMessageType.Hint }, + } as UIState, + baseInputState as InputState, + ), ).toBe(true); }); it('returns true when ctrlDPressedOnce is true', () => { expect( - shouldShowToast({ ...baseState, ctrlDPressedOnce: true } as UIState), + shouldShowToast( + { ...baseUIState, ctrlDPressedOnce: true } as UIState, + baseInputState as InputState, + ), ).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), + shouldShowToast( + { + ...baseUIState, + } as UIState, + { + ...baseInputState, + showEscapePrompt: true, + buffer: { text: 'some text' } as TextBuffer, + } as InputState, + ), ).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), + shouldShowToast( + { + ...baseUIState, + history: [{ id: '1' } as unknown as HistoryItem], + } as UIState, + { + ...baseInputState, + showEscapePrompt: true, + } as InputState, + ), ).toBe(true); }); it('returns false when showEscapePrompt is true but buffer and history are empty', () => { expect( - shouldShowToast({ - ...baseState, - showEscapePrompt: true, - } as UIState), + shouldShowToast( + { + ...baseUIState, + } as UIState, + { + ...baseInputState, + showEscapePrompt: true, + } as InputState, + ), ).toBe(false); }); it('returns true when queueErrorMessage is present', () => { expect( - shouldShowToast({ - ...baseState, - queueErrorMessage: 'error', - } as UIState), + shouldShowToast( + { + ...baseUIState, + queueErrorMessage: 'error', + } as UIState, + baseInputState as InputState, + ), ).toBe(true); }); }); @@ -151,18 +194,25 @@ describe('ToastDisplay', () => { }); it('renders Escape prompt when buffer is empty', async () => { - const { lastFrame } = await renderToastDisplay({ - showEscapePrompt: true, - history: [{ id: 1, type: 'user', text: 'test' }] as HistoryItem[], - }); + const { lastFrame } = await renderToastDisplay( + { + history: [{ id: 1, type: 'user', text: 'test' }] as HistoryItem[], + }, + { + showEscapePrompt: true, + }, + ); expect(lastFrame()).toMatchSnapshot(); }); it('renders Escape prompt when buffer is NOT empty', async () => { - const { lastFrame } = await renderToastDisplay({ - showEscapePrompt: true, - buffer: { text: 'some text' } as TextBuffer, - }); + const { lastFrame } = await renderToastDisplay( + {}, + { + showEscapePrompt: true, + buffer: { text: 'some text' } as TextBuffer, + }, + ); expect(lastFrame()).toMatchSnapshot(); }); diff --git a/packages/cli/src/ui/components/ToastDisplay.tsx b/packages/cli/src/ui/components/ToastDisplay.tsx index a43e062776..617c9bc7ed 100644 --- a/packages/cli/src/ui/components/ToastDisplay.tsx +++ b/packages/cli/src/ui/components/ToastDisplay.tsx @@ -8,15 +8,19 @@ import type React from 'react'; import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useUIState, type UIState } from '../contexts/UIStateContext.js'; +import { useInputState, type InputState } from '../contexts/InputContext.js'; import { TransientMessageType } from '../../utils/events.js'; -export function shouldShowToast(uiState: UIState): boolean { +export function shouldShowToast( + uiState: UIState, + inputState: InputState, +): boolean { return ( uiState.ctrlCPressedOnce || Boolean(uiState.transientMessage) || uiState.ctrlDPressedOnce || - (uiState.showEscapePrompt && - (uiState.buffer.text.length > 0 || uiState.history.length > 0)) || + (inputState.showEscapePrompt && + (inputState.buffer.text.length > 0 || uiState.history.length > 0)) || Boolean(uiState.queueErrorMessage) || uiState.showIsExpandableHint ); @@ -24,6 +28,7 @@ export function shouldShowToast(uiState: UIState): boolean { export const ToastDisplay: React.FC = () => { const uiState = useUIState(); + const inputState = useInputState(); if (uiState.ctrlCPressedOnce) { return ( @@ -46,8 +51,8 @@ export const ToastDisplay: React.FC = () => { ); } - if (uiState.showEscapePrompt) { - const isPromptEmpty = uiState.buffer.text.length === 0; + if (inputState.showEscapePrompt) { + const isPromptEmpty = inputState.buffer.text.length === 0; const hasHistory = uiState.history.length > 0; if (isPromptEmpty && !hasHistory) { diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index b527724492..c3f888ba5f 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -85,32 +85,43 @@ const VirtualizedListItem = memo( width, containerWidth, itemKey, - itemRef, + index, + onSetRef, }: { content: React.ReactElement; shouldBeStatic: boolean; width: number | string | undefined; containerWidth: number; itemKey: string; - itemRef: (el: DOMElement | null) => void; - }) => ( - - {shouldBeStatic ? ( - - {content} - - ) : ( - content - )} - - ), + index: number; + onSetRef: (index: number, el: DOMElement | null) => void; + }) => { + const itemRef = useCallback( + (el: DOMElement | null) => { + onSetRef(index, el); + }, + [index, onSetRef], + ); + + return ( + + {shouldBeStatic ? ( + + {content} + + ) : ( + content + )} + + ); + }, ); VirtualizedListItem.displayName = 'VirtualizedListItem'; @@ -195,6 +206,10 @@ function VirtualizedList( const containerObserverRef = useRef(null); const nodeToKeyRef = useRef(new WeakMap()); + const onSetRef = useCallback((index: number, el: DOMElement | null) => { + itemRefs.current[index] = el; + }, []); + const containerRefCallback = useCallback((node: DOMElement | null) => { containerObserverRef.current?.disconnect(); containerRef.current = node; @@ -517,7 +532,6 @@ function VirtualizedList( observedNodes.current = currentNodes; }); - const renderedItems = []; const renderRangeStart = renderStatic || overflowToBackbuffer ? 0 : startIndex; const renderRangeEnd = renderStatic ? data.length - 1 : endIndex; @@ -533,7 +547,12 @@ function VirtualizedList( process.env['NODE_ENV'] === 'test' || (width !== undefined && typeof width === 'number'); - if (isReady) { + const renderedItems = useMemo(() => { + if (!isReady) { + return []; + } + + const items = []; for (let i = renderRangeStart; i <= renderRangeEnd; i++) { const item = data[i]; if (item) { @@ -545,7 +564,7 @@ function VirtualizedList( const content = renderItem({ item, index: i }); const key = keyExtractor(item, i); - renderedItems.push( + items.push( ( shouldBeStatic={shouldBeStatic} width={width} containerWidth={containerWidth} - itemRef={(el: DOMElement | null) => { - if (i >= renderRangeStart && i <= renderRangeEnd) { - itemRefs.current[i] = el; - } - }} + index={i} + onSetRef={onSetRef} />, ); } } - } + return items; + }, [ + isReady, + renderRangeStart, + renderRangeEnd, + data, + startIndex, + endIndex, + renderStatic, + isStaticItem, + renderItem, + keyExtractor, + width, + containerWidth, + onSetRef, + ]); const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop); diff --git a/packages/cli/src/ui/contexts/InputContext.tsx b/packages/cli/src/ui/contexts/InputContext.tsx new file mode 100644 index 0000000000..45c1ff0672 --- /dev/null +++ b/packages/cli/src/ui/contexts/InputContext.tsx @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createContext, useContext } from 'react'; +import type { TextBuffer } from '../components/shared/text-buffer.js'; + +export interface InputState { + buffer: TextBuffer; + userMessages: string[]; + shellModeActive: boolean; + showEscapePrompt: boolean; + copyModeEnabled: boolean | undefined; + inputWidth: number; + suggestionsWidth: number; +} + +export const InputContext = createContext(null); + +export const useInputState = () => { + const context = useContext(InputContext); + if (!context) { + throw new Error('useInputState must be used within an InputProvider'); + } + return context; +}; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 99d5874aba..3dd7e96467 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -17,7 +17,7 @@ import type { PermissionConfirmationRequest, } from '../types.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; -import type { TextBuffer } from '../components/shared/text-buffer.js'; + import type { IdeContext, ApprovalMode, @@ -143,11 +143,6 @@ export interface UIState { initError: string | null; pendingGeminiHistoryItems: HistoryItemWithoutId[]; thought: ThoughtSummary | null; - shellModeActive: boolean; - userMessages: string[]; - buffer: TextBuffer; - inputWidth: number; - suggestionsWidth: number; isInputActive: boolean; isResuming: boolean; shouldShowIdePrompt: boolean; @@ -162,7 +157,6 @@ export interface UIState { renderMarkdown: boolean; ctrlCPressedOnce: boolean; ctrlDPressedOnce: boolean; - showEscapePrompt: boolean; shortcutsHelpVisible: boolean; cleanUiDetailsVisible: boolean; elapsedTime: number; @@ -207,7 +201,6 @@ export interface UIState { embeddedShellFocused: boolean; showDebugProfiler: boolean; showFullTodos: boolean; - copyModeEnabled: boolean; bannerData: { defaultText: string; warningText: string; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx index 402ff501ad..6be2424194 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx @@ -5,8 +5,11 @@ */ import { render } from '../../test-utils/render.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; import { DefaultAppLayout } from './DefaultAppLayout.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useInputState } from '../contexts/InputContext.js'; + +vi.mock('../contexts/InputContext.js'); import { StreamingState } from '../types.js'; import { Text } from 'ink'; import type { UIState } from '../contexts/UIStateContext.js'; @@ -95,6 +98,9 @@ const createMockShell = (pid: number): BackgroundTask => ({ describe('', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(useInputState).mockReturnValue({ + copyModeEnabled: false, + } as unknown as ReturnType); // Reset mock state defaults mockUIState.backgroundTasks = new Map(); mockUIState.activeBackgroundTaskPid = null; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 964fb5ec55..bb1fc3e9b7 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -17,9 +17,11 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { CopyModeWarning } from '../components/CopyModeWarning.js'; import { BackgroundTaskDisplay } from '../components/BackgroundTaskDisplay.js'; import { StreamingState } from '../types.js'; +import { useInputState } from '../contexts/InputContext.js'; export const DefaultAppLayout: React.FC = () => { const uiState = useUIState(); + const { copyModeEnabled } = useInputState(); const isAlternateBuffer = useAlternateBuffer(); const { rootUiRef, terminalHeight } = uiState; @@ -62,9 +64,7 @@ export const DefaultAppLayout: React.FC = () => { flexShrink={0} flexGrow={0} width={uiState.terminalWidth} - height={ - uiState.copyModeEnabled ? uiState.stableControlsHeight : undefined - } + height={copyModeEnabled ? uiState.stableControlsHeight : undefined} >