From cbbf56512179105b2298d8ffae26ae6a59589fff Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Tue, 11 Nov 2025 07:50:11 -0800 Subject: [PATCH] Support ink scrolling final pr (#12567) --- packages/cli/src/gemini.tsx | 1 - packages/cli/src/test-utils/render.tsx | 19 +- packages/cli/src/ui/App.test.tsx | 45 +- packages/cli/src/ui/App.tsx | 13 +- packages/cli/src/ui/AppContainer.test.tsx | 581 +++++------------- packages/cli/src/ui/AppContainer.tsx | 24 +- .../AlternateBufferQuittingDisplay.test.tsx | 119 ++++ .../AlternateBufferQuittingDisplay.tsx | 58 ++ packages/cli/src/ui/components/Composer.tsx | 21 +- .../cli/src/ui/components/CopyModeWarning.tsx | 26 + .../ui/components/DetailedMessagesDisplay.tsx | 2 +- .../ui/components/HistoryItemDisplay.test.tsx | 177 +++--- .../src/ui/components/HistoryItemDisplay.tsx | 4 +- .../cli/src/ui/components/InputPrompt.tsx | 53 +- .../cli/src/ui/components/MainContent.tsx | 165 +++-- .../src/ui/components/StickyHeader.test.tsx | 21 + .../cli/src/ui/components/StickyHeader.tsx | 44 ++ .../cli/src/ui/components/ThemeDialog.tsx | 20 +- ...ternateBufferQuittingDisplay.test.tsx.snap | 28 + .../HistoryItemDisplay.test.tsx.snap | 493 +++++++++++---- .../components/messages/DiffRenderer.test.tsx | 431 +++++++------ .../ui/components/messages/DiffRenderer.tsx | 391 +++++++----- .../ui/components/messages/GeminiMessage.tsx | 6 +- .../messages/GeminiMessageContent.tsx | 6 +- .../messages/ToolConfirmationMessage.tsx | 419 +++++++------ .../messages/ToolGroupMessage.test.tsx | 19 +- .../components/messages/ToolGroupMessage.tsx | 41 +- .../ui/components/messages/ToolMessage.tsx | 148 +++-- .../messages/ToolMessageRawMarkdown.test.tsx | 42 +- .../ui/components/messages/UserMessage.tsx | 13 +- .../__snapshots__/DiffRenderer.test.tsx.snap | 175 ++++++ .../ToolGroupMessage.test.tsx.snap | 7 +- .../ToolMessageRawMarkdown.test.tsx.snap | 30 +- .../components/shared/ScrollableList.test.tsx | 126 +++- .../ui/components/shared/VirtualizedList.tsx | 1 + packages/cli/src/ui/constants.ts | 6 + .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../cli/src/ui/hooks/useAlternateBuffer.ts | 12 + .../cli/src/ui/hooks/useGeminiStream.test.tsx | 4 + .../cli/src/ui/layouts/DefaultAppLayout.tsx | 30 +- packages/cli/src/ui/utils/CodeColorizer.tsx | 171 ++++-- packages/cli/src/ui/utils/MarkdownDisplay.tsx | 60 +- packages/cli/src/ui/utils/ui-sizing.ts | 13 +- 43 files changed, 2498 insertions(+), 1568 deletions(-) create mode 100644 packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx create mode 100644 packages/cli/src/ui/components/CopyModeWarning.tsx create mode 100644 packages/cli/src/ui/components/StickyHeader.test.tsx create mode 100644 packages/cli/src/ui/components/StickyHeader.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap create mode 100644 packages/cli/src/ui/hooks/useAlternateBuffer.ts diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 330de4beab..4d96c64daf 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -206,7 +206,6 @@ export async function startInteractiveUI( => { const baseState: UIState = new Proxy( @@ -150,7 +152,18 @@ export const renderWithProviders = ( ) as UIState; const terminalWidth = width ?? baseState.terminalWidth; - const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings); + let finalSettings = settings; + if (useAlternateBuffer !== undefined) { + finalSettings = createMockSettings({ + ...settings.merged, + ui: { + ...settings.merged.ui, + useAlternateBuffer, + }, + }); + } + + const mainAreaWidth = calculateMainAreaWidth(terminalWidth, finalSettings); const finalUiState = { ...baseState, @@ -160,9 +173,9 @@ export const renderWithProviders = ( return render( - + - + diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 83bae31d1d..de9213a3f6 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -12,6 +12,7 @@ import { App } from './App.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; import { StreamingState } from './types.js'; import { ConfigContext } from './contexts/ConfigContext.js'; +import { AppContext, type AppState } from './contexts/AppContext.js'; import { SettingsContext } from './contexts/SettingsContext.js'; import { type SettingScope, @@ -47,6 +48,10 @@ vi.mock('./components/QuittingDisplay.js', () => ({ QuittingDisplay: () => Quitting..., })); +vi.mock('./components/HistoryItemDisplay.js', () => ({ + HistoryItemDisplay: () => HistoryItemDisplay, +})); + vi.mock('./components/Footer.js', () => ({ Footer: () => Footer, })); @@ -65,6 +70,8 @@ describe('App', () => { clearItems: vi.fn(), loadHistory: vi.fn(), }, + history: [], + pendingHistoryItems: [], }; const mockConfig = makeFakeConfig(); @@ -84,13 +91,22 @@ describe('App', () => { new Set(), ); + const mockAppState: AppState = { + version: '1.0.0', + startupWarnings: [], + }; + const renderWithProviders = (ui: React.ReactElement, state: UIState) => render( - - - {ui} - - , + + + + + {ui} + + + + , ); it('should render main content and composer when not quitting', () => { @@ -112,6 +128,25 @@ describe('App', () => { expect(lastFrame()).toContain('Quitting...'); }); + it('should render full history in alternate buffer mode when quittingMessages is set', () => { + const quittingUIState = { + ...mockUIState, + quittingMessages: [{ id: 1, type: 'user', text: 'test' }], + history: [{ id: 1, type: 'user', text: 'history item' }], + pendingHistoryItems: [{ type: 'user', text: 'pending item' }], + } as UIState; + + mockLoadedSettings.merged.ui = { useAlternateBuffer: true }; + + const { lastFrame } = renderWithProviders(, quittingUIState); + + expect(lastFrame()).toContain('HistoryItemDisplay'); + expect(lastFrame()).toContain('Quitting...'); + + // Reset settings + mockLoadedSettings.merged.ui = { useAlternateBuffer: false }; + }); + it('should render dialog manager when dialogs are visible', () => { const dialogUIState = { ...mockUIState, diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 54684a8c2c..2c3e424ae4 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -10,13 +10,24 @@ import { StreamingContext } from './contexts/StreamingContext.js'; import { QuittingDisplay } from './components/QuittingDisplay.js'; import { ScreenReaderAppLayout } from './layouts/ScreenReaderAppLayout.js'; import { DefaultAppLayout } from './layouts/DefaultAppLayout.js'; +import { AlternateBufferQuittingDisplay } from './components/AlternateBufferQuittingDisplay.js'; +import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; export const App = () => { const uiState = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); if (uiState.quittingMessages) { - return ; + if (isAlternateBuffer) { + return ( + + + + ); + } else { + return ; + } } return ( diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index df63e54483..bcff805fba 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -18,11 +18,13 @@ import { render } from '../test-utils/render.js'; import { cleanup } from 'ink-testing-library'; import { act, useContext } from 'react'; import { AppContainer } from './AppContainer.js'; +import { SettingsContext } from './contexts/SettingsContext.js'; import { type Config, makeFakeConfig, CoreEvent, type UserFeedbackPayload, + type ResumedSessionData, } from '@google/gemini-cli-core'; // Mock coreEvents @@ -146,6 +148,37 @@ describe('AppContainer State Management', () => { let mockInitResult: InitializationResult; let mockExtensionManager: MockedObject; + // Helper to generate the AppContainer JSX for render and rerender + const getAppContainer = ({ + settings = mockSettings, + config = mockConfig, + version = '1.0.0', + initResult = mockInitResult, + startupWarnings, + resumedSessionData, + }: { + settings?: LoadedSettings; + config?: Config; + version?: string; + initResult?: InitializationResult; + startupWarnings?: string[]; + resumedSessionData?: ResumedSessionData; + } = {}) => ( + + + + ); + + // Helper to render the AppContainer + const renderAppContainer = (props?: Parameters[0]) => + render(getAppContainer(props)); + // Create typed mocks for all hooks const mockedUseQuotaAndFallback = useQuotaAndFallback as Mock; const mockedUseHistory = useHistory as Mock; @@ -313,6 +346,7 @@ describe('AppContainer State Management', () => { showStatusInTitle: false, hideWindowTitle: false, }, + useAlternateBuffer: false, }, } as unknown as LoadedSettings; @@ -331,14 +365,7 @@ describe('AppContainer State Management', () => { describe('Basic Rendering', () => { it('renders without crashing with minimal props', async () => { - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -348,15 +375,7 @@ describe('AppContainer State Management', () => { it('renders with startup warnings', async () => { const startupWarnings = ['Warning 1', 'Warning 2']; - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ startupWarnings }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -371,14 +390,9 @@ describe('AppContainer State Management', () => { themeError: 'Failed to load theme', }; - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ + initResult: initResultWithError, + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -390,28 +404,14 @@ describe('AppContainer State Management', () => { vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true); expect(() => { - render( - , - ); + renderAppContainer({ config: debugConfig }); }).not.toThrow(); }); }); describe('Context Providers', () => { it('provides AppContext with correct values', async () => { - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ version: '2.0.0' }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -421,14 +421,7 @@ describe('AppContainer State Management', () => { }); it('provides UIStateContext with state management', async () => { - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -436,14 +429,7 @@ describe('AppContainer State Management', () => { }); it('provides UIActionsContext with action handlers', async () => { - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -451,14 +437,7 @@ describe('AppContainer State Management', () => { }); it('provides ConfigContext with config object', async () => { - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -477,14 +456,7 @@ describe('AppContainer State Management', () => { }, } as unknown as LoadedSettings; - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ settings: settingsAllHidden }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -501,14 +473,7 @@ describe('AppContainer State Management', () => { }, } as unknown as LoadedSettings; - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ settings: settingsWithMemory }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -520,14 +485,7 @@ describe('AppContainer State Management', () => { it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])( 'handles version format: %s', async (version) => { - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ version }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -544,14 +502,7 @@ describe('AppContainer State Management', () => { }); // Should still render without crashing - errors should be handled internally - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ config: errorConfig }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -563,14 +514,7 @@ describe('AppContainer State Management', () => { merged: {}, } as LoadedSettings; - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ settings: undefinedSettings }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -582,14 +526,7 @@ describe('AppContainer State Management', () => { it('establishes correct provider nesting order', () => { // This tests that all the context providers are properly nested // and that the component tree can be built without circular dependencies - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); expect(() => unmount()).not.toThrow(); }); @@ -625,15 +562,13 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { - const result = render( - , - ); + const result = renderAppContainer({ + config: mockConfig, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + resumedSessionData: mockResumedSessionData, + }); unmount = result.unmount; }); await act(async () => { @@ -644,15 +579,13 @@ describe('AppContainer State Management', () => { it('renders without resumed session data', async () => { let unmount: () => void; await act(async () => { - const result = render( - , - ); + const result = renderAppContainer({ + config: mockConfig, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + resumedSessionData: undefined, + }); unmount = result.unmount; }); await act(async () => { @@ -681,14 +614,12 @@ describe('AppContainer State Management', () => { } as unknown as Config; expect(() => { - render( - , - ); + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }); }).not.toThrow(); }); }); @@ -718,14 +649,12 @@ describe('AppContainer State Management', () => { } as unknown as Config; expect(() => { - render( - , - ); + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }); }).not.toThrow(); // Verify the recording service structure is correct @@ -758,14 +687,12 @@ describe('AppContainer State Management', () => { getGeminiClient: vi.fn(() => mockGeminiClient), } as unknown as Config; - render( - , - ); + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }); // The actual recording happens through the useHistory hook // which would be triggered by user interactions @@ -822,15 +749,13 @@ describe('AppContainer State Management', () => { }; expect(() => { - render( - , - ); + renderAppContainer({ + config: configWithClient, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + resumedSessionData: resumedData, + }); }).not.toThrow(); // Verify the resume functionality structure is in place @@ -863,15 +788,13 @@ describe('AppContainer State Management', () => { filePath: '/tmp/session.json', }; - render( - , - ); + renderAppContainer({ + config: configWithClient, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + resumedSessionData: resumedData, + }); // Should not call resumeChat when client is not initialized expect(mockResumeChat).not.toHaveBeenCalled(); @@ -907,14 +830,12 @@ describe('AppContainer State Management', () => { getGeminiClient: vi.fn(() => mockGeminiClient), } as unknown as Config; - render( - , - ); + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }); // In the actual app, these stats would be displayed in components // and updated as messages are processed through the recording service @@ -926,14 +847,7 @@ describe('AppContainer State Management', () => { describe('Quota and Fallback Integration', () => { it('passes a null proQuotaRequest to UIStateContext by default', async () => { // The default mock from beforeEach already sets proQuotaRequest to null - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -956,14 +870,7 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -982,14 +889,7 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -1027,14 +927,9 @@ describe('AppContainer State Management', () => { } as unknown as LoadedSettings; // Act: Render the container - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithShowStatusFalse, + }); // Assert: Check that no title-related writes occurred const titleWrites = mockStdout.write.mock.calls.filter((call) => @@ -1059,14 +954,9 @@ describe('AppContainer State Management', () => { } as unknown as LoadedSettings; // Act: Render the container - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithHideTitleTrue, + }); // Assert: Check that no title-related writes occurred const titleWrites = mockStdout.write.mock.calls.filter((call) => @@ -1102,14 +992,9 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Assert: Check that title was updated with thought subject const titleWrites = mockStdout.write.mock.calls.filter((call) => @@ -1147,14 +1032,9 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Assert: Check that title was updated with default Idle text const titleWrites = mockStdout.write.mock.calls.filter((call) => @@ -1193,14 +1073,9 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Assert: Check that title was updated with confirmation text const titleWrites = mockStdout.write.mock.calls.filter((call) => @@ -1239,14 +1114,9 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Assert: Check that title is padded to exactly 80 characters const titleWrites = mockStdout.write.mock.calls.filter((call) => @@ -1289,14 +1159,9 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Assert: Check that the correct ANSI escape sequence is used const titleWrites = mockStdout.write.mock.calls.filter((call) => @@ -1336,14 +1201,9 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Assert: Check that title was updated with CLI_TITLE value const titleWrites = mockStdout.write.mock.calls.filter((call) => @@ -1367,14 +1227,7 @@ describe('AppContainer State Management', () => { }); it('should set and clear the queue error message after a timeout', async () => { - const { rerender, unmount } = render( - , - ); + const { rerender, unmount } = renderAppContainer(); await act(async () => { vi.advanceTimersByTime(0); }); @@ -1384,40 +1237,19 @@ describe('AppContainer State Management', () => { act(() => { capturedUIActions.setQueueErrorMessage('Test error'); }); - rerender( - , - ); + rerender(getAppContainer()); expect(capturedUIState.queueErrorMessage).toBe('Test error'); act(() => { vi.advanceTimersByTime(3000); }); - rerender( - , - ); + rerender(getAppContainer()); expect(capturedUIState.queueErrorMessage).toBeNull(); unmount(); }); it('should reset the timer if a new error message is set', async () => { - const { rerender, unmount } = render( - , - ); + const { rerender, unmount } = renderAppContainer(); await act(async () => { vi.advanceTimersByTime(0); }); @@ -1425,14 +1257,7 @@ describe('AppContainer State Management', () => { act(() => { capturedUIActions.setQueueErrorMessage('First error'); }); - rerender( - , - ); + rerender(getAppContainer()); expect(capturedUIState.queueErrorMessage).toBe('First error'); act(() => { @@ -1442,41 +1267,20 @@ describe('AppContainer State Management', () => { act(() => { capturedUIActions.setQueueErrorMessage('Second error'); }); - rerender( - , - ); + rerender(getAppContainer()); expect(capturedUIState.queueErrorMessage).toBe('Second error'); act(() => { vi.advanceTimersByTime(2000); }); - rerender( - , - ); + rerender(getAppContainer()); expect(capturedUIState.queueErrorMessage).toBe('Second error'); // 5. Advance time past the 3 second timeout from the second message act(() => { vi.advanceTimersByTime(1000); }); - rerender( - , - ); + rerender(getAppContainer()); expect(capturedUIState.queueErrorMessage).toBeNull(); unmount(); }); @@ -1502,14 +1306,7 @@ describe('AppContainer State Management', () => { activePtyId: 'some-id', }); - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -1534,27 +1331,12 @@ describe('AppContainer State Management', () => { // Helper function to reduce boilerplate in tests const setupKeypressTest = async () => { - const renderResult = render( - , - ); + const renderResult = renderAppContainer(); await act(async () => { vi.advanceTimersByTime(0); }); - rerender = () => - renderResult.rerender( - , - ); + rerender = () => renderResult.rerender(getAppContainer()); unmount = renderResult.unmount; }; @@ -1719,27 +1501,13 @@ describe('AppContainer State Management', () => { }, } as unknown as LoadedSettings; - const renderResult = render( - , - ); + const renderResult = renderAppContainer({ settings: testSettings }); await act(async () => { vi.advanceTimersByTime(0); }); rerender = () => - renderResult.rerender( - , - ); + renderResult.rerender(getAppContainer({ settings: testSettings })); unmount = renderResult.unmount; }; @@ -1879,14 +1647,7 @@ describe('AppContainer State Management', () => { closeModelDialog: vi.fn(), }); - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -1904,14 +1665,7 @@ describe('AppContainer State Management', () => { closeModelDialog: mockCloseModelDialog, }); - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -1927,14 +1681,7 @@ describe('AppContainer State Management', () => { describe('CoreEvents Integration', () => { it('subscribes to UserFeedback and drains backlog on mount', async () => { - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -1948,14 +1695,7 @@ describe('AppContainer State Management', () => { }); it('unsubscribes from UserFeedback on unmount', async () => { - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -1969,14 +1709,7 @@ describe('AppContainer State Management', () => { }); it('adds history item when UserFeedback event is received', async () => { - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -2010,16 +1743,7 @@ describe('AppContainer State Management', () => { // Arrange: Mock initial model vi.spyOn(mockConfig, 'getModel').mockReturnValue('initial-model'); - const { unmount } = render( - , - ); - - // Verify initial model + const { unmount } = renderAppContainer(); await act(async () => { await vi.waitFor(() => { expect(capturedUIState?.currentModel).toBe('initial-model'); @@ -2062,14 +1786,7 @@ describe('AppContainer State Management', () => { }); // The main assertion is that the render does not throw. - const { unmount } = render( - , - ); + const { unmount } = renderAppContainer(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 6271141fbc..0ab3cd8c9b 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -78,11 +78,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useVim } from './hooks/vim.js'; -import { - type LoadableSettingScope, - type LoadedSettings, - SettingScope, -} from '../config/settings.js'; +import { type LoadableSettingScope, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; import { useFocus } from './hooks/useFocus.js'; import { useBracketedPaste } from './hooks/useBracketedPaste.js'; @@ -110,6 +106,8 @@ import { useSessionResume } from './hooks/useSessionResume.js'; import { type ExtensionManager } from '../config/extension-manager.js'; import { requestConsentInteractive } from '../config/extensions/consent.js'; import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js'; +import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; +import { useSettings } from './contexts/SettingsContext.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; @@ -127,7 +125,6 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { interface AppContainerProps { config: Config; - settings: LoadedSettings; startupWarnings?: string[]; version: string; initializationResult: InitializationResult; @@ -147,9 +144,11 @@ const SHELL_WIDTH_FRACTION = 0.89; const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { - const { settings, config, initializationResult, resumedSessionData } = props; + const { config, initializationResult, resumedSessionData } = props; const historyManager = useHistory(); useMemoryMonitor(historyManager); + const settings = useSettings(); + const isAlternateBuffer = useAlternateBuffer(); const [corgiMode, setCorgiMode] = useState(false); const [debugMessage, setDebugMessage] = useState(''); const [quittingMessages, setQuittingMessages] = useState< @@ -359,11 +358,11 @@ export const AppContainer = (props: AppContainerProps) => { }, [historyManager.history, logger]); const refreshStatic = useCallback(() => { - if (settings.merged.ui?.useAlternateBuffer === false) { + if (!isAlternateBuffer) { stdout.write(ansiEscapes.clearTerminal); } setHistoryRemountKey((prev) => prev + 1); - }, [setHistoryRemountKey, stdout, settings]); + }, [setHistoryRemountKey, stdout, isAlternateBuffer]); const { isThemeDialogOpen, @@ -1036,10 +1035,7 @@ Logging in with Google... Please restart Gemini CLI to continue. debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); } - if ( - settings.merged.ui?.useAlternateBuffer && - keyMatchers[Command.TOGGLE_COPY_MODE](key) - ) { + if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) { setCopyModeEnabled(true); disableMouseEvents(); return; @@ -1111,7 +1107,7 @@ Logging in with Google... Please restart Gemini CLI to continue. refreshStatic, setCopyModeEnabled, copyModeEnabled, - settings.merged.ui?.useAlternateBuffer, + isAlternateBuffer, ], ); diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx new file mode 100644 index 0000000000..805fca19a3 --- /dev/null +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { AlternateBufferQuittingDisplay } from './AlternateBufferQuittingDisplay.js'; +import { ToolCallStatus } from '../types.js'; +import type { HistoryItem, HistoryItemWithoutId } from '../types.js'; +import { Text } from 'ink'; +import { renderWithProviders } from '../../test-utils/render.js'; +import type { Config } from '@google/gemini-cli-core'; +import type { ToolMessageProps } from './messages/ToolMessage.js'; + +vi.mock('../contexts/AppContext.js', () => ({ + useAppContext: () => ({ + version: '0.10.0', + }), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getMCPServerStatus: vi.fn(), + }; +}); + +vi.mock('../GeminiRespondingSpinner.js', () => ({ + GeminiRespondingSpinner: () => Spinner, +})); + +vi.mock('./messages/ToolMessage.js', () => ({ + ToolMessage: (props: ToolMessageProps) => ( + + ToolMessage: {props.name} - {props.status} + + ), +})); + +const mockHistory: HistoryItem[] = [ + { + id: 1, + type: 'tool_group', + tools: [ + { + callId: 'call1', + name: 'tool1', + description: 'Description for tool 1', + status: ToolCallStatus.Success, + resultDisplay: undefined, + confirmationDetails: undefined, + }, + ], + }, + { + id: 2, + type: 'tool_group', + tools: [ + { + callId: 'call2', + name: 'tool2', + description: 'Description for tool 2', + status: ToolCallStatus.Success, + resultDisplay: undefined, + confirmationDetails: undefined, + }, + ], + }, +]; + +const mockPendingHistoryItems: HistoryItemWithoutId[] = [ + { + type: 'tool_group', + tools: [ + { + callId: 'call3', + name: 'tool3', + description: 'Description for tool 3', + status: ToolCallStatus.Pending, + resultDisplay: undefined, + confirmationDetails: undefined, + }, + ], + }, +]; + +const mockConfig = { + getScreenReader: () => false, + getEnableInteractiveShell: () => false, + getModel: () => 'gemini-pro', + getTargetDir: () => '/tmp', + getDebugMode: () => false, + getGeminiMdFileCount: () => 0, +} as unknown as Config; + +describe('AlternateBufferQuittingDisplay', () => { + it('renders with active and pending tool messages', () => { + const { lastFrame } = renderWithProviders( + , + { + uiState: { + history: mockHistory, + pendingHistoryItems: mockPendingHistoryItems, + terminalWidth: 80, + mainAreaWidth: 80, + slashCommands: [], + activePtyId: undefined, + embeddedShellFocused: false, + renderMarkdown: false, + }, + config: mockConfig, + }, + ); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx new file mode 100644 index 0000000000..0defa735e4 --- /dev/null +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box } from 'ink'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { AppHeader } from './AppHeader.js'; +import { HistoryItemDisplay } from './HistoryItemDisplay.js'; +import { QuittingDisplay } from './QuittingDisplay.js'; +import { useAppContext } from '../contexts/AppContext.js'; +import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; + +export const AlternateBufferQuittingDisplay = () => { + const { version } = useAppContext(); + const uiState = useUIState(); + + // We render the entire chat history and header here to ensure that the + // conversation history is visible to the user after the app quits and the + // user exits alternate buffer mode. + // Our version of Ink is clever and will render a final frame outside of + // the alternate buffer on app exit. + return ( + + + {uiState.history.map((h) => ( + + ))} + {uiState.pendingHistoryItems.map((item, i) => ( + + ))} + + + ); +}; diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index c9d9f5719f..6962ab2160 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { useState } from 'react'; import { Box, Text, useIsScreenReaderEnabled } from 'ink'; import { LoadingIndicator } from './LoadingIndicator.js'; import { ContextSummaryDisplay } from './ContextSummaryDisplay.js'; @@ -23,6 +24,7 @@ import { useUIActions } from '../contexts/UIActionsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { ApprovalMode } from '@google/gemini-cli-core'; import { StreamingState } from '../types.js'; import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; @@ -38,11 +40,21 @@ export const Composer = () => { const terminalWidth = process.stdout.columns; const isNarrow = isNarrowWidth(terminalWidth); const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5)); + const [suggestionsVisible, setSuggestionsVisible] = useState(false); + const isAlternateBuffer = useAlternateBuffer(); const { contextFileNames, showAutoAcceptIndicator } = uiState; + const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; + const hideContextSummary = + suggestionsVisible && suggestionsPosition === 'above'; return ( - + {!uiState.embeddedShellFocused && ( { ) : uiState.queueErrorMessage ? ( {uiState.queueErrorMessage} ) : ( - !settings.merged.ui?.hideContextSummary && ( + !settings.merged.ui?.hideContextSummary && + !hideContextSummary && ( { uiState.constrainHeight ? debugConsoleMaxHeight : undefined } width={uiState.mainAreaWidth} - hasFocus={true} + hasFocus={uiState.showErrorDetails} /> @@ -161,6 +174,8 @@ export const Composer = () => { } setQueueErrorMessage={uiActions.setQueueErrorMessage} streamingState={uiState.streamingState} + suggestionsPosition={suggestionsPosition} + onSuggestionsVisibilityChange={setSuggestionsVisible} /> )} diff --git a/packages/cli/src/ui/components/CopyModeWarning.tsx b/packages/cli/src/ui/components/CopyModeWarning.tsx new file mode 100644 index 0000000000..8d5423bb89 --- /dev/null +++ b/packages/cli/src/ui/components/CopyModeWarning.tsx @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { theme } from '../semantic-colors.js'; + +export const CopyModeWarning: React.FC = () => { + const { copyModeEnabled } = useUIState(); + + if (!copyModeEnabled) { + return null; + } + + return ( + + + In Copy Mode. Press any key to exit. + + + ); +}; diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx index 6208164a14..a8c332ac35 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx @@ -26,7 +26,7 @@ export const DetailedMessagesDisplay: React.FC< > = ({ messages, maxHeight, width, hasFocus }) => { const scrollableListRef = useRef>(null); - const borderAndPadding = 4; + const borderAndPadding = 3; const estimatedItemHeight = useCallback( (index: number) => { diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index f603e9616a..8488a78dfb 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -55,17 +55,21 @@ describe('', () => { expect(lastFrame()).toContain('/theme'); }); - it('renders InfoMessage for "info" type with multi-line text', () => { - const item: HistoryItem = { - ...baseItem, - type: MessageType.INFO, - text: '⚡ Line 1\n⚡ Line 2\n⚡ Line 3', - }; - const { lastFrame } = renderWithProviders( - , - ); - expect(lastFrame()).toMatchSnapshot(); - }); + it.each([true, false])( + 'renders InfoMessage for "info" type with multi-line text (alternateBuffer=%s)', + (useAlternateBuffer) => { + const item: HistoryItem = { + ...baseItem, + type: MessageType.INFO, + text: '⚡ Line 1\n⚡ Line 2\n⚡ Line 3', + }; + const { lastFrame } = renderWithProviders( + , + { useAlternateBuffer }, + ); + expect(lastFrame()).toMatchSnapshot(); + }, + ); it('renders StatsDisplay for "stats" type', () => { const item: HistoryItem = { @@ -203,83 +207,92 @@ describe('', () => { ); }); - const longCode = - '# Example code block:\n' + - '```python\n' + - Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`).join('\n') + - '\n```'; + describe.each([true, false])( + 'gemini items (alternateBuffer=%s)', + (useAlternateBuffer) => { + const longCode = + '# Example code block:\n' + + '```python\n' + + Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`).join('\n') + + '\n```'; - it('should render a truncated gemini item', () => { - const item: HistoryItem = { - id: 1, - type: 'gemini', - text: longCode, - }; - const { lastFrame } = renderWithProviders( - , - ); + it('should render a truncated gemini item', () => { + const item: HistoryItem = { + id: 1, + type: 'gemini', + text: longCode, + }; + const { lastFrame } = renderWithProviders( + , + { useAlternateBuffer }, + ); - expect(lastFrame()).toMatchSnapshot(); - }); + expect(lastFrame()).toMatchSnapshot(); + }); - it('should render a full gemini item when using availableTerminalHeightGemini', () => { - const item: HistoryItem = { - id: 1, - type: 'gemini', - text: longCode, - }; - const { lastFrame } = renderWithProviders( - , - ); + it('should render a full gemini item when using availableTerminalHeightGemini', () => { + const item: HistoryItem = { + id: 1, + type: 'gemini', + text: longCode, + }; + const { lastFrame } = renderWithProviders( + , + { useAlternateBuffer }, + ); - expect(lastFrame()).toMatchSnapshot(); - }); + expect(lastFrame()).toMatchSnapshot(); + }); - it('should render a truncated gemini_content item', () => { - const item: HistoryItem = { - id: 1, - type: 'gemini_content', - text: longCode, - }; - const { lastFrame } = renderWithProviders( - , - ); + it('should render a truncated gemini_content item', () => { + const item: HistoryItem = { + id: 1, + type: 'gemini_content', + text: longCode, + }; + const { lastFrame } = renderWithProviders( + , + { useAlternateBuffer }, + ); - expect(lastFrame()).toMatchSnapshot(); - }); + expect(lastFrame()).toMatchSnapshot(); + }); - it('should render a full gemini_content item when using availableTerminalHeightGemini', () => { - const item: HistoryItem = { - id: 1, - type: 'gemini_content', - text: longCode, - }; - const { lastFrame } = renderWithProviders( - , - ); + it('should render a full gemini_content item when using availableTerminalHeightGemini', () => { + const item: HistoryItem = { + id: 1, + type: 'gemini_content', + text: longCode, + }; + const { lastFrame } = renderWithProviders( + , + { useAlternateBuffer }, + ); - expect(lastFrame()).toMatchSnapshot(); - }); + expect(lastFrame()).toMatchSnapshot(); + }); + }, + ); }); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index ad51f91a8a..4cc912c3b8 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -57,10 +57,10 @@ export const HistoryItemDisplay: React.FC = ({ const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); return ( - + {/* Render standard message types */} {itemForDisplay.type === 'user' && ( - + )} {itemForDisplay.type === 'user_shell' && ( diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index e1f359c302..06bbf4da74 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -72,11 +72,13 @@ export interface InputPromptProps { setShellModeActive: (value: boolean) => void; approvalMode: ApprovalMode; onEscapePromptChange?: (showPrompt: boolean) => void; + onSuggestionsVisibilityChange?: (visible: boolean) => void; vimHandleInput?: (key: Key) => boolean; isEmbeddedShellFocused?: boolean; setQueueErrorMessage: (message: string | null) => void; streamingState: StreamingState; popAllMessages?: (onPop: (messages: string | undefined) => void) => void; + suggestionsPosition?: 'above' | 'below'; } // The input content, input container, and input suggestions list may have different widths @@ -111,11 +113,13 @@ export const InputPrompt: React.FC = ({ setShellModeActive, approvalMode, onEscapePromptChange, + onSuggestionsVisibilityChange, vimHandleInput, isEmbeddedShellFocused, setQueueErrorMessage, streamingState, popAllMessages, + suggestionsPosition = 'below', }) => { const kittyProtocol = useKittyKeyboardProtocol(); const isShellFocused = useShellFocusState(); @@ -943,6 +947,12 @@ export const InputPrompt: React.FC = ({ const activeCompletion = getActiveCompletion(); const shouldShowSuggestions = activeCompletion.showSuggestions; + useEffect(() => { + if (onSuggestionsVisibilityChange) { + onSuggestionsVisibilityChange(shouldShowSuggestions); + } + }, [shouldShowSuggestions, onSuggestionsVisibilityChange]); + const showAutoAcceptStyling = !shellModeActive && approvalMode === ApprovalMode.AUTO_EDIT; const showYoloStyling = @@ -961,8 +971,30 @@ export const InputPrompt: React.FC = ({ statusText = 'Accepting edits'; } + const suggestionsNode = shouldShowSuggestions ? ( + + + + ) : null; + return ( <> + {suggestionsPosition === 'above' && suggestionsNode} = ({ )} - {shouldShowSuggestions && ( - - - - )} + {suggestionsPosition === 'below' && suggestionsNode} ); }; diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index f3424112ce..a60f782d8f 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -11,19 +11,23 @@ import { OverflowProvider } from '../contexts/OverflowContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; -import { useSettings } from '../contexts/SettingsContext.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js'; +import { ScrollableList } from './shared/ScrollableList.js'; +import { useMemo, memo, useCallback } from 'react'; +import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; + +const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay); +const MemoizedAppHeader = memo(AppHeader); // Limit Gemini messages to a very high number of lines to mitigate performance // issues in the worst case if we somehow get an enormous response from Gemini. // This threshold is arbitrary but should be high enough to never impact normal // usage. -const MAX_GEMINI_MESSAGE_LINES = 65536; - export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); - const settings = useSettings(); - const useAlternateBuffer = settings.merged.ui?.useAlternateBuffer ?? false; + const isAlternateBuffer = useAlternateBuffer(); const { pendingHistoryItems, @@ -32,65 +36,116 @@ export const MainContent = () => { availableTerminalHeight, } = uiState; - const historyItems = [ - , - ...uiState.history.map((h) => ( - - )), - ]; + const historyItems = uiState.history.map((h) => ( + + )); - const pendingItems = ( - - - {pendingHistoryItems.map((item, i) => ( - - ))} - - - + const pendingItems = useMemo( + () => ( + + + {pendingHistoryItems.map((item, i) => ( + + ))} + + + + ), + [ + pendingHistoryItems, + uiState.constrainHeight, + availableTerminalHeight, + mainAreaWidth, + uiState.isEditorDialogOpen, + uiState.activePtyId, + uiState.embeddedShellFocused, + ], ); - if (useAlternateBuffer) { - // Placeholder alternate buffer implementation using a scrollable box that - // is always scrolled to the bottom. In follow up PRs we will switch this - // to a proper alternate buffer implementation. + const virtualizedData = useMemo( + () => [ + { type: 'header' as const }, + ...uiState.history.map((item) => ({ type: 'history' as const, item })), + { type: 'pending' as const }, + ], + [uiState.history], + ); + + const renderItem = useCallback( + ({ item }: { item: (typeof virtualizedData)[number] }) => { + if (item.type === 'header') { + return ; + } else if (item.type === 'history') { + return ( + + ); + } else { + return pendingItems; + } + }, + [ + version, + mainAreaWidth, + staticAreaMaxItemHeight, + uiState.slashCommands, + pendingItems, + ], + ); + + if (isAlternateBuffer) { return ( - - - {historyItems} - {pendingItems} - - + 100} + keyExtractor={(item, _index) => { + if (item.type === 'header') return 'header'; + if (item.type === 'history') return item.item.id.toString(); + return 'pending'; + }} + initialScrollIndex={SCROLL_TO_ITEM_END} + initialScrollOffsetInIndex={SCROLL_TO_ITEM_END} + /> ); } return ( <> - + , + ...historyItems, + ]} + > {(item) => item} {pendingItems} diff --git a/packages/cli/src/ui/components/StickyHeader.test.tsx b/packages/cli/src/ui/components/StickyHeader.test.tsx new file mode 100644 index 0000000000..975d5dd146 --- /dev/null +++ b/packages/cli/src/ui/components/StickyHeader.test.tsx @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Text } from 'ink'; +import { describe, it, expect } from 'vitest'; +import { StickyHeader } from './StickyHeader.js'; +import { renderWithProviders } from '../../test-utils/render.js'; + +describe('StickyHeader', () => { + it('renders children', () => { + const { lastFrame } = renderWithProviders( + + Hello Sticky + , + ); + expect(lastFrame()).toContain('Hello Sticky'); + }); +}); diff --git a/packages/cli/src/ui/components/StickyHeader.tsx b/packages/cli/src/ui/components/StickyHeader.tsx new file mode 100644 index 0000000000..e438f12a2b --- /dev/null +++ b/packages/cli/src/ui/components/StickyHeader.tsx @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box } from 'ink'; +import { theme } from '../semantic-colors.js'; + +export interface StickyHeaderProps { + children: React.ReactNode; + width: number; +} + +export const StickyHeader: React.FC = ({ + children, + width, +}) => ( + + {children} + + } + > + + {children} + + +); diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 611c9f9a71..f36d55a652 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -19,6 +19,7 @@ import type { import { SettingScope } from '../../config/settings.js'; import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { ScopeSelector } from './shared/ScopeSelector.js'; interface ThemeDialogProps { @@ -44,6 +45,7 @@ export function ThemeDialog({ availableTerminalHeight, terminalWidth, }: ThemeDialogProps): React.JSX.Element { + const isAlternateBuffer = useAlternateBuffer(); const [selectedScope, setSelectedScope] = useState( SettingScope.User, ); @@ -243,17 +245,19 @@ export function ThemeDialog({ paddingRight={1} flexDirection="column" > - {colorizeCode( - `# function + {colorizeCode({ + code: `# function def fibonacci(n): a, b = 0, 1 for _ in range(n): a, b = b, a + b return a`, - 'python', - codeBlockHeight, - colorizeCodeWidth, - )} + language: 'python', + availableHeight: + isAlternateBuffer === false ? codeBlockHeight : undefined, + maxWidth: colorizeCodeWidth, + settings, + })} diff --git a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap new file mode 100644 index 0000000000..0e7ef17e3b --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap @@ -0,0 +1,28 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages 1`] = ` +" + ███ █████████ +░░░███ ███░░░░░███ + ░░░███ ███ ░░░ + ░░░███░███ + ███░ ░███ █████ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ + +Tips for getting started: +1. Ask questions, edit files, or run commands. +2. Be specific for the best results. +3. Create GEMINI.md files to customize your interactions with Gemini. +4. /help for more information. +╭────────────────────────────────────────────────────────────────────────────╮ +│ToolMessage: tool1 - Success │ +╰────────────────────────────────────────────────────────────────────────────╯ +╭────────────────────────────────────────────────────────────────────────────╮ +│ToolMessage: tool2 - Success │ +╰────────────────────────────────────────────────────────────────────────────╯ +╭────────────────────────────────────────────────────────────────────────────╮ +│ToolMessage: tool3 - Pending │ +╰────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap index b9c4b5e8bb..74b54dbc79 100644 --- a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap @@ -1,144 +1,367 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` > renders InfoMessage for "info" type with multi-line text 1`] = ` +exports[` > gemini items (alternateBuffer=false) > should render a full gemini item when using availableTerminalHeightGemini 1`] = ` +"✦ Example code block: + 1 Line 1 + 2 Line 2 + 3 Line 3 + 4 Line 4 + 5 Line 5 + 6 Line 6 + 7 Line 7 + 8 Line 8 + 9 Line 9 + 10 Line 10 + 11 Line 11 + 12 Line 12 + 13 Line 13 + 14 Line 14 + 15 Line 15 + 16 Line 16 + 17 Line 17 + 18 Line 18 + 19 Line 19 + 20 Line 20 + 21 Line 21 + 22 Line 22 + 23 Line 23 + 24 Line 24 + 25 Line 25 + 26 Line 26 + 27 Line 27 + 28 Line 28 + 29 Line 29 + 30 Line 30 + 31 Line 31 + 32 Line 32 + 33 Line 33 + 34 Line 34 + 35 Line 35 + 36 Line 36 + 37 Line 37 + 38 Line 38 + 39 Line 39 + 40 Line 40 + 41 Line 41 + 42 Line 42 + 43 Line 43 + 44 Line 44 + 45 Line 45 + 46 Line 46 + 47 Line 47 + 48 Line 48 + 49 Line 49 + 50 Line 50" +`; + +exports[` > gemini items (alternateBuffer=false) > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = ` +" Example code block: + 1 Line 1 + 2 Line 2 + 3 Line 3 + 4 Line 4 + 5 Line 5 + 6 Line 6 + 7 Line 7 + 8 Line 8 + 9 Line 9 + 10 Line 10 + 11 Line 11 + 12 Line 12 + 13 Line 13 + 14 Line 14 + 15 Line 15 + 16 Line 16 + 17 Line 17 + 18 Line 18 + 19 Line 19 + 20 Line 20 + 21 Line 21 + 22 Line 22 + 23 Line 23 + 24 Line 24 + 25 Line 25 + 26 Line 26 + 27 Line 27 + 28 Line 28 + 29 Line 29 + 30 Line 30 + 31 Line 31 + 32 Line 32 + 33 Line 33 + 34 Line 34 + 35 Line 35 + 36 Line 36 + 37 Line 37 + 38 Line 38 + 39 Line 39 + 40 Line 40 + 41 Line 41 + 42 Line 42 + 43 Line 43 + 44 Line 44 + 45 Line 45 + 46 Line 46 + 47 Line 47 + 48 Line 48 + 49 Line 49 + 50 Line 50" +`; + +exports[` > gemini items (alternateBuffer=false) > should render a truncated gemini item 1`] = ` +"✦ Example code block: + ... first 41 lines hidden ... + 42 Line 42 + 43 Line 43 + 44 Line 44 + 45 Line 45 + 46 Line 46 + 47 Line 47 + 48 Line 48 + 49 Line 49 + 50 Line 50" +`; + +exports[` > gemini items (alternateBuffer=false) > should render a truncated gemini_content item 1`] = ` +" Example code block: + ... first 41 lines hidden ... + 42 Line 42 + 43 Line 43 + 44 Line 44 + 45 Line 45 + 46 Line 46 + 47 Line 47 + 48 Line 48 + 49 Line 49 + 50 Line 50" +`; + +exports[` > gemini items (alternateBuffer=true) > should render a full gemini item when using availableTerminalHeightGemini 1`] = ` +"✦ Example code block: + 1 Line 1 + 2 Line 2 + 3 Line 3 + 4 Line 4 + 5 Line 5 + 6 Line 6 + 7 Line 7 + 8 Line 8 + 9 Line 9 + 10 Line 10 + 11 Line 11 + 12 Line 12 + 13 Line 13 + 14 Line 14 + 15 Line 15 + 16 Line 16 + 17 Line 17 + 18 Line 18 + 19 Line 19 + 20 Line 20 + 21 Line 21 + 22 Line 22 + 23 Line 23 + 24 Line 24 + 25 Line 25 + 26 Line 26 + 27 Line 27 + 28 Line 28 + 29 Line 29 + 30 Line 30 + 31 Line 31 + 32 Line 32 + 33 Line 33 + 34 Line 34 + 35 Line 35 + 36 Line 36 + 37 Line 37 + 38 Line 38 + 39 Line 39 + 40 Line 40 + 41 Line 41 + 42 Line 42 + 43 Line 43 + 44 Line 44 + 45 Line 45 + 46 Line 46 + 47 Line 47 + 48 Line 48 + 49 Line 49 + 50 Line 50" +`; + +exports[` > gemini items (alternateBuffer=true) > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = ` +" Example code block: + 1 Line 1 + 2 Line 2 + 3 Line 3 + 4 Line 4 + 5 Line 5 + 6 Line 6 + 7 Line 7 + 8 Line 8 + 9 Line 9 + 10 Line 10 + 11 Line 11 + 12 Line 12 + 13 Line 13 + 14 Line 14 + 15 Line 15 + 16 Line 16 + 17 Line 17 + 18 Line 18 + 19 Line 19 + 20 Line 20 + 21 Line 21 + 22 Line 22 + 23 Line 23 + 24 Line 24 + 25 Line 25 + 26 Line 26 + 27 Line 27 + 28 Line 28 + 29 Line 29 + 30 Line 30 + 31 Line 31 + 32 Line 32 + 33 Line 33 + 34 Line 34 + 35 Line 35 + 36 Line 36 + 37 Line 37 + 38 Line 38 + 39 Line 39 + 40 Line 40 + 41 Line 41 + 42 Line 42 + 43 Line 43 + 44 Line 44 + 45 Line 45 + 46 Line 46 + 47 Line 47 + 48 Line 48 + 49 Line 49 + 50 Line 50" +`; + +exports[` > gemini items (alternateBuffer=true) > should render a truncated gemini item 1`] = ` +"✦ Example code block: + 1 Line 1 + 2 Line 2 + 3 Line 3 + 4 Line 4 + 5 Line 5 + 6 Line 6 + 7 Line 7 + 8 Line 8 + 9 Line 9 + 10 Line 10 + 11 Line 11 + 12 Line 12 + 13 Line 13 + 14 Line 14 + 15 Line 15 + 16 Line 16 + 17 Line 17 + 18 Line 18 + 19 Line 19 + 20 Line 20 + 21 Line 21 + 22 Line 22 + 23 Line 23 + 24 Line 24 + 25 Line 25 + 26 Line 26 + 27 Line 27 + 28 Line 28 + 29 Line 29 + 30 Line 30 + 31 Line 31 + 32 Line 32 + 33 Line 33 + 34 Line 34 + 35 Line 35 + 36 Line 36 + 37 Line 37 + 38 Line 38 + 39 Line 39 + 40 Line 40 + 41 Line 41 + 42 Line 42 + 43 Line 43 + 44 Line 44 + 45 Line 45 + 46 Line 46 + 47 Line 47 + 48 Line 48 + 49 Line 49 + 50 Line 50" +`; + +exports[` > gemini items (alternateBuffer=true) > should render a truncated gemini_content item 1`] = ` +" Example code block: + 1 Line 1 + 2 Line 2 + 3 Line 3 + 4 Line 4 + 5 Line 5 + 6 Line 6 + 7 Line 7 + 8 Line 8 + 9 Line 9 + 10 Line 10 + 11 Line 11 + 12 Line 12 + 13 Line 13 + 14 Line 14 + 15 Line 15 + 16 Line 16 + 17 Line 17 + 18 Line 18 + 19 Line 19 + 20 Line 20 + 21 Line 21 + 22 Line 22 + 23 Line 23 + 24 Line 24 + 25 Line 25 + 26 Line 26 + 27 Line 27 + 28 Line 28 + 29 Line 29 + 30 Line 30 + 31 Line 31 + 32 Line 32 + 33 Line 33 + 34 Line 34 + 35 Line 35 + 36 Line 36 + 37 Line 37 + 38 Line 38 + 39 Line 39 + 40 Line 40 + 41 Line 41 + 42 Line 42 + 43 Line 43 + 44 Line 44 + 45 Line 45 + 46 Line 46 + 47 Line 47 + 48 Line 48 + 49 Line 49 + 50 Line 50" +`; + +exports[` > renders InfoMessage for "info" type with multi-line text (alternateBuffer=false) 1`] = ` " ℹ ⚡ Line 1 ⚡ Line 2 ⚡ Line 3" `; -exports[` > should render a full gemini item when using availableTerminalHeightGemini 1`] = ` -"✦ Example code block: - 1 Line 1 - 2 Line 2 - 3 Line 3 - 4 Line 4 - 5 Line 5 - 6 Line 6 - 7 Line 7 - 8 Line 8 - 9 Line 9 - 10 Line 10 - 11 Line 11 - 12 Line 12 - 13 Line 13 - 14 Line 14 - 15 Line 15 - 16 Line 16 - 17 Line 17 - 18 Line 18 - 19 Line 19 - 20 Line 20 - 21 Line 21 - 22 Line 22 - 23 Line 23 - 24 Line 24 - 25 Line 25 - 26 Line 26 - 27 Line 27 - 28 Line 28 - 29 Line 29 - 30 Line 30 - 31 Line 31 - 32 Line 32 - 33 Line 33 - 34 Line 34 - 35 Line 35 - 36 Line 36 - 37 Line 37 - 38 Line 38 - 39 Line 39 - 40 Line 40 - 41 Line 41 - 42 Line 42 - 43 Line 43 - 44 Line 44 - 45 Line 45 - 46 Line 46 - 47 Line 47 - 48 Line 48 - 49 Line 49 - 50 Line 50" -`; - -exports[` > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = ` -" Example code block: - 1 Line 1 - 2 Line 2 - 3 Line 3 - 4 Line 4 - 5 Line 5 - 6 Line 6 - 7 Line 7 - 8 Line 8 - 9 Line 9 - 10 Line 10 - 11 Line 11 - 12 Line 12 - 13 Line 13 - 14 Line 14 - 15 Line 15 - 16 Line 16 - 17 Line 17 - 18 Line 18 - 19 Line 19 - 20 Line 20 - 21 Line 21 - 22 Line 22 - 23 Line 23 - 24 Line 24 - 25 Line 25 - 26 Line 26 - 27 Line 27 - 28 Line 28 - 29 Line 29 - 30 Line 30 - 31 Line 31 - 32 Line 32 - 33 Line 33 - 34 Line 34 - 35 Line 35 - 36 Line 36 - 37 Line 37 - 38 Line 38 - 39 Line 39 - 40 Line 40 - 41 Line 41 - 42 Line 42 - 43 Line 43 - 44 Line 44 - 45 Line 45 - 46 Line 46 - 47 Line 47 - 48 Line 48 - 49 Line 49 - 50 Line 50" -`; - -exports[` > should render a truncated gemini item 1`] = ` -"✦ Example code block: - ... first 41 lines hidden ... - 42 Line 42 - 43 Line 43 - 44 Line 44 - 45 Line 45 - 46 Line 46 - 47 Line 47 - 48 Line 48 - 49 Line 49 - 50 Line 50" -`; - -exports[` > should render a truncated gemini_content item 1`] = ` -" Example code block: - ... first 41 lines hidden ... - 42 Line 42 - 43 Line 43 - 44 Line 44 - 45 Line 45 - 46 Line 46 - 47 Line 47 - 48 Line 48 - 49 Line 49 - 50 Line 50" +exports[` > renders InfoMessage for "info" type with multi-line text (alternateBuffer=true) 1`] = ` +" +ℹ ⚡ Line 1 + ⚡ Line 2 + ⚡ Line 3" `; diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx index 1ed5e36ab3..9b1b93f25a 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx @@ -5,7 +5,7 @@ */ import { OverflowProvider } from '../../contexts/OverflowContext.js'; -import { render } from '../../../test-utils/render.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; import { DiffRenderer } from './DiffRenderer.js'; import * as CodeColorizer from '../../utils/CodeColorizer.js'; import { vi } from 'vitest'; @@ -20,8 +20,11 @@ describe('', () => { const sanitizeOutput = (output: string | undefined, terminalWidth: number) => output?.replace(/GAP_INDICATOR/g, '═'.repeat(terminalWidth)); - it('should call colorizeCode with correct language for new file with known extension', () => { - const newFileDiffContent = ` + describe.each([true, false])( + 'with useAlternateBuffer = %s', + (useAlternateBuffer) => { + it('should call colorizeCode with correct language for new file with known extension', () => { + const newFileDiffContent = ` diff --git a/test.py b/test.py new file mode 100644 index 0000000..e69de29 @@ -30,26 +33,28 @@ index 0000000..e69de29 @@ -0,0 +1 @@ +print("hello world") `; - render( - - - , - ); - expect(mockColorizeCode).toHaveBeenCalledWith( - 'print("hello world")', - 'python', - undefined, - 80, - undefined, - ); - }); + renderWithProviders( + + + , + { useAlternateBuffer }, + ); + expect(mockColorizeCode).toHaveBeenCalledWith({ + code: 'print("hello world")', + language: 'python', + availableHeight: undefined, + maxWidth: 80, + theme: undefined, + settings: expect.anything(), + }); + }); - it('should call colorizeCode with null language for new file with unknown extension', () => { - const newFileDiffContent = ` + it('should call colorizeCode with null language for new file with unknown extension', () => { + const newFileDiffContent = ` diff --git a/test.unknown b/test.unknown new file mode 100644 index 0000000..e69de29 @@ -58,26 +63,28 @@ index 0000000..e69de29 @@ -0,0 +1 @@ +some content `; - render( - - - , - ); - expect(mockColorizeCode).toHaveBeenCalledWith( - 'some content', - null, - undefined, - 80, - undefined, - ); - }); + renderWithProviders( + + + , + { useAlternateBuffer }, + ); + expect(mockColorizeCode).toHaveBeenCalledWith({ + code: 'some content', + language: null, + availableHeight: undefined, + maxWidth: 80, + theme: undefined, + settings: expect.anything(), + }); + }); - it('should call colorizeCode with null language for new file if no filename is provided', () => { - const newFileDiffContent = ` + it('should call colorizeCode with null language for new file if no filename is provided', () => { + const newFileDiffContent = ` diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..e69de29 @@ -86,22 +93,25 @@ index 0000000..e69de29 @@ -0,0 +1 @@ +some text content `; - render( - - - , - ); - expect(mockColorizeCode).toHaveBeenCalledWith( - 'some text content', - null, - undefined, - 80, - undefined, - ); - }); + renderWithProviders( + + + , + { useAlternateBuffer }, + ); + expect(mockColorizeCode).toHaveBeenCalledWith({ + code: 'some text content', + language: null, + availableHeight: undefined, + maxWidth: 80, + theme: undefined, + settings: expect.anything(), + }); + }); + + it('should render diff content for existing file (not calling colorizeCode directly for the whole block)', () => { + const existingFileDiffContent = ` - it('should render diff content for existing file (not calling colorizeCode directly for the whole block)', () => { - const existingFileDiffContent = ` diff --git a/test.txt b/test.txt index 0000001..0000002 100644 --- a/test.txt @@ -110,61 +120,64 @@ index 0000001..0000002 100644 -old line +new line `; - const { lastFrame } = render( - - - , - ); - // colorizeCode is used internally by the line-by-line rendering, not for the whole block - expect(mockColorizeCode).not.toHaveBeenCalledWith( - expect.stringContaining('old line'), - expect.anything(), - ); - expect(mockColorizeCode).not.toHaveBeenCalledWith( - expect.stringContaining('new line'), - expect.anything(), - ); - const output = lastFrame(); - const lines = output!.split('\n'); - expect(lines[0]).toBe('1 - old line'); - expect(lines[1]).toBe('1 + new line'); - }); + const { lastFrame } = renderWithProviders( + + + , + { useAlternateBuffer }, + ); + // colorizeCode is used internally by the line-by-line rendering, not for the whole block + expect(mockColorizeCode).not.toHaveBeenCalledWith( + expect.objectContaining({ + code: expect.stringContaining('old line'), + }), + ); + expect(mockColorizeCode).not.toHaveBeenCalledWith( + expect.objectContaining({ + code: expect.stringContaining('new line'), + }), + ); + expect(lastFrame()).toMatchSnapshot(); + }); - it('should handle diff with only header and no changes', () => { - const noChangeDiff = `diff --git a/file.txt b/file.txt + it('should handle diff with only header and no changes', () => { + const noChangeDiff = `diff --git a/file.txt b/file.txt index 1234567..1234567 100644 --- a/file.txt +++ b/file.txt `; - const { lastFrame } = render( - - - , - ); - expect(lastFrame()).toContain('No changes detected'); - expect(mockColorizeCode).not.toHaveBeenCalled(); - }); + const { lastFrame } = renderWithProviders( + + + , + { useAlternateBuffer }, + ); + expect(lastFrame()).toMatchSnapshot(); + expect(mockColorizeCode).not.toHaveBeenCalled(); + }); - it('should handle empty diff content', () => { - const { lastFrame } = render( - - - , - ); - expect(lastFrame()).toContain('No diff content'); - expect(mockColorizeCode).not.toHaveBeenCalled(); - }); + it('should handle empty diff content', () => { + const { lastFrame } = renderWithProviders( + + + , + { useAlternateBuffer }, + ); + expect(lastFrame()).toMatchSnapshot(); + expect(mockColorizeCode).not.toHaveBeenCalled(); + }); + + it('should render a gap indicator for skipped lines', () => { + const diffWithGap = ` - it('should render a gap indicator for skipped lines', () => { - const diffWithGap = ` diff --git a/file.txt b/file.txt index 123..456 100644 --- a/file.txt @@ -177,26 +190,22 @@ index 123..456 100644 context line 10 context line 11 `; - const { lastFrame } = render( - - - , - ); - const output = lastFrame(); - expect(output).toContain('═'); // Check for the border character used in the gap + const { lastFrame } = renderWithProviders( + + + , + { useAlternateBuffer }, + ); + expect(lastFrame()).toMatchSnapshot(); + }); - // Verify that lines before and after the gap are rendered - expect(output).toContain('context line 1'); - expect(output).toContain('added line'); - expect(output).toContain('context line 10'); - }); + it('should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP)', () => { + const diffWithSmallGap = ` - it('should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP)', () => { - const diffWithSmallGap = ` diff --git a/file.txt b/file.txt index abc..def 100644 --- a/file.txt @@ -214,25 +223,22 @@ index abc..def 100644 context line 14 context line 15 `; - const { lastFrame } = render( - - - , - ); - const output = lastFrame(); - expect(output).not.toContain('═'); // Ensure no separator is rendered + const { lastFrame } = renderWithProviders( + + + , + { useAlternateBuffer }, + ); + expect(lastFrame()).toMatchSnapshot(); + }); - // Verify that lines before and after the gap are rendered - expect(output).toContain('context line 5'); - expect(output).toContain('context line 11'); - }); + describe('should correctly render a diff with multiple hunks and a gap indicator', () => { + const diffWithMultipleHunks = ` - describe('should correctly render a diff with multiple hunks and a gap indicator', () => { - const diffWithMultipleHunks = ` diff --git a/multi.js b/multi.js index 123..789 100644 --- a/multi.js @@ -249,61 +255,42 @@ index 123..789 100644 console.log('end of second hunk'); `; - it.each([ - { - terminalWidth: 80, - height: undefined, - expected: ` 1 console.log('first hunk'); - 2 - const oldVar = 1; - 2 + const newVar = 1; - 3 console.log('end of first hunk'); -════════════════════════════════════════════════════════════════════════════════ -20 console.log('second hunk'); -21 - const anotherOld = 'test'; -21 + const anotherNew = 'test'; -22 console.log('end of second hunk');`, - }, - { - terminalWidth: 80, - height: 6, - expected: `... first 4 lines hidden ... -════════════════════════════════════════════════════════════════════════════════ -20 console.log('second hunk'); -21 - const anotherOld = 'test'; -21 + const anotherNew = 'test'; -22 console.log('end of second hunk');`, - }, - { - terminalWidth: 30, - height: 6, - expected: `... first 10 lines hidden ... - ; -21 + const anotherNew = 'test' - ; -22 console.log('end of - second hunk');`, - }, - ])( - 'with terminalWidth $terminalWidth and height $height', - ({ terminalWidth, height, expected }) => { - const { lastFrame } = render( - - - , + it.each([ + { + terminalWidth: 80, + height: undefined, + }, + { + terminalWidth: 80, + height: 6, + }, + { + terminalWidth: 30, + height: 6, + }, + ])( + 'with terminalWidth $terminalWidth and height $height', + ({ terminalWidth, height }) => { + const { lastFrame } = renderWithProviders( + + + , + { useAlternateBuffer }, + ); + const output = lastFrame(); + expect(sanitizeOutput(output, terminalWidth)).toMatchSnapshot(); + }, ); - const output = lastFrame(); - expect(sanitizeOutput(output, terminalWidth)).toEqual(expected); - }, - ); - }); + }); + + it('should correctly render a diff with a SVN diff format', () => { + const newFileDiff = ` - it('should correctly render a diff with a SVN diff format', () => { - const newFileDiff = ` fileDiff Index: file.txt =================================================================== --- a/file.txt Current @@ -318,26 +305,22 @@ fileDiff Index: file.txt +const anotherNew = 'test'; \\ No newline at end of file `; - const { lastFrame } = render( - - - , - ); - const output = lastFrame(); + const { lastFrame } = renderWithProviders( + + + , + { useAlternateBuffer }, + ); + expect(lastFrame()).toMatchSnapshot(); + }); - expect(output).toEqual(` 1 - const oldVar = 1; - 1 + const newVar = 1; -════════════════════════════════════════════════════════════════════════════════ -20 - const anotherOld = 'test'; -20 + const anotherNew = 'test';`); - }); + it('should correctly render a new file with no file extension correctly', () => { + const newFileDiff = ` - it('should correctly render a new file with no file extension correctly', () => { - const newFileDiff = ` fileDiff Index: Dockerfile =================================================================== --- Dockerfile Current @@ -348,18 +331,18 @@ fileDiff Index: Dockerfile +RUN npm run build \\ No newline at end of file `; - const { lastFrame } = render( - - - , - ); - const output = lastFrame(); - expect(output).toEqual(`1 FROM node:14 -2 RUN npm install -3 RUN npm run build`); - }); + const { lastFrame } = renderWithProviders( + + + , + { useAlternateBuffer }, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + }, + ); }); diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx index d962d683b8..fdf1d26c91 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx @@ -5,12 +5,15 @@ */ import type React from 'react'; +import { useMemo } from 'react'; import { Box, Text, useIsScreenReaderEnabled } from 'ink'; import crypto from 'node:crypto'; import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { theme as semanticTheme } from '../../semantic-colors.js'; import type { Theme } from '../../themes/theme.js'; +import { useSettings } from '../../contexts/SettingsContext.js'; +import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; interface DiffLine { type: 'add' | 'del' | 'context' | 'hunk' | 'other'; @@ -98,75 +101,100 @@ export const DiffRenderer: React.FC = ({ terminalWidth, theme, }) => { + const settings = useSettings(); + const isAlternateBuffer = useAlternateBuffer(); + const screenReaderEnabled = useIsScreenReaderEnabled(); - if (!diffContent || typeof diffContent !== 'string') { - return No diff content.; - } - const parsedLines = parseDiffWithLineNumbers(diffContent); + const parsedLines = useMemo(() => { + if (!diffContent || typeof diffContent !== 'string') { + return []; + } + return parseDiffWithLineNumbers(diffContent); + }, [diffContent]); - if (parsedLines.length === 0) { - return ( - - No changes detected. - + const isNewFile = useMemo(() => { + if (parsedLines.length === 0) return false; + return parsedLines.every( + (line) => + line.type === 'add' || + line.type === 'hunk' || + line.type === 'other' || + line.content.startsWith('diff --git') || + line.content.startsWith('new file mode'), ); - } - if (screenReaderEnabled) { - return ( - - {parsedLines.map((line, index) => ( - - {line.type}: {line.content} - - ))} - - ); - } + }, [parsedLines]); - // Check if the diff represents a new file (only additions and header lines) - const isNewFile = parsedLines.every( - (line) => - line.type === 'add' || - line.type === 'hunk' || - line.type === 'other' || - line.content.startsWith('diff --git') || - line.content.startsWith('new file mode'), - ); + const renderedOutput = useMemo(() => { + if (!diffContent || typeof diffContent !== 'string') { + return No diff content.; + } - let renderedOutput; + if (parsedLines.length === 0) { + return ( + + No changes detected. + + ); + } + if (screenReaderEnabled) { + return ( + + {parsedLines.map((line, index) => ( + + {line.type}: {line.content} + + ))} + + ); + } - if (isNewFile) { - // Extract only the added lines' content - const addedContent = parsedLines - .filter((line) => line.type === 'add') - .map((line) => line.content) - .join('\n'); - // Attempt to infer language from filename, default to plain text if no filename - const fileExtension = filename?.split('.').pop() || null; - const language = fileExtension - ? getLanguageFromExtension(fileExtension) - : null; - renderedOutput = colorizeCode( - addedContent, - language, - availableTerminalHeight, - terminalWidth, - theme, - ); - } else { - renderedOutput = renderDiffContent( - parsedLines, - filename, - tabWidth, - availableTerminalHeight, - terminalWidth, - ); - } + if (isNewFile) { + // Extract only the added lines' content + const addedContent = parsedLines + .filter((line) => line.type === 'add') + .map((line) => line.content) + .join('\n'); + // Attempt to infer language from filename, default to plain text if no filename + const fileExtension = filename?.split('.').pop() || null; + const language = fileExtension + ? getLanguageFromExtension(fileExtension) + : null; + return colorizeCode({ + code: addedContent, + language, + availableHeight: availableTerminalHeight, + maxWidth: terminalWidth, + theme, + settings, + }); + } else { + return renderDiffContent( + parsedLines, + filename, + tabWidth, + availableTerminalHeight, + terminalWidth, + !isAlternateBuffer, + ); + } + }, [ + diffContent, + parsedLines, + screenReaderEnabled, + isNewFile, + filename, + availableTerminalHeight, + terminalWidth, + theme, + settings, + isAlternateBuffer, + tabWidth, + ]); return renderedOutput; }; @@ -177,6 +205,7 @@ const renderDiffContent = ( tabWidth = DEFAULT_TAB_WIDTH, availableTerminalHeight: number | undefined, terminalWidth: number, + useMaxSizedBox: boolean, ) => { // 1. Normalize whitespace (replace tabs with spaces) *before* further processing const normalizedLines = parsedLines.map((line) => ({ @@ -235,115 +264,151 @@ const renderDiffContent = ( let lastLineNumber: number | null = null; const MAX_CONTEXT_LINES_WITHOUT_GAP = 5; - return ( - - {displayableLines.reduce((acc, line, index) => { - // Determine the relevant line number for gap calculation based on type - let relevantLineNumberForGapCalc: number | null = null; - if (line.type === 'add' || line.type === 'context') { - relevantLineNumberForGapCalc = line.newLine ?? null; - } else if (line.type === 'del') { - // For deletions, the gap is typically in relation to the original file's line numbering - relevantLineNumberForGapCalc = line.oldLine ?? null; - } + const content = displayableLines.reduce( + (acc, line, index) => { + // Determine the relevant line number for gap calculation based on type + let relevantLineNumberForGapCalc: number | null = null; + if (line.type === 'add' || line.type === 'context') { + relevantLineNumberForGapCalc = line.newLine ?? null; + } else if (line.type === 'del') { + // For deletions, the gap is typically in relation to the original file's line numbering + relevantLineNumberForGapCalc = line.oldLine ?? null; + } - if ( - lastLineNumber !== null && - relevantLineNumberForGapCalc !== null && - relevantLineNumberForGapCalc > - lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1 - ) { - acc.push( - + if ( + lastLineNumber !== null && + relevantLineNumberForGapCalc !== null && + relevantLineNumberForGapCalc > + lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1 + ) { + acc.push( + + {useMaxSizedBox ? ( {'═'.repeat(terminalWidth)} - , - ); - } - - const lineKey = `diff-line-${index}`; - let gutterNumStr = ''; - let prefixSymbol = ' '; - - switch (line.type) { - case 'add': - gutterNumStr = (line.newLine ?? '').toString(); - prefixSymbol = '+'; - lastLineNumber = line.newLine ?? null; - break; - case 'del': - gutterNumStr = (line.oldLine ?? '').toString(); - prefixSymbol = '-'; - // For deletions, update lastLineNumber based on oldLine if it's advancing. - // This helps manage gaps correctly if there are multiple consecutive deletions - // or if a deletion is followed by a context line far away in the original file. - if (line.oldLine !== undefined) { - lastLineNumber = line.oldLine; - } - break; - case 'context': - gutterNumStr = (line.newLine ?? '').toString(); - prefixSymbol = ' '; - lastLineNumber = line.newLine ?? null; - break; - default: - return acc; - } - - const displayContent = line.content.substring(baseIndentation); - - acc.push( - - - {gutterNumStr.padStart(gutterWidth)}{' '} - - {line.type === 'context' ? ( - <> - {prefixSymbol} - - {colorizeLine(displayContent, language)} - - ) : ( - - - {prefixSymbol} - {' '} - {colorizeLine(displayContent, language)} - + // We can use a proper separator when not using max sized box. + )} , ); - return acc; - }, [])} - + } + + const lineKey = `diff-line-${index}`; + let gutterNumStr = ''; + let prefixSymbol = ' '; + + switch (line.type) { + case 'add': + gutterNumStr = (line.newLine ?? '').toString(); + prefixSymbol = '+'; + lastLineNumber = line.newLine ?? null; + break; + case 'del': + gutterNumStr = (line.oldLine ?? '').toString(); + prefixSymbol = '-'; + // For deletions, update lastLineNumber based on oldLine if it's advancing. + // This helps manage gaps correctly if there are multiple consecutive deletions + // or if a deletion is followed by a context line far away in the original file. + if (line.oldLine !== undefined) { + lastLineNumber = line.oldLine; + } + break; + case 'context': + gutterNumStr = (line.newLine ?? '').toString(); + prefixSymbol = ' '; + lastLineNumber = line.newLine ?? null; + break; + default: + return acc; + } + + const displayContent = line.content.substring(baseIndentation); + + const backgroundColor = + line.type === 'add' + ? semanticTheme.background.diff.added + : line.type === 'del' + ? semanticTheme.background.diff.removed + : undefined; + acc.push( + + {useMaxSizedBox ? ( + + {gutterNumStr.padStart(gutterWidth)}{' '} + + ) : ( + + {gutterNumStr} + + )} + {line.type === 'context' ? ( + <> + {prefixSymbol} + {colorizeLine(displayContent, language)} + + ) : ( + + + {prefixSymbol} + {' '} + {colorizeLine(displayContent, language)} + + )} + , + ); + return acc; + }, + [], + ); + + if (useMaxSizedBox) { + return ( + + {content} + + ); + } + + return ( + + {content} + ); }; diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx index 1426ea73e9..87a2b1b622 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -10,6 +10,7 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { theme } from '../../semantic-colors.js'; import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js'; import { useUIState } from '../../contexts/UIStateContext.js'; +import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; interface GeminiMessageProps { text: string; @@ -28,6 +29,7 @@ export const GeminiMessage: React.FC = ({ const prefix = '✦ '; const prefixWidth = prefix.length; + const isAlternateBuffer = useAlternateBuffer(); return ( @@ -39,7 +41,9 @@ export const GeminiMessage: React.FC = ({ diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx index 4908ea1780..965a0bcb0f 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Box } from 'ink'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { useUIState } from '../../contexts/UIStateContext.js'; +import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; interface GeminiMessageContentProps { text: string; @@ -29,6 +30,7 @@ export const GeminiMessageContent: React.FC = ({ terminalWidth, }) => { const { renderMarkdown } = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); const originalPrefix = '✦ '; const prefixWidth = originalPrefix.length; @@ -37,7 +39,9 @@ export const GeminiMessageContent: React.FC = ({ diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 61880deb67..879f2a4974 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { Box, Text } from 'ink'; import { DiffRenderer } from './DiffRenderer.js'; import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; @@ -21,6 +21,7 @@ import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { theme } from '../../semantic-colors.js'; +import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; export interface ToolConfirmationMessageProps { confirmationDetails: ToolCallConfirmationDetails; @@ -42,6 +43,8 @@ export const ToolConfirmationMessage: React.FC< const { onConfirm } = confirmationDetails; const childWidth = terminalWidth - 2; // 2 for padding + const isAlternateBuffer = useAlternateBuffer(); + const [ideClient, setIdeClient] = useState(null); const [isDiffingEnabled, setIsDiffingEnabled] = useState(false); @@ -90,42 +93,230 @@ export const ToolConfirmationMessage: React.FC< const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item); - let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here - let question: string; + const { question, bodyContent, options } = useMemo(() => { + let bodyContent: React.ReactNode | null = null; + let question = ''; + const options: Array> = []; - const options: Array> = new Array< - RadioSelectItem - >(); + if (confirmationDetails.type === 'edit') { + if (!confirmationDetails.isModifying) { + question = `Apply this change?`; + options.push({ + label: 'Yes, allow once', + value: ToolConfirmationOutcome.ProceedOnce, + key: 'Yes, allow once', + }); + if (isTrustedFolder) { + options.push({ + label: 'Yes, allow always', + value: ToolConfirmationOutcome.ProceedAlways, + key: 'Yes, allow always', + }); + } + if (!config.getIdeMode() || !isDiffingEnabled) { + options.push({ + label: 'Modify with external editor', + value: ToolConfirmationOutcome.ModifyWithEditor, + key: 'Modify with external editor', + }); + } - // Body content is now the DiffRenderer, passing filename to it - // The bordered box is removed from here and handled within DiffRenderer + options.push({ + label: 'No, suggest changes (esc)', + value: ToolConfirmationOutcome.Cancel, + key: 'No, suggest changes (esc)', + }); + } + } else if (confirmationDetails.type === 'exec') { + const executionProps = + confirmationDetails as ToolExecuteConfirmationDetails; - function availableBodyContentHeight() { - if (options.length === 0) { - // This should not happen in practice as options are always added before this is called. - throw new Error('Options not provided for confirmation message'); + question = `Allow execution of: '${executionProps.rootCommand}'?`; + options.push({ + label: 'Yes, allow once', + value: ToolConfirmationOutcome.ProceedOnce, + key: 'Yes, allow once', + }); + if (isTrustedFolder) { + options.push({ + label: `Yes, allow always ...`, + value: ToolConfirmationOutcome.ProceedAlways, + key: `Yes, allow always ...`, + }); + } + options.push({ + label: 'No, suggest changes (esc)', + value: ToolConfirmationOutcome.Cancel, + key: 'No, suggest changes (esc)', + }); + } else if (confirmationDetails.type === 'info') { + question = `Do you want to proceed?`; + options.push({ + label: 'Yes, allow once', + value: ToolConfirmationOutcome.ProceedOnce, + key: 'Yes, allow once', + }); + if (isTrustedFolder) { + options.push({ + label: 'Yes, allow always', + value: ToolConfirmationOutcome.ProceedAlways, + key: 'Yes, allow always', + }); + } + options.push({ + label: 'No, suggest changes (esc)', + value: ToolConfirmationOutcome.Cancel, + key: 'No, suggest changes (esc)', + }); + } else { + // mcp tool confirmation + const mcpProps = confirmationDetails as ToolMcpConfirmationDetails; + question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`; + options.push({ + label: 'Yes, allow once', + value: ToolConfirmationOutcome.ProceedOnce, + key: 'Yes, allow once', + }); + if (isTrustedFolder) { + options.push({ + label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`, + value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated + key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`, + }); + options.push({ + label: `Yes, always allow all tools from server "${mcpProps.serverName}"`, + value: ToolConfirmationOutcome.ProceedAlwaysServer, + key: `Yes, always allow all tools from server "${mcpProps.serverName}"`, + }); + } + options.push({ + label: 'No, suggest changes (esc)', + value: ToolConfirmationOutcome.Cancel, + key: 'No, suggest changes (esc)', + }); } - if (availableTerminalHeight === undefined) { - return undefined; + function availableBodyContentHeight() { + if (options.length === 0) { + // Should not happen if we populated options correctly above for all types + // except when isModifying is true, but in that case we don't call this because we don't enter the if block for it. + return undefined; + } + + if (availableTerminalHeight === undefined) { + return undefined; + } + + // Calculate the vertical space (in lines) consumed by UI elements + // surrounding the main body content. + const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom). + const MARGIN_BODY_BOTTOM = 1; // margin on the body container. + const HEIGHT_QUESTION = 1; // The question text is one line. + const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container. + const HEIGHT_OPTIONS = options.length; // Each option in the radio select takes one line. + + const surroundingElementsHeight = + PADDING_OUTER_Y + + MARGIN_BODY_BOTTOM + + HEIGHT_QUESTION + + MARGIN_QUESTION_BOTTOM + + HEIGHT_OPTIONS; + return Math.max(availableTerminalHeight - surroundingElementsHeight, 1); } - // Calculate the vertical space (in lines) consumed by UI elements - // surrounding the main body content. - const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom). - const MARGIN_BODY_BOTTOM = 1; // margin on the body container. - const HEIGHT_QUESTION = 1; // The question text is one line. - const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container. - const HEIGHT_OPTIONS = options.length; // Each option in the radio select takes one line. + if (confirmationDetails.type === 'edit') { + if (!confirmationDetails.isModifying) { + bodyContent = ( + + ); + } + } else if (confirmationDetails.type === 'exec') { + const executionProps = + confirmationDetails as ToolExecuteConfirmationDetails; + let bodyContentHeight = availableBodyContentHeight(); + if (bodyContentHeight !== undefined) { + bodyContentHeight -= 2; // Account for padding; + } - const surroundingElementsHeight = - PADDING_OUTER_Y + - MARGIN_BODY_BOTTOM + - HEIGHT_QUESTION + - MARGIN_QUESTION_BOTTOM + - HEIGHT_OPTIONS; - return Math.max(availableTerminalHeight - surroundingElementsHeight, 1); - } + const commandBox = ( + + {executionProps.command} + + ); + + bodyContent = ( + + + {isAlternateBuffer ? ( + commandBox + ) : ( + + {commandBox} + + )} + + + ); + } else if (confirmationDetails.type === 'info') { + const infoProps = confirmationDetails; + const displayUrls = + infoProps.urls && + !( + infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt + ); + + bodyContent = ( + + + + + {displayUrls && infoProps.urls && infoProps.urls.length > 0 && ( + + URLs to fetch: + {infoProps.urls.map((url) => ( + + {' '} + - + + ))} + + )} + + ); + } else { + // mcp tool confirmation + const mcpProps = confirmationDetails as ToolMcpConfirmationDetails; + + bodyContent = ( + + MCP Server: {mcpProps.serverName} + Tool: {mcpProps.toolName} + + ); + } + + return { question, bodyContent, options }; + }, [ + confirmationDetails, + isTrustedFolder, + config, + isDiffingEnabled, + availableTerminalHeight, + terminalWidth, + isAlternateBuffer, + childWidth, + ]); if (confirmationDetails.type === 'edit') { if (confirmationDetails.isModifying) { @@ -145,177 +336,29 @@ export const ToolConfirmationMessage: React.FC< ); } - - question = `Apply this change?`; - options.push({ - label: 'Yes, allow once', - value: ToolConfirmationOutcome.ProceedOnce, - key: 'Yes, allow once', - }); - if (isTrustedFolder) { - options.push({ - label: 'Yes, allow always', - value: ToolConfirmationOutcome.ProceedAlways, - key: 'Yes, allow always', - }); - } - if (!config.getIdeMode() || !isDiffingEnabled) { - options.push({ - label: 'Modify with external editor', - value: ToolConfirmationOutcome.ModifyWithEditor, - key: 'Modify with external editor', - }); - } - - options.push({ - label: 'No, suggest changes (esc)', - value: ToolConfirmationOutcome.Cancel, - key: 'No, suggest changes (esc)', - }); - - bodyContent = ( - - ); - } else if (confirmationDetails.type === 'exec') { - const executionProps = - confirmationDetails as ToolExecuteConfirmationDetails; - - question = `Allow execution of: '${executionProps.rootCommand}'?`; - options.push({ - label: 'Yes, allow once', - value: ToolConfirmationOutcome.ProceedOnce, - key: 'Yes, allow once', - }); - if (isTrustedFolder) { - options.push({ - label: `Yes, allow always ...`, - value: ToolConfirmationOutcome.ProceedAlways, - key: `Yes, allow always ...`, - }); - } - options.push({ - label: 'No, suggest changes (esc)', - value: ToolConfirmationOutcome.Cancel, - key: 'No, suggest changes (esc)', - }); - - let bodyContentHeight = availableBodyContentHeight(); - if (bodyContentHeight !== undefined) { - bodyContentHeight -= 2; // Account for padding; - } - bodyContent = ( - - - - - {executionProps.command} - - - - - ); - } else if (confirmationDetails.type === 'info') { - const infoProps = confirmationDetails; - const displayUrls = - infoProps.urls && - !(infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt); - - question = `Do you want to proceed?`; - options.push({ - label: 'Yes, allow once', - value: ToolConfirmationOutcome.ProceedOnce, - key: 'Yes, allow once', - }); - if (isTrustedFolder) { - options.push({ - label: 'Yes, allow always', - value: ToolConfirmationOutcome.ProceedAlways, - key: 'Yes, allow always', - }); - } - options.push({ - label: 'No, suggest changes (esc)', - value: ToolConfirmationOutcome.Cancel, - key: 'No, suggest changes (esc)', - }); - - bodyContent = ( - - - {displayUrls && infoProps.urls && infoProps.urls.length > 0 && ( - - URLs to fetch: - {infoProps.urls.map((url) => ( - - {' '} - - - - ))} - - )} - - ); - } else { - // mcp tool confirmation - const mcpProps = confirmationDetails as ToolMcpConfirmationDetails; - - bodyContent = ( - - MCP Server: {mcpProps.serverName} - Tool: {mcpProps.toolName} - - ); - - question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`; - options.push({ - label: 'Yes, allow once', - value: ToolConfirmationOutcome.ProceedOnce, - key: 'Yes, allow once', - }); - if (isTrustedFolder) { - options.push({ - label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`, - value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated - key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`, - }); - options.push({ - label: `Yes, always allow all tools from server "${mcpProps.serverName}"`, - value: ToolConfirmationOutcome.ProceedAlwaysServer, - key: `Yes, always allow all tools from server "${mcpProps.serverName}"`, - }); - } - options.push({ - label: 'No, suggest changes (esc)', - value: ToolConfirmationOutcome.Cancel, - key: 'No, suggest changes (esc)', - }); } return ( - + {/* Body Content (Diff Renderer or Command Info) */} {/* No separate context display here anymore for edits */} - + {bodyContent} {/* Confirmation Question */} - - - {question} - + + {question} {/* Select Input for Options */} - + ({ @@ -66,8 +61,6 @@ vi.mock('./ToolConfirmationMessage.js', () => ({ })); describe('', () => { - const mockConfig: Config = {} as Config; - const createToolCall = ( overrides: Partial = {}, ): IndividualToolCallDisplay => ({ @@ -87,14 +80,6 @@ describe('', () => { isFocused: true, }; - // Helper to wrap component with required providers - const renderWithProviders = (component: React.ReactElement) => - render( - - {component} - , - ); - describe('Golden Snapshots', () => { it('renders single successful tool call', () => { const toolCalls = [createToolCall()]; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 931a178da4..47b5c12f47 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -14,6 +14,7 @@ import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { theme } from '../../semantic-colors.js'; import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js'; import { useConfig } from '../../contexts/ConfigContext.js'; +import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; interface ToolGroupMessageProps { groupId: number; @@ -47,6 +48,7 @@ export const ToolGroupMessage: React.FC = ({ ); const config = useConfig(); + const isAlternateBuffer = useAlternateBuffer(); const isShellCommand = toolCalls.some( (t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME, ); @@ -59,8 +61,8 @@ export const ToolGroupMessage: React.FC = ({ const staticHeight = /* border */ 2 + /* marginBottom */ 1; // This is a bit of a magic number, but it accounts for the border and - // marginLeft. - const innerWidth = terminalWidth - 4; + // marginLeft in regular mode and just the border in alternate buffer mode. + const innerWidth = isAlternateBuffer ? terminalWidth - 3 : terminalWidth - 4; // only prompt for tool approval on the first 'confirming' tool in the list // note, after the CTA, this automatically moves over to the next 'confirming' tool @@ -106,24 +108,23 @@ export const ToolGroupMessage: React.FC = ({ {toolCalls.map((tool) => { const isConfirming = toolAwaitingApproval?.callId === tool.callId; return ( - - - - + + {tool.status === ToolCallStatus.Confirming && isConfirming && tool.confirmationDetails && ( diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index e031afa431..60b91fabc7 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -14,6 +14,7 @@ import { AnsiOutputText } from '../AnsiOutput.js'; import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { ShellInputPrompt } from '../ShellInputPrompt.js'; +import { StickyHeader } from '../StickyHeader.js'; import { SHELL_COMMAND_NAME, SHELL_NAME, @@ -22,6 +23,7 @@ import { import { theme } from '../../semantic-colors.js'; import type { AnsiOutput, Config } from '@google/gemini-cli-core'; import { useUIState } from '../../contexts/UIStateContext.js'; +import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; const STATIC_HEIGHT = 1; const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. @@ -58,6 +60,7 @@ export const ToolMessage: React.FC = ({ config, }) => { const { renderMarkdown } = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); const isThisShellFocused = (name === SHELL_COMMAND_NAME || name === 'Shell') && status === ToolCallStatus.Executing && @@ -108,23 +111,93 @@ export const ToolMessage: React.FC = ({ : undefined; // Long tool call response in MarkdownDisplay doesn't respect availableTerminalHeight properly, - // we're forcing it to not render as markdown when the response is too long, it will fallback + // so if we aren't using alternate buffer mode, we're forcing it to not render as markdown when the response is too long, it will fallback // to render as plain text, which is contained within the terminal using MaxSizedBox - if (availableHeight) { + if (availableHeight && !isAlternateBuffer) { renderOutputAsMarkdown = false; } + const childWidth = terminalWidth; - const childWidth = terminalWidth - 3; // account for padding. - if (typeof resultDisplay === 'string') { - if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) { - // Truncate the result display to fit within the available width. - resultDisplay = - '...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); + const truncatedResultDisplay = React.useMemo(() => { + if (typeof resultDisplay === 'string') { + if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) { + return '...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); + } } - } + return resultDisplay; + }, [resultDisplay]); + + const renderedResult = React.useMemo(() => { + if (!truncatedResultDisplay) return null; + + return ( + + + {typeof truncatedResultDisplay === 'string' && + renderOutputAsMarkdown ? ( + + + + ) : typeof truncatedResultDisplay === 'string' && + !renderOutputAsMarkdown ? ( + isAlternateBuffer ? ( + + + {truncatedResultDisplay} + + + ) : ( + + + + {truncatedResultDisplay} + + + + ) + ) : typeof truncatedResultDisplay === 'object' && + 'fileDiff' in truncatedResultDisplay ? ( + + ) : typeof truncatedResultDisplay === 'object' && + 'todos' in truncatedResultDisplay ? ( + // display nothing, as the TodoTray will handle rendering todos + <> + ) : ( + + )} + + + ); + }, [ + truncatedResultDisplay, + renderOutputAsMarkdown, + childWidth, + renderMarkdown, + isAlternateBuffer, + availableHeight, + terminalWidth, + ]); + return ( - - + // We have the StickyHeader intentionally exceedsthe allowed width for this + // component by 1 so tne horizontal line it renders can extend into the 1 + // pixel of padding of the box drawn by the parent of the ToolMessage. + <> + = ({ )} {emphasis === 'high' && } - - {resultDisplay && ( - - - {typeof resultDisplay === 'string' && renderOutputAsMarkdown ? ( - - - - ) : typeof resultDisplay === 'string' && !renderOutputAsMarkdown ? ( - - - - {resultDisplay} - - - - ) : typeof resultDisplay === 'object' && - 'fileDiff' in resultDisplay ? ( - - ) : typeof resultDisplay === 'object' && - 'todos' in resultDisplay ? ( - // display nothing, as the TodoTray will handle rendering todos - <> - ) : ( - - )} - - - )} + + {renderedResult} {isThisShellFocused && config && ( = ({ /> )} - + ); }; @@ -271,10 +302,7 @@ const ToolInfo: React.FC = ({ }, [emphasis]); return ( - + {name} {' '} diff --git a/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx b/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx index 27c4b88a23..c9030a0af8 100644 --- a/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx @@ -23,20 +23,52 @@ describe(' - Raw Markdown Display Snapshots', () => { }; it.each([ - { renderMarkdown: true, description: '(default)' }, + { + renderMarkdown: true, + useAlternateBuffer: false, + description: '(default, regular buffer)', + }, + { + renderMarkdown: true, + useAlternateBuffer: true, + description: '(default, alternate buffer)', + }, { renderMarkdown: false, - description: '(raw markdown with syntax highlighting, no line numbers)', + useAlternateBuffer: false, + description: '(raw markdown, regular buffer)', + }, + { + renderMarkdown: false, + useAlternateBuffer: true, + description: '(raw markdown, alternate buffer)', + }, + // Test cases where height constraint affects rendering in regular buffer but not alternate + { + renderMarkdown: true, + useAlternateBuffer: false, + availableTerminalHeight: 10, + description: '(constrained height, regular buffer -> forces raw)', + }, + { + renderMarkdown: true, + useAlternateBuffer: true, + availableTerminalHeight: 10, + description: '(constrained height, alternate buffer -> keeps markdown)', }, ])( - 'renders with renderMarkdown=$renderMarkdown $description', - ({ renderMarkdown }) => { + 'renders with renderMarkdown=$renderMarkdown, useAlternateBuffer=$useAlternateBuffer $description', + ({ renderMarkdown, useAlternateBuffer, availableTerminalHeight }) => { const { lastFrame } = renderWithProviders( - + , { uiState: { renderMarkdown, streamingState: StreamingState.Idle }, + useAlternateBuffer, }, ); expect(lastFrame()).toMatchSnapshot(); diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx index 5cc2b965ca..51f94a0601 100644 --- a/packages/cli/src/ui/components/messages/UserMessage.tsx +++ b/packages/cli/src/ui/components/messages/UserMessage.tsx @@ -12,9 +12,10 @@ import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils. interface UserMessageProps { text: string; + width: number; } -export const UserMessage: React.FC = ({ text }) => { +export const UserMessage: React.FC = ({ text, width }) => { const prefix = '> '; const prefixWidth = prefix.length; const isSlashCommand = checkIsSlashCommand(text); @@ -22,8 +23,14 @@ export const UserMessage: React.FC = ({ text }) => { const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary; return ( - - + + {prefix} diff --git a/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap new file mode 100644 index 0000000000..38944657b1 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap @@ -0,0 +1,175 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > with useAlternateBuffer = false > should correctly render a diff with a SVN diff format 1`] = ` +" 1 - const oldVar = 1; + 1 + const newVar = 1; +════════════════════════════════════════════════════════════════════════════════ +20 - const anotherOld = 'test'; +20 + const anotherNew = 'test';" +`; + +exports[` > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = ` +"... first 10 lines hidden ... + ; +21 + const anotherNew = 'test' + ; +22 console.log('end of + second hunk');" +`; + +exports[` > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = ` +"... first 4 lines hidden ... +════════════════════════════════════════════════════════════════════════════════ +20 console.log('second hunk'); +21 - const anotherOld = 'test'; +21 + const anotherNew = 'test'; +22 console.log('end of second hunk');" +`; + +exports[` > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height undefined 1`] = ` +" 1 console.log('first hunk'); + 2 - const oldVar = 1; + 2 + const newVar = 1; + 3 console.log('end of first hunk'); +════════════════════════════════════════════════════════════════════════════════ +20 console.log('second hunk'); +21 - const anotherOld = 'test'; +21 + const anotherNew = 'test'; +22 console.log('end of second hunk');" +`; + +exports[` > with useAlternateBuffer = false > should correctly render a new file with no file extension correctly 1`] = ` +"1 FROM node:14 +2 RUN npm install +3 RUN npm run build" +`; + +exports[` > with useAlternateBuffer = false > should handle diff with only header and no changes 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No changes detected. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > with useAlternateBuffer = false > should handle empty diff content 1`] = `"No diff content."`; + +exports[` > with useAlternateBuffer = false > should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP) 1`] = ` +" 1 context line 1 + 2 context line 2 + 3 context line 3 + 4 context line 4 + 5 context line 5 +11 context line 11 +12 context line 12 +13 context line 13 +14 context line 14 +15 context line 15" +`; + +exports[` > with useAlternateBuffer = false > should render a gap indicator for skipped lines 1`] = ` +" 1 context line 1 + 2 - deleted line + 2 + added line +════════════════════════════════════════════════════════════════════════════════ +10 context line 10 +11 context line 11" +`; + +exports[` > with useAlternateBuffer = false > should render diff content for existing file (not calling colorizeCode directly for the whole block) 1`] = ` +"1 - old line +1 + new line" +`; + +exports[` > with useAlternateBuffer = true > should correctly render a diff with a SVN diff format 1`] = ` +" 1 - const oldVar = 1; + 1 + const newVar = 1; +═══════════════════════════════════════════════════════════════════════════════ +20 - const anotherOld = 'test'; +20 + const anotherNew = 'test';" +`; + +exports[` > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = ` +" 1 console.log('first hunk'); + + 2 - const oldVar = 1; + 2 + const newVar = 1; + 3 console.log('end of first + hunk'); +═════════════════════════════ +20 console.log('second + hunk'); +21 - const anotherOld = + 'test'; +21 + const anotherNew = + 'test'; +22 console.log('end of second + hunk');" +`; + +exports[` > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = ` +" 1 console.log('first hunk'); + 2 - const oldVar = 1; + 2 + const newVar = 1; + 3 console.log('end of first hunk'); +═══════════════════════════════════════════════════════════════════════════════ +20 console.log('second hunk'); +21 - const anotherOld = 'test'; +21 + const anotherNew = 'test'; +22 console.log('end of second hunk');" +`; + +exports[` > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height undefined 1`] = ` +" 1 console.log('first hunk'); + 2 - const oldVar = 1; + 2 + const newVar = 1; + 3 console.log('end of first hunk'); +═══════════════════════════════════════════════════════════════════════════════ +20 console.log('second hunk'); +21 - const anotherOld = 'test'; +21 + const anotherNew = 'test'; +22 console.log('end of second hunk');" +`; + +exports[` > with useAlternateBuffer = true > should correctly render a new file with no file extension correctly 1`] = ` +"1 FROM node:14 +2 RUN npm install +3 RUN npm run build" +`; + +exports[` > with useAlternateBuffer = true > should handle diff with only header and no changes 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No changes detected. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > with useAlternateBuffer = true > should handle empty diff content 1`] = `"No diff content."`; + +exports[` > with useAlternateBuffer = true > should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP) 1`] = ` +" 1 context line 1 + 2 context line 2 + 3 context line 3 + 4 context line 4 + 5 context line 5 +11 context line 11 +12 context line 12 +13 context line 13 +14 context line 14 +15 context line 15" +`; + +exports[` > with useAlternateBuffer = true > should render a gap indicator for skipped lines 1`] = ` +" 1 context line 1 + 2 - deleted line + 2 + added line +═══════════════════════════════════════════════════════════════════════════════ +10 context line 10 +11 context line 11" +`; + +exports[` > with useAlternateBuffer = true > should render diff content for existing file (not calling colorizeCode directly for the whole block) 1`] = ` +"1 - old line +1 + new line" +`; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index 4dbd1c399f..2215c6674d 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -91,9 +91,10 @@ exports[` > Golden Snapshots > renders with limited terminal exports[` > Golden Snapshots > renders with narrow terminal width 1`] = ` "╭──────────────────────────────────────╮ │MockTool[tool-123]: ✓ │ -│very-long-tool-name-that-might-wrap - │ -│This is a very long description that │ -│might cause wrapping issues (medium) │ +│very-long-tool-name-that-might-wrap │ +│- This is a very long description │ +│that might cause wrapping issues │ +│(medium) │ ╰──────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap index 94725a99cc..abb6a53f9b 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap @@ -1,13 +1,31 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` - Raw Markdown Display Snapshots > renders with renderMarkdown=false '(raw markdown with syntax highlightin…' 1`] = ` +exports[` - Raw Markdown Display Snapshots > renders with renderMarkdown=false, useAlternateBuffer=false '(raw markdown, regular buffer)' 1`] = ` " ✓ test-tool A tool for testing - - Test **bold** and \`code\` markdown" + Test **bold** and \`code\` markdown" `; -exports[` - Raw Markdown Display Snapshots > renders with renderMarkdown=true '(default)' 1`] = ` +exports[` - Raw Markdown Display Snapshots > renders with renderMarkdown=false, useAlternateBuffer=true '(raw markdown, alternate buffer)' 1`] = ` " ✓ test-tool A tool for testing - - Test bold and code markdown" + Test **bold** and \`code\` markdown" +`; + +exports[` - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=false '(constrained height, regular buffer -…' 1`] = ` +" ✓ test-tool A tool for testing + Test **bold** and \`code\` markdown" +`; + +exports[` - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=false '(default, regular buffer)' 1`] = ` +" ✓ test-tool A tool for testing + Test bold and code markdown" +`; + +exports[` - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=true '(constrained height, alternate buffer…' 1`] = ` +" ✓ test-tool A tool for testing + Test bold and code markdown" +`; + +exports[` - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=true '(default, alternate buffer)' 1`] = ` +" ✓ test-tool A tool for testing + Test bold and code markdown" `; diff --git a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx index 6ff66a4a2a..dec86fa56f 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx @@ -12,6 +12,8 @@ import { ScrollProvider } from '../../contexts/ScrollProvider.js'; import { KeypressProvider } from '../../contexts/KeypressContext.js'; import { MouseProvider } from '../../contexts/MouseContext.js'; import { describe, it, expect, vi } from 'vitest'; +import { waitFor } from '../../../test-utils/async.js'; + // Mock useStdout to provide a fixed size for testing vi.mock('ink', async (importOriginal) => { const actual = await importOriginal(); @@ -152,28 +154,20 @@ describe('ScrollableList Demo Behavior', () => { await act(async () => { addItem?.(); }); - for (let i = 0; i < 20; i++) { - if (lastFrame!()?.includes('Count: 1001')) break; - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); - }); - } + await waitFor(() => { + expect(lastFrame!()).toContain('Count: 1001'); + }); expect(lastFrame!()).toContain('Item 1001'); - expect(lastFrame!()).toContain('Count: 1001'); expect(lastFrame!()).not.toContain('Item 990'); // Should have scrolled past it // Add item 1002 await act(async () => { addItem?.(); }); - for (let i = 0; i < 20; i++) { - if (lastFrame!()?.includes('Count: 1002')) break; - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); - }); - } + await waitFor(() => { + expect(lastFrame!()).toContain('Count: 1002'); + }); expect(lastFrame!()).toContain('Item 1002'); - expect(lastFrame!()).toContain('Count: 1002'); expect(lastFrame!()).not.toContain('Item 991'); // Scroll up directly via ref @@ -188,13 +182,103 @@ describe('ScrollableList Demo Behavior', () => { await act(async () => { addItem?.(); }); - for (let i = 0; i < 20; i++) { - if (lastFrame!()?.includes('Count: 1003')) break; - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); - }); - } + await waitFor(() => { + expect(lastFrame!()).toContain('Count: 1003'); + }); expect(lastFrame!()).not.toContain('Item 1003'); - expect(lastFrame!()).toContain('Count: 1003'); + }); + + it('should display sticky header when scrolled past the item', async () => { + let listRef: ScrollableListRef | null = null; + const StickyTestComponent = () => { + const items = Array.from({ length: 100 }, (_, i) => ({ + id: String(i), + title: `Item ${i + 1}`, + })); + + const ref = useRef>(null); + useEffect(() => { + listRef = ref.current; + }, []); + + return ( + + + + + ( + + {index === 0 ? ( + [STICKY] {item.title}} + > + [Normal] {item.title} + + ) : ( + [Normal] {item.title} + )} + Content for {item.title} + More content for {item.title} + + )} + estimatedItemHeight={() => 3} + keyExtractor={(item) => item.id} + hasFocus={true} + /> + + + + + ); + }; + + let lastFrame: () => string | undefined; + await act(async () => { + const result = render(); + lastFrame = result.lastFrame; + }); + + // Initially at top, should see Normal Item 1 + await waitFor(() => { + expect(lastFrame!()).toContain('[Normal] Item 1'); + }); + expect(lastFrame!()).not.toContain('[STICKY] Item 1'); + + // Scroll down slightly. Item 1 (height 3) is now partially off-screen (-2), so it should stick. + await act(async () => { + listRef?.scrollBy(2); + }); + + // Now Item 1 should be stuck + await waitFor(() => { + expect(lastFrame!()).toContain('[STICKY] Item 1'); + }); + expect(lastFrame!()).not.toContain('[Normal] Item 1'); + + // Scroll further down to unmount Item 1. + // Viewport height 10, item height 3. Scroll to 10. + // startIndex should be around 2, so Item 1 (index 0) is unmounted. + await act(async () => { + listRef?.scrollTo(10); + }); + + await waitFor(() => { + expect(lastFrame!()).not.toContain('[STICKY] Item 1'); + }); + + // Scroll back to top + await act(async () => { + listRef?.scrollTo(0); + }); + + // Should be normal again + await waitFor(() => { + expect(lastFrame!()).toContain('[Normal] Item 1'); + }); + expect(lastFrame!()).not.toContain('[STICKY] Item 1'); }); }); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index 02adae3e5d..fd7902d33d 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -481,6 +481,7 @@ function VirtualizedList( width="100%" height="100%" flexDirection="column" + paddingRight={1} > diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 6b099fd806..143556f003 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -18,6 +18,12 @@ export const SHELL_COMMAND_NAME = 'Shell Command'; export const SHELL_NAME = 'Shell'; +// Limit Gemini messages to a very high number of lines to mitigate performance +// issues in the worst case if we somehow get an enormous response from Gemini. +// This threshold is arbitrary but should be high enough to never impact normal +// usage. +export const MAX_GEMINI_MESSAGE_LINES = 65536; + // Tool status symbols used in ToolMessage component export const TOOL_STATUS = { SUCCESS: '✓', diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 90a35c185f..7812ea3431 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -123,6 +123,7 @@ export interface UIState { embeddedShellFocused: boolean; showDebugProfiler: boolean; showFullTodos: boolean; + copyModeEnabled: boolean; } export const UIStateContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/useAlternateBuffer.ts b/packages/cli/src/ui/hooks/useAlternateBuffer.ts new file mode 100644 index 0000000000..5b6a55b215 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAlternateBuffer.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useSettings } from '../contexts/SettingsContext.js'; + +export const useAlternateBuffer = (): boolean => { + const settings = useSettings(); + return settings.merged.ui?.useAlternateBuffer ?? false; +}; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 679427eea1..5019d51d9c 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -146,6 +146,10 @@ vi.mock('./slashCommandProcessor.js', () => ({ handleSlashCommand: vi.fn().mockReturnValue(false), })); +vi.mock('./useAlternateBuffer.js', () => ({ + useAlternateBuffer: vi.fn(() => false), +})); + // --- END MOCKS --- // --- Tests for useGeminiStream Hook --- diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index db715343f1..5c99a78fe9 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -13,30 +13,40 @@ import { Composer } from '../components/Composer.js'; import { ExitWarning } from '../components/ExitWarning.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useFlickerDetector } from '../hooks/useFlickerDetector.js'; -import { useSettings } from '../contexts/SettingsContext.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { CopyModeWarning } from '../components/CopyModeWarning.js'; export const DefaultAppLayout: React.FC = () => { const uiState = useUIState(); - const { rootUiRef, terminalHeight } = uiState; - const settings = useSettings(); - useFlickerDetector(rootUiRef, terminalHeight); + const isAlternateBuffer = useAlternateBuffer(); + const { rootUiRef, terminalHeight } = uiState; + useFlickerDetector(rootUiRef, terminalHeight); + // If in alternate buffer mode, need to leave room to draw the scrollbar on + // the right side of the terminal. + const width = isAlternateBuffer + ? uiState.terminalWidth + : uiState.mainAreaWidth; return ( - + + {uiState.dialogsVisible ? ( elements for the highlighted code. */ -export function colorizeCode( - code: string, - language: string | null, - availableHeight?: number, - maxWidth?: number, - theme?: Theme, - settings?: LoadedSettings, - hideLineNumbers?: boolean, -): React.ReactNode { +export function colorizeCode({ + code, + language = null, + availableHeight, + maxWidth, + theme = null, + settings, + hideLineNumbers = false, +}: ColorizeCodeOptions): React.ReactNode { const codeToHighlight = code.replace(/\n$/, ''); const activeTheme = theme || themeManager.getActiveTheme(); const showLineNumbers = hideLineNumbers ? false : (settings?.merged.ui?.showLineNumbers ?? true); + const useMaxSizedBox = settings?.merged.ui?.useAlternateBuffer !== true; try { // Render the HAST tree using the adapted theme // Apply the theme's default foreground color to the top-level Text element @@ -150,7 +160,10 @@ export function colorizeCode( let hiddenLinesCount = 0; // Optimization to avoid highlighting lines that cannot possibly be displayed. - if (availableHeight !== undefined) { + if ( + availableHeight !== undefined && + settings?.merged.ui?.useAlternateBuffer === false + ) { availableHeight = Math.max(availableHeight, MINIMUM_MAX_HEIGHT); if (lines.length > availableHeight) { const sliceIndex = lines.length - availableHeight; @@ -159,37 +172,61 @@ export function colorizeCode( } } - return ( - - {lines.map((line, index) => { - const contentToRender = highlightAndRenderLine( - line, - language, - activeTheme, - ); + const renderedLines = lines.map((line, index) => { + const contentToRender = highlightAndRenderLine( + line, + language, + activeTheme, + ); - return ( - - {showLineNumbers && ( - - {`${String(index + 1 + hiddenLinesCount).padStart( - padWidth, - ' ', - )} `} - - )} - - {contentToRender} + return ( + + {/* We have to render line numbers differently depending on whether we are using MaxSizeBox or not */} + {showLineNumbers && useMaxSizedBox && ( + + {`${String(index + 1 + hiddenLinesCount).padStart( + padWidth, + ' ', + )} `} + + )} + {showLineNumbers && !useMaxSizedBox && ( + + + {`${index + 1 + hiddenLinesCount}`} - ); - })} - + )} + + {contentToRender} + + + ); + }); + + if (useMaxSizedBox) { + return ( + + {renderedLines} + + ); + } + + return ( + + {renderedLines} + ); } catch (error) { debugLogger.warn( @@ -200,23 +237,45 @@ export function colorizeCode( // Also display line numbers in fallback const lines = codeToHighlight.split('\n'); const padWidth = String(lines.length).length; // Calculate padding width based on number of lines - return ( - - {lines.map((line, index) => ( - - {showLineNumbers && ( - - {`${String(index + 1).padStart(padWidth, ' ')} `} - - )} - {line} + const fallbackLines = lines.map((line, index) => ( + + {/* We have to render line numbers differently depending on whether we are using MaxSizeBox or not */} + {showLineNumbers && useMaxSizedBox && ( + + {`${String(index + 1).padStart(padWidth, ' ')} `} + + )} + {showLineNumbers && !useMaxSizedBox && ( + + {`${index + 1}`} - ))} - + )} + {line} + + )); + + if (useMaxSizedBox) { + return ( + + {fallbackLines} + + ); + } + + return ( + + {fallbackLines} + ); } } diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 6d7197ea89..60f15e9598 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -11,6 +11,7 @@ import { colorizeCode } from './CodeColorizer.js'; import { TableRenderer } from './TableRenderer.js'; import { RenderInline } from './InlineMarkdownRenderer.js'; import { useSettings } from '../contexts/SettingsContext.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; interface MarkdownDisplayProps { text: string; @@ -35,6 +36,7 @@ const MarkdownDisplayInternal: React.FC = ({ renderMarkdown = true, }) => { const settings = useSettings(); + const isAlternateBuffer = useAlternateBuffer(); const responseColor = theme.text.response ?? theme.text.primary; if (!text) return <>; @@ -42,15 +44,14 @@ const MarkdownDisplayInternal: React.FC = ({ // Raw markdown mode - display syntax-highlighted markdown without rendering if (!renderMarkdown) { // Hide line numbers in raw markdown mode as they are confusing due to chunked output - const colorizedMarkdown = colorizeCode( - text, - 'markdown', - availableTerminalHeight, - terminalWidth - CODE_BLOCK_PREFIX_PADDING, - undefined, + const colorizedMarkdown = colorizeCode({ + code: text, + language: 'markdown', + availableHeight: isAlternateBuffer ? undefined : availableTerminalHeight, + maxWidth: terminalWidth - CODE_BLOCK_PREFIX_PADDING, settings, - true, // hideLineNumbers - ); + hideLineNumbers: true, + }); return ( {colorizedMarkdown} @@ -100,7 +101,9 @@ const MarkdownDisplayInternal: React.FC = ({ content={codeBlockContent} lang={codeBlockLang} isPending={isPending} - availableTerminalHeight={availableTerminalHeight} + availableTerminalHeight={ + isAlternateBuffer ? undefined : availableTerminalHeight + } terminalWidth={terminalWidth} />, ); @@ -288,7 +291,9 @@ const MarkdownDisplayInternal: React.FC = ({ content={codeBlockContent} lang={codeBlockLang} isPending={isPending} - availableTerminalHeight={availableTerminalHeight} + availableTerminalHeight={ + isAlternateBuffer ? undefined : availableTerminalHeight + } terminalWidth={terminalWidth} />, ); @@ -327,10 +332,17 @@ const RenderCodeBlockInternal: React.FC = ({ terminalWidth, }) => { const settings = useSettings(); + const isAlternateBuffer = useAlternateBuffer(); const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding - if (isPending && availableTerminalHeight !== undefined) { + // When not in alternate buffer mode we need to be careful that we don't + // trigger flicker when the pending code is to long to fit in the terminal + if ( + !isAlternateBuffer && + isPending && + availableTerminalHeight !== undefined + ) { const MAX_CODE_LINES_WHEN_PENDING = Math.max( 0, availableTerminalHeight - RESERVED_LINES, @@ -348,14 +360,13 @@ const RenderCodeBlockInternal: React.FC = ({ ); } const truncatedContent = content.slice(0, MAX_CODE_LINES_WHEN_PENDING); - const colorizedTruncatedCode = colorizeCode( - truncatedContent.join('\n'), - lang, - availableTerminalHeight, - terminalWidth - CODE_BLOCK_PREFIX_PADDING, - undefined, + const colorizedTruncatedCode = colorizeCode({ + code: truncatedContent.join('\n'), + language: lang, + availableHeight: availableTerminalHeight, + maxWidth: terminalWidth - CODE_BLOCK_PREFIX_PADDING, settings, - ); + }); return ( {colorizedTruncatedCode} @@ -366,14 +377,13 @@ const RenderCodeBlockInternal: React.FC = ({ } const fullContent = content.join('\n'); - const colorizedCode = colorizeCode( - fullContent, - lang, - availableTerminalHeight, - terminalWidth - CODE_BLOCK_PREFIX_PADDING, - undefined, + const colorizedCode = colorizeCode({ + code: fullContent, + language: lang, + availableHeight: isAlternateBuffer ? undefined : availableTerminalHeight, + maxWidth: terminalWidth - CODE_BLOCK_PREFIX_PADDING, settings, - ); + }); return ( { export const calculateMainAreaWidth = ( terminalWidth: number, settings: LoadedSettings, -): number => - settings.merged.ui?.useFullWidth - ? terminalWidth - : getMainAreaWidthInternal(terminalWidth); +): number => { + if (settings.merged.ui?.useFullWidth) { + if (settings.merged.ui?.useAlternateBuffer) { + return terminalWidth - 1; + } + return terminalWidth; + } + return getMainAreaWidthInternal(terminalWidth); +};