Support ink scrolling final pr (#12567)

This commit is contained in:
Jacob Richman
2025-11-11 07:50:11 -08:00
committed by GitHub
parent bfcb4f883c
commit ca478d47ce
43 changed files with 2498 additions and 1568 deletions

View File

@@ -206,7 +206,6 @@ export async function startInteractiveUI(
<VimModeProvider settings={settings}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}

View File

@@ -122,6 +122,7 @@ export const renderWithProviders = (
width,
mouseEventsEnabled = false,
config = configProxy as unknown as Config,
useAlternateBuffer,
}: {
shellFocus?: boolean;
settings?: LoadedSettings;
@@ -129,6 +130,7 @@ export const renderWithProviders = (
width?: number;
mouseEventsEnabled?: boolean;
config?: Config;
useAlternateBuffer?: boolean;
} = {},
): ReturnType<typeof render> => {
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(
<ConfigContext.Provider value={config}>
<SettingsContext.Provider value={settings}>
<SettingsContext.Provider value={finalSettings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider settings={settings}>
<VimModeProvider settings={finalSettings}>
<ShellFocusContext.Provider value={shellFocus}>
<KeypressProvider>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>

View File

@@ -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: () => <Text>Quitting...</Text>,
}));
vi.mock('./components/HistoryItemDisplay.js', () => ({
HistoryItemDisplay: () => <Text>HistoryItemDisplay</Text>,
}));
vi.mock('./components/Footer.js', () => ({
Footer: () => <Text>Footer</Text>,
}));
@@ -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<SettingScope>(),
);
const mockAppState: AppState = {
version: '1.0.0',
startupWarnings: [],
};
const renderWithProviders = (ui: React.ReactElement, state: UIState) =>
render(
<ConfigContext.Provider value={mockConfig}>
<SettingsContext.Provider value={mockLoadedSettings}>
<UIStateContext.Provider value={state}>{ui}</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
<AppContext.Provider value={mockAppState}>
<ConfigContext.Provider value={mockConfig}>
<SettingsContext.Provider value={mockLoadedSettings}>
<UIStateContext.Provider value={state}>
{ui}
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>
</AppContext.Provider>,
);
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(<App />, 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,

View File

@@ -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 <QuittingDisplay />;
if (isAlternateBuffer) {
return (
<StreamingContext.Provider value={uiState.streamingState}>
<AlternateBufferQuittingDisplay />
</StreamingContext.Provider>
);
} else {
return <QuittingDisplay />;
}
}
return (

View File

@@ -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<ExtensionManager>;
// 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;
} = {}) => (
<SettingsContext.Provider value={settings}>
<AppContainer
config={config}
version={version}
initializationResult={initResult}
startupWarnings={startupWarnings}
resumedSessionData={resumedSessionData}
/>
</SettingsContext.Provider>
);
// Helper to render the AppContainer
const renderAppContainer = (props?: Parameters<typeof getAppContainer>[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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
startupWarnings={startupWarnings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={initResultWithError}
/>,
);
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(
<AppContainer
config={debugConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
renderAppContainer({ config: debugConfig });
}).not.toThrow();
});
});
describe('Context Providers', () => {
it('provides AppContext with correct values', async () => {
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="2.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={settingsAllHidden}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={settingsWithMemory}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version={version}
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={errorConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={undefinedSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
const { unmount } = renderAppContainer();
expect(() => unmount()).not.toThrow();
});
@@ -625,15 +562,13 @@ describe('AppContainer State Management', () => {
let unmount: () => void;
await act(async () => {
const result = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
resumedSessionData={mockResumedSessionData}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
resumedSessionData={undefined}
/>,
);
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(
<AppContainer
config={configWithRecording}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={configWithRecording}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={configWithRecording}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={configWithClient}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
resumedSessionData={resumedData}
/>,
);
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(
<AppContainer
config={configWithClient}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
resumedSessionData={resumedData}
/>,
);
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(
<AppContainer
config={configWithRecording}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithShowStatusFalse}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithHideTitleTrue}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithTitleEnabled}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithTitleEnabled}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithTitleEnabled}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithTitleEnabled}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithTitleEnabled}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithTitleEnabled}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
const { rerender, unmount } = renderAppContainer();
await act(async () => {
vi.advanceTimersByTime(0);
});
@@ -1384,40 +1237,19 @@ describe('AppContainer State Management', () => {
act(() => {
capturedUIActions.setQueueErrorMessage('Test error');
});
rerender(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
rerender(getAppContainer());
expect(capturedUIState.queueErrorMessage).toBe('Test error');
act(() => {
vi.advanceTimersByTime(3000);
});
rerender(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
rerender(getAppContainer());
expect(capturedUIState.queueErrorMessage).toBeNull();
unmount();
});
it('should reset the timer if a new error message is set', async () => {
const { rerender, unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
const { rerender, unmount } = renderAppContainer();
await act(async () => {
vi.advanceTimersByTime(0);
});
@@ -1425,14 +1257,7 @@ describe('AppContainer State Management', () => {
act(() => {
capturedUIActions.setQueueErrorMessage('First error');
});
rerender(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
rerender(getAppContainer());
expect(capturedUIState.queueErrorMessage).toBe('First error');
act(() => {
@@ -1442,41 +1267,20 @@ describe('AppContainer State Management', () => {
act(() => {
capturedUIActions.setQueueErrorMessage('Second error');
});
rerender(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
rerender(getAppContainer());
expect(capturedUIState.queueErrorMessage).toBe('Second error');
act(() => {
vi.advanceTimersByTime(2000);
});
rerender(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
rerender(getAppContainer());
expect(capturedUIState.queueErrorMessage).toBeNull();
unmount();
});
@@ -1502,14 +1306,7 @@ describe('AppContainer State Management', () => {
activePtyId: 'some-id',
});
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
const renderResult = renderAppContainer();
await act(async () => {
vi.advanceTimersByTime(0);
});
rerender = () =>
renderResult.rerender(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
rerender = () => renderResult.rerender(getAppContainer());
unmount = renderResult.unmount;
};
@@ -1719,27 +1501,13 @@ describe('AppContainer State Management', () => {
},
} as unknown as LoadedSettings;
const renderResult = render(
<AppContainer
config={mockConfig}
settings={testSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
const renderResult = renderAppContainer({ settings: testSettings });
await act(async () => {
vi.advanceTimersByTime(0);
});
rerender = () =>
renderResult.rerender(
<AppContainer
config={mockConfig}
settings={testSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
renderResult.rerender(getAppContainer({ settings: testSettings }));
unmount = renderResult.unmount;
};
@@ -1879,14 +1647,7 @@ describe('AppContainer State Management', () => {
closeModelDialog: vi.fn(),
});
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
// 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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
const { unmount } = renderAppContainer();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));

View File

@@ -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<string>('');
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,
],
);

View File

@@ -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<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getMCPServerStatus: vi.fn(),
};
});
vi.mock('../GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: () => <Text>Spinner</Text>,
}));
vi.mock('./messages/ToolMessage.js', () => ({
ToolMessage: (props: ToolMessageProps) => (
<Text>
ToolMessage: {props.name} - {props.status}
</Text>
),
}));
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(
<AlternateBufferQuittingDisplay />,
{
uiState: {
history: mockHistory,
pendingHistoryItems: mockPendingHistoryItems,
terminalWidth: 80,
mainAreaWidth: 80,
slashCommands: [],
activePtyId: undefined,
embeddedShellFocused: false,
renderMarkdown: false,
},
config: mockConfig,
},
);
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -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 (
<Box
flexDirection="column"
flexShrink={0}
flexGrow={0}
width={uiState.terminalWidth}
>
<AppHeader key="app-header" version={version} />
{uiState.history.map((h) => (
<HistoryItemDisplay
terminalWidth={uiState.mainAreaWidth}
availableTerminalHeight={undefined}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
/>
))}
{uiState.pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={undefined}
terminalWidth={uiState.mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={false}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
<QuittingDisplay />
</Box>
);
};

View File

@@ -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 (
<Box flexDirection="column" width={uiState.mainAreaWidth} flexShrink={0}>
<Box
flexDirection="column"
width={uiState.mainAreaWidth}
flexGrow={0}
flexShrink={0}
>
{!uiState.embeddedShellFocused && (
<LoadingIndicator
thought={
@@ -96,7 +108,8 @@ export const Composer = () => {
) : uiState.queueErrorMessage ? (
<Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>
) : (
!settings.merged.ui?.hideContextSummary && (
!settings.merged.ui?.hideContextSummary &&
!hideContextSummary && (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
geminiMdFileCount={uiState.geminiMdFileCount}
@@ -128,7 +141,7 @@ export const Composer = () => {
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
}
width={uiState.mainAreaWidth}
hasFocus={true}
hasFocus={uiState.showErrorDetails}
/>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
@@ -161,6 +174,8 @@ export const Composer = () => {
}
setQueueErrorMessage={uiActions.setQueueErrorMessage}
streamingState={uiState.streamingState}
suggestionsPosition={suggestionsPosition}
onSuggestionsVisibilityChange={setSuggestionsVisible}
/>
)}

View File

@@ -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 (
<Box>
<Text color={theme.status.warning}>
In Copy Mode. Press any key to exit.
</Text>
</Box>
);
};

View File

@@ -26,7 +26,7 @@ export const DetailedMessagesDisplay: React.FC<
> = ({ messages, maxHeight, width, hasFocus }) => {
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
const borderAndPadding = 4;
const borderAndPadding = 3;
const estimatedItemHeight = useCallback(
(index: number) => {

View File

@@ -55,17 +55,21 @@ describe('<HistoryItemDisplay />', () => {
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(
<HistoryItemDisplay {...baseItem} item={item} />,
);
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(
<HistoryItemDisplay {...baseItem} item={item} />,
{ useAlternateBuffer },
);
expect(lastFrame()).toMatchSnapshot();
},
);
it('renders StatsDisplay for "stats" type', () => {
const item: HistoryItem = {
@@ -203,83 +207,92 @@ describe('<HistoryItemDisplay />', () => {
);
});
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(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
/>,
);
it('should render a truncated gemini item', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini',
text: longCode,
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
/>,
{ 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(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
);
it('should render a full gemini item when using availableTerminalHeightGemini', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini',
text: longCode,
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
{ 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(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
/>,
);
it('should render a truncated gemini_content item', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_content',
text: longCode,
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
/>,
{ 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(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
);
it('should render a full gemini_content item when using availableTerminalHeightGemini', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_content',
text: longCode,
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
{ useAlternateBuffer },
);
expect(lastFrame()).toMatchSnapshot();
});
expect(lastFrame()).toMatchSnapshot();
});
},
);
});

View File

@@ -57,10 +57,10 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
return (
<Box flexDirection="column" key={itemForDisplay.id}>
<Box flexDirection="column" key={itemForDisplay.id} width={terminalWidth}>
{/* Render standard message types */}
{itemForDisplay.type === 'user' && (
<UserMessage text={itemForDisplay.text} />
<UserMessage text={itemForDisplay.text} width={terminalWidth} />
)}
{itemForDisplay.type === 'user_shell' && (
<UserShellMessage text={itemForDisplay.text} />

View File

@@ -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<InputPromptProps> = ({
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<InputPromptProps> = ({
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<InputPromptProps> = ({
statusText = 'Accepting edits';
}
const suggestionsNode = shouldShowSuggestions ? (
<Box paddingRight={2}>
<SuggestionsDisplay
suggestions={activeCompletion.suggestions}
activeIndex={activeCompletion.activeSuggestionIndex}
isLoading={activeCompletion.isLoadingSuggestions}
width={suggestionsWidth}
scrollOffset={activeCompletion.visibleStartIndex}
userInput={buffer.text}
mode={
buffer.text.startsWith('/') &&
!reverseSearchActive &&
!commandSearchActive
? 'slash'
: 'reverse'
}
expandedIndex={expandedSuggestionIndex}
/>
</Box>
) : null;
return (
<>
{suggestionsPosition === 'above' && suggestionsNode}
<Box
borderStyle="round"
borderColor={
@@ -1141,26 +1173,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
)}
</Box>
</Box>
{shouldShowSuggestions && (
<Box paddingRight={2}>
<SuggestionsDisplay
suggestions={activeCompletion.suggestions}
activeIndex={activeCompletion.activeSuggestionIndex}
isLoading={activeCompletion.isLoadingSuggestions}
width={suggestionsWidth}
scrollOffset={activeCompletion.visibleStartIndex}
userInput={buffer.text}
mode={
buffer.text.startsWith('/') &&
!reverseSearchActive &&
!commandSearchActive
? 'slash'
: 'reverse'
}
expandedIndex={expandedSuggestionIndex}
/>
</Box>
)}
{suggestionsPosition === 'below' && suggestionsNode}
</>
);
};

View File

@@ -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 = [
<AppHeader key="app-header" version={version} />,
...uiState.history.map((h) => (
<HistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
/>
)),
];
const historyItems = uiState.history.map((h) => (
<HistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
/>
));
const pendingItems = (
<OverflowProvider>
<Box flexDirection="column" width={mainAreaWidth}>
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>
const pendingItems = useMemo(
() => (
<OverflowProvider>
<Box flexDirection="column">
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>
),
[
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 <MemoizedAppHeader key="app-header" version={version} />;
} else if (item.type === 'history') {
return (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={item.item.id}
item={item.item}
isPending={false}
commands={uiState.slashCommands}
/>
);
} else {
return pendingItems;
}
},
[
version,
mainAreaWidth,
staticAreaMaxItemHeight,
uiState.slashCommands,
pendingItems,
],
);
if (isAlternateBuffer) {
return (
<Box
flexDirection="column"
overflowY="scroll"
scrollTop={Number.MAX_SAFE_INTEGER}
maxHeight={availableTerminalHeight}
>
<Box flexDirection="column" flexShrink={0}>
{historyItems}
{pendingItems}
</Box>
</Box>
<ScrollableList
hasFocus={!uiState.isEditorDialogOpen}
data={virtualizedData}
renderItem={renderItem}
estimatedItemHeight={() => 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 (
<>
<Static key={uiState.historyRemountKey} items={historyItems}>
<Static
key={uiState.historyRemountKey}
items={[
<AppHeader key="app-header" version={version} />,
...historyItems,
]}
>
{(item) => item}
</Static>
{pendingItems}

View File

@@ -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(
<StickyHeader width={80}>
<Text>Hello Sticky</Text>
</StickyHeader>,
);
expect(lastFrame()).toContain('Hello Sticky');
});
});

View File

@@ -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<StickyHeaderProps> = ({
children,
width,
}) => (
<Box
sticky
minHeight={1}
flexShrink={0}
width={width}
stickyChildren={
<Box
borderStyle="single"
width={width}
opaque
borderColor={theme.ui.dark}
borderTop={false}
borderLeft={false}
borderRight={false}
paddingX={1}
>
{children}
</Box>
}
>
<Box paddingX={1} width={width}>
{children}
</Box>
</Box>
);

View File

@@ -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<LoadableSettingScope>(
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,
})}
<Box marginTop={1} />
<DiffRenderer
diffContent={`--- a/util.py
@@ -262,7 +266,9 @@ def fibonacci(n):
- print("Hello, " + name)
+ print(f"Hello, {name}!")
`}
availableTerminalHeight={diffHeight}
availableTerminalHeight={
isAlternateBuffer === false ? diffHeight : undefined
}
terminalWidth={colorizeCodeWidth}
theme={previewTheme}
/>

View File

@@ -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 │
╰────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -1,144 +1,367 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<HistoryItemDisplay /> > renders InfoMessage for "info" type with multi-line text 1`] = `
exports[`<HistoryItemDisplay /> > 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[`<HistoryItemDisplay /> > 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[`<HistoryItemDisplay /> > 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[`<HistoryItemDisplay /> > 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[`<HistoryItemDisplay /> > 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[`<HistoryItemDisplay /> > 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[`<HistoryItemDisplay /> > 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[`<HistoryItemDisplay /> > 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[`<HistoryItemDisplay /> > renders InfoMessage for "info" type with multi-line text (alternateBuffer=false) 1`] = `
"
⚡ Line 1
⚡ Line 2
⚡ Line 3"
`;
exports[`<HistoryItemDisplay /> > 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[`<HistoryItemDisplay /> > 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[`<HistoryItemDisplay /> > 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[`<HistoryItemDisplay /> > 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[`<HistoryItemDisplay /> > renders InfoMessage for "info" type with multi-line text (alternateBuffer=true) 1`] = `
"
Line 1
Line 2
Line 3"
`;

View File

@@ -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('<OverflowProvider><DiffRenderer /></OverflowProvider>', () => {
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(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiffContent}
filename="test.py"
terminalWidth={80}
/>
</OverflowProvider>,
);
expect(mockColorizeCode).toHaveBeenCalledWith(
'print("hello world")',
'python',
undefined,
80,
undefined,
);
});
renderWithProviders(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiffContent}
filename="test.py"
terminalWidth={80}
/>
</OverflowProvider>,
{ 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(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiffContent}
filename="test.unknown"
terminalWidth={80}
/>
</OverflowProvider>,
);
expect(mockColorizeCode).toHaveBeenCalledWith(
'some content',
null,
undefined,
80,
undefined,
);
});
renderWithProviders(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiffContent}
filename="test.unknown"
terminalWidth={80}
/>
</OverflowProvider>,
{ 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(
<OverflowProvider>
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
</OverflowProvider>,
);
expect(mockColorizeCode).toHaveBeenCalledWith(
'some text content',
null,
undefined,
80,
undefined,
);
});
renderWithProviders(
<OverflowProvider>
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
</OverflowProvider>,
{ 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(
<OverflowProvider>
<DiffRenderer
diffContent={existingFileDiffContent}
filename="test.txt"
terminalWidth={80}
/>
</OverflowProvider>,
);
// 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(
<OverflowProvider>
<DiffRenderer
diffContent={existingFileDiffContent}
filename="test.txt"
terminalWidth={80}
/>
</OverflowProvider>,
{ 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(
<OverflowProvider>
<DiffRenderer
diffContent={noChangeDiff}
filename="file.txt"
terminalWidth={80}
/>
</OverflowProvider>,
);
expect(lastFrame()).toContain('No changes detected');
expect(mockColorizeCode).not.toHaveBeenCalled();
});
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<DiffRenderer
diffContent={noChangeDiff}
filename="file.txt"
terminalWidth={80}
/>
</OverflowProvider>,
{ useAlternateBuffer },
);
expect(lastFrame()).toMatchSnapshot();
expect(mockColorizeCode).not.toHaveBeenCalled();
});
it('should handle empty diff content', () => {
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer diffContent="" terminalWidth={80} />
</OverflowProvider>,
);
expect(lastFrame()).toContain('No diff content');
expect(mockColorizeCode).not.toHaveBeenCalled();
});
it('should handle empty diff content', () => {
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<DiffRenderer diffContent="" terminalWidth={80} />
</OverflowProvider>,
{ 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(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithGap}
filename="file.txt"
terminalWidth={80}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(output).toContain('═'); // Check for the border character used in the gap
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithGap}
filename="file.txt"
terminalWidth={80}
/>
</OverflowProvider>,
{ 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(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithSmallGap}
filename="file.txt"
terminalWidth={80}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(output).not.toContain('═'); // Ensure no separator is rendered
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithSmallGap}
filename="file.txt"
terminalWidth={80}
/>
</OverflowProvider>,
{ 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(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithMultipleHunks}
filename="multi.js"
terminalWidth={terminalWidth}
availableTerminalHeight={height}
/>
</OverflowProvider>,
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(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithMultipleHunks}
filename="multi.js"
terminalWidth={terminalWidth}
availableTerminalHeight={height}
/>
</OverflowProvider>,
{ 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(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiff}
filename="TEST"
terminalWidth={80}
/>
</OverflowProvider>,
);
const output = lastFrame();
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiff}
filename="TEST"
terminalWidth={80}
/>
</OverflowProvider>,
{ 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(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiff}
filename="Dockerfile"
terminalWidth={80}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(output).toEqual(`1 FROM node:14
2 RUN npm install
3 RUN npm run build`);
});
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<DiffRenderer
diffContent={newFileDiff}
filename="Dockerfile"
terminalWidth={80}
/>
</OverflowProvider>,
{ useAlternateBuffer },
);
expect(lastFrame()).toMatchSnapshot();
});
},
);
});

View File

@@ -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<DiffRendererProps> = ({
terminalWidth,
theme,
}) => {
const settings = useSettings();
const isAlternateBuffer = useAlternateBuffer();
const screenReaderEnabled = useIsScreenReaderEnabled();
if (!diffContent || typeof diffContent !== 'string') {
return <Text color={semanticTheme.status.warning}>No diff content.</Text>;
}
const parsedLines = parseDiffWithLineNumbers(diffContent);
const parsedLines = useMemo(() => {
if (!diffContent || typeof diffContent !== 'string') {
return [];
}
return parseDiffWithLineNumbers(diffContent);
}, [diffContent]);
if (parsedLines.length === 0) {
return (
<Box
borderStyle="round"
borderColor={semanticTheme.border.default}
padding={1}
>
<Text dimColor>No changes detected.</Text>
</Box>
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 (
<Box flexDirection="column">
{parsedLines.map((line, index) => (
<Text key={index}>
{line.type}: {line.content}
</Text>
))}
</Box>
);
}
}, [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 <Text color={semanticTheme.status.warning}>No diff content.</Text>;
}
let renderedOutput;
if (parsedLines.length === 0) {
return (
<Box
borderStyle="round"
borderColor={semanticTheme.border.default}
padding={1}
>
<Text dimColor>No changes detected.</Text>
</Box>
);
}
if (screenReaderEnabled) {
return (
<Box flexDirection="column">
{parsedLines.map((line, index) => (
<Text key={index}>
{line.type}: {line.content}
</Text>
))}
</Box>
);
}
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 (
<MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={terminalWidth}
key={key}
>
{displayableLines.reduce<React.ReactNode[]>((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<React.ReactNode[]>(
(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(
<Box key={`gap-${index}`}>
if (
lastLineNumber !== null &&
relevantLineNumberForGapCalc !== null &&
relevantLineNumberForGapCalc >
lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1
) {
acc.push(
<Box key={`gap-${index}`}>
{useMaxSizedBox ? (
<Text wrap="truncate" color={semanticTheme.text.secondary}>
{'═'.repeat(terminalWidth)}
</Text>
</Box>,
);
}
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(
<Box key={lineKey} flexDirection="row">
<Text
color={semanticTheme.text.secondary}
backgroundColor={
line.type === 'add'
? semanticTheme.background.diff.added
: line.type === 'del'
? semanticTheme.background.diff.removed
: undefined
}
>
{gutterNumStr.padStart(gutterWidth)}{' '}
</Text>
{line.type === 'context' ? (
<>
<Text>{prefixSymbol} </Text>
<Text wrap="wrap">
{colorizeLine(displayContent, language)}
</Text>
</>
) : (
<Text
backgroundColor={
line.type === 'add'
? semanticTheme.background.diff.added
: semanticTheme.background.diff.removed
}
wrap="wrap"
>
<Text
color={
line.type === 'add'
? semanticTheme.status.success
: semanticTheme.status.error
}
>
{prefixSymbol}
</Text>{' '}
{colorizeLine(displayContent, language)}
</Text>
// We can use a proper separator when not using max sized box.
<Box
borderStyle="double"
borderLeft={false}
borderRight={false}
borderBottom={false}
width={terminalWidth}
borderColor={semanticTheme.text.secondary}
marginRight={1}
></Box>
)}
</Box>,
);
return acc;
}, [])}
</MaxSizedBox>
}
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(
<Box key={lineKey} flexDirection="row">
{useMaxSizedBox ? (
<Text
color={semanticTheme.text.secondary}
backgroundColor={backgroundColor}
>
{gutterNumStr.padStart(gutterWidth)}{' '}
</Text>
) : (
<Box
width={gutterWidth + 1}
paddingRight={1}
flexShrink={0}
backgroundColor={backgroundColor}
justifyContent="flex-end"
>
<Text color={semanticTheme.text.secondary}>{gutterNumStr}</Text>
</Box>
)}
{line.type === 'context' ? (
<>
<Text>{prefixSymbol} </Text>
<Text wrap="wrap">{colorizeLine(displayContent, language)}</Text>
</>
) : (
<Text
backgroundColor={
line.type === 'add'
? semanticTheme.background.diff.added
: semanticTheme.background.diff.removed
}
wrap="wrap"
>
<Text
color={
line.type === 'add'
? semanticTheme.status.success
: semanticTheme.status.error
}
>
{prefixSymbol}
</Text>{' '}
{colorizeLine(displayContent, language)}
</Text>
)}
</Box>,
);
return acc;
},
[],
);
if (useMaxSizedBox) {
return (
<MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={terminalWidth}
key={key}
>
{content}
</MaxSizedBox>
);
}
return (
<Box key={key} flexDirection="column" width={terminalWidth} flexShrink={0}>
{content}
</Box>
);
};

View File

@@ -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<GeminiMessageProps> = ({
const prefix = '✦ ';
const prefixWidth = prefix.length;
const isAlternateBuffer = useAlternateBuffer();
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
@@ -39,7 +41,9 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
availableTerminalHeight={
isAlternateBuffer ? undefined : availableTerminalHeight
}
terminalWidth={terminalWidth}
renderMarkdown={renderMarkdown}
/>

View File

@@ -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<GeminiMessageContentProps> = ({
terminalWidth,
}) => {
const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
@@ -37,7 +39,9 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
availableTerminalHeight={
isAlternateBuffer ? undefined : availableTerminalHeight
}
terminalWidth={terminalWidth}
renderMarkdown={renderMarkdown}
/>

View File

@@ -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<IdeClient | null>(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<RadioSelectItem<ToolConfirmationOutcome>> = [];
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = new Array<
RadioSelectItem<ToolConfirmationOutcome>
>();
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 = (
<DiffRenderer
diffContent={confirmationDetails.fileDiff}
filename={confirmationDetails.fileName}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={terminalWidth}
/>
);
}
} 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 = (
<Box>
<Text color={theme.text.link}>{executionProps.command}</Text>
</Box>
);
bodyContent = (
<Box flexDirection="column">
<Box paddingX={1}>
{isAlternateBuffer ? (
commandBox
) : (
<MaxSizedBox
maxHeight={bodyContentHeight}
maxWidth={Math.max(childWidth, 1)}
>
{commandBox}
</MaxSizedBox>
)}
</Box>
</Box>
);
} else if (confirmationDetails.type === 'info') {
const infoProps = confirmationDetails;
const displayUrls =
infoProps.urls &&
!(
infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt
);
bodyContent = (
<Box flexDirection="column" paddingX={1}>
<Text color={theme.text.link}>
<RenderInline
text={infoProps.prompt}
defaultColor={theme.text.link}
/>
</Text>
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>URLs to fetch:</Text>
{infoProps.urls.map((url) => (
<Text key={url}>
{' '}
- <RenderInline text={url} />
</Text>
))}
</Box>
)}
</Box>
);
} else {
// mcp tool confirmation
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
bodyContent = (
<Box flexDirection="column" paddingX={1}>
<Text color={theme.text.link}>MCP Server: {mcpProps.serverName}</Text>
<Text color={theme.text.link}>Tool: {mcpProps.toolName}</Text>
</Box>
);
}
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<
</Box>
);
}
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 = (
<DiffRenderer
diffContent={confirmationDetails.fileDiff}
filename={confirmationDetails.fileName}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={childWidth}
/>
);
} 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 = (
<Box flexDirection="column">
<Box paddingX={1} marginLeft={1}>
<MaxSizedBox
maxHeight={bodyContentHeight}
maxWidth={Math.max(childWidth - 4, 1)}
>
<Box>
<Text color={theme.text.link}>{executionProps.command}</Text>
</Box>
</MaxSizedBox>
</Box>
</Box>
);
} 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 = (
<Box flexDirection="column" paddingX={1} marginLeft={1}>
<RenderInline text={infoProps.prompt} defaultColor={theme.text.link} />
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>URLs to fetch:</Text>
{infoProps.urls.map((url) => (
<Text key={url}>
{' '}
- <RenderInline text={url} />
</Text>
))}
</Box>
)}
</Box>
);
} else {
// mcp tool confirmation
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}>
<Text color={theme.text.link}>MCP Server: {mcpProps.serverName}</Text>
<Text color={theme.text.link}>Tool: {mcpProps.toolName}</Text>
</Box>
);
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 (
<Box flexDirection="column" padding={1} width={childWidth}>
<Box flexDirection="column" paddingTop={0} paddingBottom={1}>
{/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */}
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
<Box
flexGrow={1}
flexShrink={1}
overflow="hidden"
marginBottom={1}
paddingLeft={1}
>
{bodyContent}
</Box>
{/* Confirmation Question */}
<Box marginBottom={1} flexShrink={0}>
<Text color={theme.text.primary} wrap="truncate">
{question}
</Text>
<Box marginBottom={1} flexShrink={0} paddingX={1}>
<Text color={theme.text.primary}>{question}</Text>
</Box>
{/* Select Input for Options */}
<Box flexShrink={0}>
<Box flexShrink={0} paddingX={1}>
<RadioButtonSelect
items={options}
onSelect={handleSelect}

View File

@@ -4,19 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
import type React from 'react';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import type {
Config,
ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
import type { ToolCallConfirmationDetails } from '@google/gemini-cli-core';
import { TOOL_STATUS } from '../../constants.js';
import { ConfigContext } from '../../contexts/ConfigContext.js';
// Mock child components to isolate ToolGroupMessage behavior
vi.mock('./ToolMessage.js', () => ({
@@ -66,8 +61,6 @@ vi.mock('./ToolConfirmationMessage.js', () => ({
}));
describe('<ToolGroupMessage />', () => {
const mockConfig: Config = {} as Config;
const createToolCall = (
overrides: Partial<IndividualToolCallDisplay> = {},
): IndividualToolCallDisplay => ({
@@ -87,14 +80,6 @@ describe('<ToolGroupMessage />', () => {
isFocused: true,
};
// Helper to wrap component with required providers
const renderWithProviders = (component: React.ReactElement) =>
render(
<ConfigContext.Provider value={mockConfig}>
{component}
</ConfigContext.Provider>,
);
describe('Golden Snapshots', () => {
it('renders single successful tool call', () => {
const toolCalls = [createToolCall()];

View File

@@ -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<ToolGroupMessageProps> = ({
);
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<ToolGroupMessageProps> = ({
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<ToolGroupMessageProps> = ({
{toolCalls.map((tool) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
return (
<Box key={tool.callId} flexDirection="column" minHeight={1}>
<Box flexDirection="row" alignItems="center">
<ToolMessage
{...tool}
availableTerminalHeight={availableTerminalHeightPerToolMessage}
terminalWidth={innerWidth}
emphasis={
isConfirming
? 'high'
: toolAwaitingApproval
? 'low'
: 'medium'
}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
config={config}
/>
</Box>
<Box
key={tool.callId}
flexDirection="column"
minHeight={1}
width={innerWidth}
>
<ToolMessage
{...tool}
availableTerminalHeight={availableTerminalHeightPerToolMessage}
terminalWidth={innerWidth}
emphasis={
isConfirming ? 'high' : toolAwaitingApproval ? 'low' : 'medium'
}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
config={config}
/>
{tool.status === ToolCallStatus.Confirming &&
isConfirming &&
tool.confirmationDetails && (

View File

@@ -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<ToolMessageProps> = ({
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<ToolMessageProps> = ({
: 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 (
<Box width={terminalWidth} flexDirection="column" paddingLeft={1}>
<Box flexDirection="column">
{typeof truncatedResultDisplay === 'string' &&
renderOutputAsMarkdown ? (
<Box flexDirection="column">
<MarkdownDisplay
text={truncatedResultDisplay}
terminalWidth={childWidth}
renderMarkdown={renderMarkdown}
isPending={false}
/>
</Box>
) : typeof truncatedResultDisplay === 'string' &&
!renderOutputAsMarkdown ? (
isAlternateBuffer ? (
<Box flexDirection="column" width={childWidth}>
<Text wrap="wrap" color={theme.text.primary}>
{truncatedResultDisplay}
</Text>
</Box>
) : (
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
<Box>
<Text wrap="wrap" color={theme.text.primary}>
{truncatedResultDisplay}
</Text>
</Box>
</MaxSizedBox>
)
) : typeof truncatedResultDisplay === 'object' &&
'fileDiff' in truncatedResultDisplay ? (
<DiffRenderer
diffContent={truncatedResultDisplay.fileDiff}
filename={truncatedResultDisplay.fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
) : typeof truncatedResultDisplay === 'object' &&
'todos' in truncatedResultDisplay ? (
// display nothing, as the TodoTray will handle rendering todos
<></>
) : (
<AnsiOutputText
data={truncatedResultDisplay as AnsiOutput}
availableTerminalHeight={availableHeight}
width={childWidth}
/>
)}
</Box>
</Box>
);
}, [
truncatedResultDisplay,
renderOutputAsMarkdown,
childWidth,
renderMarkdown,
isAlternateBuffer,
availableHeight,
terminalWidth,
]);
return (
<Box paddingX={1} paddingY={0} flexDirection="column">
<Box minHeight={1}>
// 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.
<>
<StickyHeader width={terminalWidth + 1}>
<ToolStatusIndicator status={status} name={name} />
<ToolInfo
name={name}
@@ -140,50 +213,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
</Box>
)}
{emphasis === 'high' && <TrailingIndicator />}
</Box>
{resultDisplay && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
<Box flexDirection="column">
{typeof resultDisplay === 'string' && renderOutputAsMarkdown ? (
<Box flexDirection="column">
<MarkdownDisplay
text={resultDisplay}
isPending={false}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
renderMarkdown={renderMarkdown}
/>
</Box>
) : typeof resultDisplay === 'string' && !renderOutputAsMarkdown ? (
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
<Box>
<Text wrap="wrap" color={theme.text.primary}>
{resultDisplay}
</Text>
</Box>
</MaxSizedBox>
) : typeof resultDisplay === 'object' &&
'fileDiff' in resultDisplay ? (
<DiffRenderer
diffContent={resultDisplay.fileDiff}
filename={resultDisplay.fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
) : typeof resultDisplay === 'object' &&
'todos' in resultDisplay ? (
// display nothing, as the TodoTray will handle rendering todos
<></>
) : (
<AnsiOutputText
data={resultDisplay as AnsiOutput}
availableTerminalHeight={availableHeight}
width={childWidth}
/>
)}
</Box>
</Box>
)}
</StickyHeader>
{renderedResult}
{isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
<ShellInputPrompt
@@ -192,7 +223,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
/>
</Box>
)}
</Box>
</>
);
};
@@ -271,10 +302,7 @@ const ToolInfo: React.FC<ToolInfo> = ({
}, [emphasis]);
return (
<Box>
<Text
wrap="truncate-end"
strikethrough={status === ToolCallStatus.Canceled}
>
<Text strikethrough={status === ToolCallStatus.Canceled}>
<Text color={nameColor} bold>
{name}
</Text>{' '}

View File

@@ -23,20 +23,52 @@ describe('<ToolMessage /> - 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(
<StreamingContext.Provider value={StreamingState.Idle}>
<ToolMessage {...baseProps} />
<ToolMessage
{...baseProps}
availableTerminalHeight={availableTerminalHeight}
/>
</StreamingContext.Provider>,
{
uiState: { renderMarkdown, streamingState: StreamingState.Idle },
useAlternateBuffer,
},
);
expect(lastFrame()).toMatchSnapshot();

View File

@@ -12,9 +12,10 @@ import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.
interface UserMessageProps {
text: string;
width: number;
}
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
const prefix = '> ';
const prefixWidth = prefix.length;
const isSlashCommand = checkIsSlashCommand(text);
@@ -22,8 +23,14 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary;
return (
<Box flexDirection="row" paddingY={0} marginY={1} alignSelf="flex-start">
<Box width={prefixWidth}>
<Box
flexDirection="row"
paddingY={0}
marginY={1}
alignSelf="flex-start"
width={width}
>
<Box width={prefixWidth} flexShrink={0}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_USER_PREFIX}>
{prefix}
</Text>

View File

@@ -0,0 +1,175 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should handle diff with only header and no changes 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ No changes detected. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should handle empty diff content 1`] = `"No diff content."`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should handle diff with only header and no changes 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ No changes detected. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should handle empty diff content 1`] = `"No diff content."`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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[`<OverflowProvider><DiffRenderer /></OverflowProvider> > 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"
`;

View File

@@ -91,9 +91,10 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal
exports[`<ToolGroupMessage /> > 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) │
╰──────────────────────────────────────╯"
`;

View File

@@ -1,13 +1,31 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false '(raw markdown with syntax highlightin…' 1`] = `
exports[`<ToolMessage /> - 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[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true '(default)' 1`] = `
exports[`<ToolMessage /> - 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[`<ToolMessage /> - 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[`<ToolMessage /> - 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[`<ToolMessage /> - 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[`<ToolMessage /> - 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"
`;

View File

@@ -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<typeof import('ink')>();
@@ -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<Item> | null = null;
const StickyTestComponent = () => {
const items = Array.from({ length: 100 }, (_, i) => ({
id: String(i),
title: `Item ${i + 1}`,
}));
const ref = useRef<ScrollableListRef<Item>>(null);
useEffect(() => {
listRef = ref.current;
}, []);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={10}>
<ScrollableList
ref={ref}
data={items}
renderItem={({ item, index }) => (
<Box flexDirection="column" height={3}>
{index === 0 ? (
<Box
sticky
stickyChildren={<Text>[STICKY] {item.title}</Text>}
>
<Text>[Normal] {item.title}</Text>
</Box>
) : (
<Text>[Normal] {item.title}</Text>
)}
<Text>Content for {item.title}</Text>
<Text>More content for {item.title}</Text>
</Box>
)}
estimatedItemHeight={() => 3}
keyExtractor={(item) => item.id}
hasFocus={true}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let lastFrame: () => string | undefined;
await act(async () => {
const result = render(<StickyTestComponent />);
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');
});
});

View File

@@ -481,6 +481,7 @@ function VirtualizedList<T>(
width="100%"
height="100%"
flexDirection="column"
paddingRight={1}
>
<Box flexShrink={0} width="100%" flexDirection="column">
<Box height={topSpacerHeight} flexShrink={0} />

View File

@@ -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: '✓',

View File

@@ -123,6 +123,7 @@ export interface UIState {
embeddedShellFocused: boolean;
showDebugProfiler: boolean;
showFullTodos: boolean;
copyModeEnabled: boolean;
}
export const UIStateContext = createContext<UIState | null>(null);

View File

@@ -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;
};

View File

@@ -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 ---

View File

@@ -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 (
<Box
flexDirection="column"
width={uiState.mainAreaWidth}
ref={uiState.rootUiRef}
height={
settings.merged.ui?.useAlternateBuffer ? terminalHeight - 1 : undefined
}
width={width}
height={isAlternateBuffer ? terminalHeight - 1 : undefined}
flexShrink={0}
flexGrow={0}
overflow="hidden"
ref={uiState.rootUiRef}
>
<MainContent />
<Box flexDirection="column" ref={uiState.mainControlsRef}>
<Box
flexDirection="column"
ref={uiState.mainControlsRef}
flexShrink={0}
flexGrow={0}
>
<Notifications />
<CopyModeWarning />
{uiState.dialogsVisible ? (
<DialogManager

View File

@@ -119,28 +119,38 @@ export function colorizeLine(
return highlightAndRenderLine(line, language, activeTheme);
}
export interface ColorizeCodeOptions {
code: string;
language?: string | null;
availableHeight?: number;
maxWidth: number;
theme?: Theme | null;
settings: LoadedSettings;
hideLineNumbers?: boolean;
}
/**
* Renders syntax-highlighted code for Ink applications using a selected theme.
*
* @param code The code string to highlight.
* @param language The language identifier (e.g., 'javascript', 'css', 'html')
* @param options The options for colorizing the code.
* @returns A React.ReactNode containing Ink <Text> 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 (
<MaxSizedBox
maxHeight={availableHeight}
maxWidth={maxWidth}
additionalHiddenLinesCount={hiddenLinesCount}
overflowDirection="top"
>
{lines.map((line, index) => {
const contentToRender = highlightAndRenderLine(
line,
language,
activeTheme,
);
const renderedLines = lines.map((line, index) => {
const contentToRender = highlightAndRenderLine(
line,
language,
activeTheme,
);
return (
<Box key={index}>
{showLineNumbers && (
<Text color={activeTheme.colors.Gray}>
{`${String(index + 1 + hiddenLinesCount).padStart(
padWidth,
' ',
)} `}
</Text>
)}
<Text color={activeTheme.defaultColor} wrap="wrap">
{contentToRender}
return (
<Box key={index}>
{/* We have to render line numbers differently depending on whether we are using MaxSizeBox or not */}
{showLineNumbers && useMaxSizedBox && (
<Text color={activeTheme.colors.Gray}>
{`${String(index + 1 + hiddenLinesCount).padStart(
padWidth,
' ',
)} `}
</Text>
)}
{showLineNumbers && !useMaxSizedBox && (
<Box
minWidth={padWidth + 1}
flexShrink={0}
paddingRight={1}
alignItems="flex-start"
justifyContent="flex-end"
>
<Text color={activeTheme.colors.Gray}>
{`${index + 1 + hiddenLinesCount}`}
</Text>
</Box>
);
})}
</MaxSizedBox>
)}
<Text color={activeTheme.defaultColor} wrap="wrap">
{contentToRender}
</Text>
</Box>
);
});
if (useMaxSizedBox) {
return (
<MaxSizedBox
maxHeight={availableHeight}
maxWidth={maxWidth}
additionalHiddenLinesCount={hiddenLinesCount}
overflowDirection="top"
>
{renderedLines}
</MaxSizedBox>
);
}
return (
<Box flexDirection="column" width={maxWidth}>
{renderedLines}
</Box>
);
} 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 (
<MaxSizedBox
maxHeight={availableHeight}
maxWidth={maxWidth}
overflowDirection="top"
>
{lines.map((line, index) => (
<Box key={index}>
{showLineNumbers && (
<Text color={activeTheme.defaultColor}>
{`${String(index + 1).padStart(padWidth, ' ')} `}
</Text>
)}
<Text color={activeTheme.colors.Gray}>{line}</Text>
const fallbackLines = lines.map((line, index) => (
<Box key={index}>
{/* We have to render line numbers differently depending on whether we are using MaxSizeBox or not */}
{showLineNumbers && useMaxSizedBox && (
<Text color={activeTheme.defaultColor}>
{`${String(index + 1).padStart(padWidth, ' ')} `}
</Text>
)}
{showLineNumbers && !useMaxSizedBox && (
<Box
minWidth={padWidth + 1}
flexShrink={0}
paddingRight={1}
alignItems="flex-start"
justifyContent="flex-end"
>
<Text color={activeTheme.defaultColor}>{`${index + 1}`}</Text>
</Box>
))}
</MaxSizedBox>
)}
<Text color={activeTheme.colors.Gray}>{line}</Text>
</Box>
));
if (useMaxSizedBox) {
return (
<MaxSizedBox
maxHeight={availableHeight}
maxWidth={maxWidth}
overflowDirection="top"
>
{fallbackLines}
</MaxSizedBox>
);
}
return (
<Box flexDirection="column" width={maxWidth}>
{fallbackLines}
</Box>
);
}
}

View File

@@ -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<MarkdownDisplayProps> = ({
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<MarkdownDisplayProps> = ({
// 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 (
<Box paddingLeft={CODE_BLOCK_PREFIX_PADDING} flexDirection="column">
{colorizedMarkdown}
@@ -100,7 +101,9 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
content={codeBlockContent}
lang={codeBlockLang}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
availableTerminalHeight={
isAlternateBuffer ? undefined : availableTerminalHeight
}
terminalWidth={terminalWidth}
/>,
);
@@ -288,7 +291,9 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
content={codeBlockContent}
lang={codeBlockLang}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
availableTerminalHeight={
isAlternateBuffer ? undefined : availableTerminalHeight
}
terminalWidth={terminalWidth}
/>,
);
@@ -327,10 +332,17 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
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<RenderCodeBlockProps> = ({
);
}
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 (
<Box paddingLeft={CODE_BLOCK_PREFIX_PADDING} flexDirection="column">
{colorizedTruncatedCode}
@@ -366,14 +377,13 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
}
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 (
<Box

View File

@@ -25,7 +25,12 @@ const getMainAreaWidthInternal = (terminalWidth: number): number => {
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);
};