mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 19:14:33 -07:00
feat(cli): improve CTRL+O experience for both standard and alternate screen buffer (ASB) modes (#19010)
Co-authored-by: jacob314 <jacob314@gmail.com>
This commit is contained in:
+14
-11
@@ -102,6 +102,7 @@ import { createPolicyUpdater } from './config/policy.js';
|
||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
|
||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
@@ -238,17 +239,19 @@ export async function startInteractiveUI(
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
<OverflowProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</OverflowProvider>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
|
||||
@@ -35,6 +35,13 @@ import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
|
||||
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
|
||||
import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js';
|
||||
import { TerminalProvider } from '../ui/contexts/TerminalContext.js';
|
||||
import {
|
||||
OverflowProvider,
|
||||
useOverflowActions,
|
||||
useOverflowState,
|
||||
type OverflowActions,
|
||||
type OverflowState,
|
||||
} from '../ui/contexts/OverflowContext.js';
|
||||
|
||||
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
|
||||
import { FakePersistentState } from './persistentStateFake.js';
|
||||
@@ -335,6 +342,8 @@ export type RenderInstance = {
|
||||
lastFrame: (options?: { allowEmpty?: boolean }) => string;
|
||||
terminal: Terminal;
|
||||
waitUntilReady: () => Promise<void>;
|
||||
capturedOverflowState: OverflowState | undefined;
|
||||
capturedOverflowActions: OverflowActions | undefined;
|
||||
};
|
||||
|
||||
const instances: InkInstance[] = [];
|
||||
@@ -343,7 +352,10 @@ const instances: InkInstance[] = [];
|
||||
export const render = (
|
||||
tree: React.ReactElement,
|
||||
terminalWidth?: number,
|
||||
): RenderInstance => {
|
||||
): Omit<
|
||||
RenderInstance,
|
||||
'capturedOverflowState' | 'capturedOverflowActions'
|
||||
> => {
|
||||
const cols = terminalWidth ?? 100;
|
||||
// We use 1000 rows to avoid windows with incorrect snapshots if a correct
|
||||
// value was used (e.g. 40 rows). The alternatives to make things worse are
|
||||
@@ -562,6 +574,16 @@ const mockUIActions: UIActions = {
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
};
|
||||
|
||||
let capturedOverflowState: OverflowState | undefined;
|
||||
let capturedOverflowActions: OverflowActions | undefined;
|
||||
const ContextCapture: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
capturedOverflowState = useOverflowState();
|
||||
capturedOverflowActions = useOverflowActions();
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export const renderWithProviders = (
|
||||
component: React.ReactElement,
|
||||
{
|
||||
@@ -663,6 +685,9 @@ export const renderWithProviders = (
|
||||
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
|
||||
.flatMap((item) => item.tools);
|
||||
|
||||
capturedOverflowState = undefined;
|
||||
capturedOverflowActions = undefined;
|
||||
|
||||
const renderResult = render(
|
||||
<AppContext.Provider value={appState}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
@@ -675,35 +700,39 @@ export const renderWithProviders = (
|
||||
value={finalUiState.streamingState}
|
||||
>
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
<OverflowProvider>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<ContextCapture>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ContextCapture>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</OverflowProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
</SessionStatsProvider>
|
||||
@@ -718,6 +747,8 @@ export const renderWithProviders = (
|
||||
|
||||
return {
|
||||
...renderResult,
|
||||
capturedOverflowState,
|
||||
capturedOverflowActions,
|
||||
simulateClick: (col: number, row: number, button?: 0 | 1 | 2) =>
|
||||
simulateClick(renderResult.stdin, col, row, button),
|
||||
};
|
||||
|
||||
@@ -105,6 +105,11 @@ import {
|
||||
type UIActions,
|
||||
} from './contexts/UIActionsContext.js';
|
||||
import { KeypressProvider } from './contexts/KeypressContext.js';
|
||||
import { OverflowProvider } from './contexts/OverflowContext.js';
|
||||
import {
|
||||
useOverflowActions,
|
||||
type OverflowActions,
|
||||
} from './contexts/OverflowContext.js';
|
||||
|
||||
// Mock useStdout to capture terminal title writes
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
@@ -120,9 +125,11 @@ vi.mock('ink', async (importOriginal) => {
|
||||
// so we can assert against them in our tests.
|
||||
let capturedUIState: UIState;
|
||||
let capturedUIActions: UIActions;
|
||||
let capturedOverflowActions: OverflowActions;
|
||||
function TestContextConsumer() {
|
||||
capturedUIState = useContext(UIStateContext)!;
|
||||
capturedUIActions = useContext(UIActionsContext)!;
|
||||
capturedOverflowActions = useOverflowActions()!;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -229,7 +236,10 @@ import {
|
||||
disableMouseEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type ExtensionManager } from '../config/extension-manager.js';
|
||||
import { WARNING_PROMPT_DURATION_MS } from './constants.js';
|
||||
import {
|
||||
WARNING_PROMPT_DURATION_MS,
|
||||
EXPAND_HINT_DURATION_MS,
|
||||
} from './constants.js';
|
||||
|
||||
describe('AppContainer State Management', () => {
|
||||
let mockConfig: Config;
|
||||
@@ -255,13 +265,15 @@ describe('AppContainer State Management', () => {
|
||||
} = {}) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider config={config}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
<OverflowProvider>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
</OverflowProvider>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
@@ -2687,12 +2699,14 @@ describe('AppContainer State Management', () => {
|
||||
const getTree = (settings: LoadedSettings) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider config={mockConfig}>
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>
|
||||
<TestChild />
|
||||
<OverflowProvider>
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>
|
||||
<TestChild />
|
||||
</OverflowProvider>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
@@ -3303,6 +3317,306 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Submission Handling', () => {
|
||||
it('resets expansion state on submission when not in alternate buffer', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue([]);
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
unmount = renderAppContainer({
|
||||
settings: {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
ui: { ...mockSettings.merged.ui, useAlternateBuffer: false },
|
||||
},
|
||||
} as LoadedSettings,
|
||||
}).unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(capturedUIActions).toBeTruthy());
|
||||
|
||||
// Expand first
|
||||
act(() => capturedUIActions.setConstrainHeight(false));
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// Reset mock stdout to clear any initial writes
|
||||
mocks.mockStdout.write.mockClear();
|
||||
|
||||
// Submit
|
||||
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
|
||||
|
||||
// Should be reset
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
// Should refresh static (which clears terminal in non-alternate buffer)
|
||||
expect(mocks.mockStdout.write).toHaveBeenCalledWith(
|
||||
ansiEscapes.clearTerminal,
|
||||
);
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue([]);
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
unmount = renderAppContainer({
|
||||
settings: {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
ui: { ...mockSettings.merged.ui, useAlternateBuffer: true },
|
||||
},
|
||||
} as LoadedSettings,
|
||||
}).unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(capturedUIActions).toBeTruthy());
|
||||
|
||||
// Expand first
|
||||
act(() => capturedUIActions.setConstrainHeight(false));
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// Reset mock stdout
|
||||
mocks.mockStdout.write.mockClear();
|
||||
|
||||
// Submit
|
||||
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
|
||||
|
||||
// Should be reset
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
// Should NOT refresh static's clearTerminal in alternate buffer
|
||||
expect(mocks.mockStdout.write).not.toHaveBeenCalledWith(
|
||||
ansiEscapes.clearTerminal,
|
||||
);
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Overflow Hint Handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('sets showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Trigger overflow
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show hint because we are in Standard Mode (default settings) and have overflow
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
|
||||
// Advance just before the timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Advance to hit the timeout mark
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
});
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => {
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof renderAppContainer>['stdin'];
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
stdin = result.stdin;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Initial state is constrainHeight = true
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
// Trigger overflow so the hint starts showing
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
|
||||
// Advance half the duration
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Simulate Ctrl+O
|
||||
act(() => {
|
||||
stdin.write('\x0f'); // \x0f is Ctrl+O
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// constrainHeight should toggle
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
});
|
||||
|
||||
// Advance enough that the original timer would have expired if it hadn't reset
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 1000);
|
||||
});
|
||||
|
||||
// We expect it to still be true because Ctrl+O should have reset the timer
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Advance remaining time to reach the new timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 1000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
});
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => {
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof renderAppContainer>['stdin'];
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
stdin = result.stdin;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Initial state is constrainHeight = true
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
// Trigger overflow so the hint starts showing
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
|
||||
// Advance half the duration
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// First toggle 'on' (expanded)
|
||||
act(() => {
|
||||
stdin.write('\x0f'); // Ctrl+O
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
});
|
||||
|
||||
// Wait 1 second
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Second toggle 'off' (collapsed)
|
||||
act(() => {
|
||||
stdin.write('\x0f'); // Ctrl+O
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
});
|
||||
|
||||
// Wait 1 second
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Third toggle 'on' (expanded)
|
||||
act(() => {
|
||||
stdin.write('\x0f'); // Ctrl+O
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
});
|
||||
|
||||
// Now we wait just before the timeout from the LAST toggle.
|
||||
// It should still be true.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Wait 0.1s more to hit exactly the timeout since the last toggle.
|
||||
// It should hide now.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
});
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('does NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => {
|
||||
const alternateSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const settingsWithAlternateBuffer = {
|
||||
merged: {
|
||||
...alternateSettings,
|
||||
ui: {
|
||||
...alternateSettings.ui,
|
||||
useAlternateBuffer: true,
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({
|
||||
settings: settingsWithAlternateBuffer,
|
||||
});
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Trigger overflow
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id');
|
||||
});
|
||||
|
||||
// Should NOT show hint because we are in Alternate Buffer Mode
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permission Handling', () => {
|
||||
it('shows permission dialog when checkPermissions returns paths', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
|
||||
@@ -95,6 +95,10 @@ import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import {
|
||||
useOverflowActions,
|
||||
useOverflowState,
|
||||
} from './contexts/OverflowContext.js';
|
||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import { calculatePromptWidths } from './components/InputPrompt.js';
|
||||
@@ -151,6 +155,7 @@ import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js'
|
||||
import {
|
||||
WARNING_PROMPT_DURATION_MS,
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
EXPAND_HINT_DURATION_MS,
|
||||
} from './constants.js';
|
||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||
@@ -214,6 +219,7 @@ const SHELL_HEIGHT_PADDING = 10;
|
||||
export const AppContainer = (props: AppContainerProps) => {
|
||||
const { config, initializationResult, resumedSessionData } = props;
|
||||
const settings = useSettings();
|
||||
const { reset } = useOverflowActions()!;
|
||||
const notificationsEnabled = isNotificationsEnabled(settings);
|
||||
|
||||
const historyManager = useHistory({
|
||||
@@ -262,6 +268,54 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
|
||||
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
|
||||
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
||||
const [showIsExpandableHint, setShowIsExpandableHint] = useState(false);
|
||||
const expandHintTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const overflowState = useOverflowState();
|
||||
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
|
||||
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight;
|
||||
|
||||
/**
|
||||
* Manages the visibility and x-second timer for the expansion hint.
|
||||
*
|
||||
* This effect triggers the timer countdown whenever an overflow is detected
|
||||
* or the user manually toggles the expansion state with Ctrl+O. We use a stable
|
||||
* boolean dependency (hasOverflowState) to ensure the timer only resets on
|
||||
* genuine state transitions, preventing it from infinitely resetting during
|
||||
* active text streaming.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isAlternateBuffer) {
|
||||
setShowIsExpandableHint(false);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasOverflowState) {
|
||||
setShowIsExpandableHint(true);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
expandHintTimerRef.current = setTimeout(() => {
|
||||
setShowIsExpandableHint(false);
|
||||
}, EXPAND_HINT_DURATION_MS);
|
||||
}
|
||||
}, [hasOverflowState, isAlternateBuffer, constrainHeight]);
|
||||
|
||||
/**
|
||||
* Safe cleanup to ensure the expansion hint timer is cancelled when the
|
||||
* component unmounts, preventing memory leaks.
|
||||
*/
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const [defaultBannerText, setDefaultBannerText] = useState('');
|
||||
const [warningBannerText, setWarningBannerText] = useState('');
|
||||
@@ -1189,6 +1243,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const handleFinalSubmit = useCallback(
|
||||
async (submittedValue: string) => {
|
||||
reset();
|
||||
// Explicitly hide the expansion hint and clear its x-second timer when a new turn begins.
|
||||
setShowIsExpandableHint(false);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
if (!constrainHeight) {
|
||||
setConstrainHeight(true);
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
}
|
||||
}
|
||||
|
||||
const isSlash = isSlashCommand(submittedValue.trim());
|
||||
const isIdle = streamingState === StreamingState.Idle;
|
||||
const isAgentRunning =
|
||||
@@ -1247,15 +1314,32 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingSlashCommandHistoryItems,
|
||||
pendingGeminiHistoryItems,
|
||||
config,
|
||||
constrainHeight,
|
||||
setConstrainHeight,
|
||||
isAlternateBuffer,
|
||||
refreshStatic,
|
||||
reset,
|
||||
handleHintSubmit,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClearScreen = useCallback(() => {
|
||||
reset();
|
||||
// Explicitly hide the expansion hint and clear its x-second timer when clearing the screen.
|
||||
setShowIsExpandableHint(false);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
historyManager.clearItems();
|
||||
clearConsoleMessagesState();
|
||||
refreshStatic();
|
||||
}, [historyManager, clearConsoleMessagesState, refreshStatic]);
|
||||
}, [
|
||||
historyManager,
|
||||
clearConsoleMessagesState,
|
||||
refreshStatic,
|
||||
reset,
|
||||
setShowIsExpandableHint,
|
||||
]);
|
||||
|
||||
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
|
||||
|
||||
@@ -1425,7 +1509,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
windowMs: WARNING_PROMPT_DURATION_MS,
|
||||
onRepeat: handleExitRepeat,
|
||||
});
|
||||
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
||||
|
||||
const [ideContextState, setIdeContextState] = useState<
|
||||
IdeContext | undefined
|
||||
>();
|
||||
@@ -1655,6 +1739,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (!constrainHeight) {
|
||||
enteringConstrainHeightMode = true;
|
||||
setConstrainHeight(true);
|
||||
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
|
||||
// If the user manually collapses the view, show the hint and reset the x-second timer.
|
||||
setShowIsExpandableHint(true);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
expandHintTimerRef.current = setTimeout(() => {
|
||||
setShowIsExpandableHint(false);
|
||||
}, EXPAND_HINT_DURATION_MS);
|
||||
}
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
}
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
|
||||
@@ -1698,6 +1795,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!enteringConstrainHeightMode
|
||||
) {
|
||||
setConstrainHeight(false);
|
||||
// If the user manually expands the view, show the hint and reset the x-second timer.
|
||||
setShowIsExpandableHint(true);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
expandHintTimerRef.current = setTimeout(() => {
|
||||
setShowIsExpandableHint(false);
|
||||
}, EXPAND_HINT_DURATION_MS);
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
}
|
||||
return true;
|
||||
} else if (
|
||||
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
|
||||
@@ -2218,6 +2326,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isBackgroundShellListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
hintMode:
|
||||
config.isModelSteeringEnabled() &&
|
||||
isToolExecuting([
|
||||
@@ -2344,6 +2453,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
backgroundShells,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -173,7 +173,9 @@ describe('FolderTrustDialog', () => {
|
||||
// Initial state: truncated
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Do you trust the files in this folder?');
|
||||
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
|
||||
// In standard terminal mode, the expansion hint is handled globally by ToastDisplay
|
||||
// via AppContainer, so it should not be present in the dialog's local frame.
|
||||
expect(lastFrame()).not.toContain('Press Ctrl+O');
|
||||
expect(lastFrame()).toContain('hidden');
|
||||
});
|
||||
|
||||
|
||||
@@ -285,33 +285,37 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box flexDirection="column" marginLeft={1} marginRight={1}>
|
||||
{renderContent()}
|
||||
</Box>
|
||||
|
||||
<Box paddingX={2} marginBottom={1}>
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||
</Box>
|
||||
|
||||
{isRestarting && (
|
||||
<Box marginLeft={1} marginTop={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
Gemini CLI is restarting to apply the trust changes...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{exiting && (
|
||||
<Box marginLeft={1} marginTop={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
A folder trust level must be selected to continue. Exiting since
|
||||
escape was pressed.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
const content = (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box flexDirection="column" marginLeft={1} marginRight={1}>
|
||||
{renderContent()}
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
|
||||
<Box paddingX={2} marginBottom={1}>
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||
</Box>
|
||||
|
||||
{isRestarting && (
|
||||
<Box marginLeft={1} marginTop={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
Gemini CLI is restarting to apply the trust changes...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{exiting && (
|
||||
<Box marginLeft={1} marginTop={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
A folder trust level must be selected to continue. Exiting since
|
||||
escape was pressed.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return isAlternateBuffer ? (
|
||||
<OverflowProvider>{content}</OverflowProvider>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ interface HistoryItemDisplayProps {
|
||||
isPending: boolean;
|
||||
commands?: readonly SlashCommand[];
|
||||
availableTerminalHeightGemini?: number;
|
||||
isExpandable?: boolean;
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
@@ -55,6 +56,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
isPending,
|
||||
commands,
|
||||
availableTerminalHeightGemini,
|
||||
isExpandable,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
@@ -180,6 +182,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
terminalWidth={terminalWidth}
|
||||
borderTop={itemForDisplay.borderTop}
|
||||
borderBottom={itemForDisplay.borderBottom}
|
||||
isExpandable={isExpandable}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'compression' && (
|
||||
|
||||
@@ -493,7 +493,8 @@ describe('MainContent', () => {
|
||||
isAlternateBuffer: true,
|
||||
embeddedShellFocused: true,
|
||||
constrainHeight: true,
|
||||
shouldShowLine1: true,
|
||||
shouldShowLine1: false,
|
||||
staticAreaMaxItemHeight: 15,
|
||||
},
|
||||
{
|
||||
name: 'ASB mode - Unfocused shell',
|
||||
@@ -501,6 +502,7 @@ describe('MainContent', () => {
|
||||
embeddedShellFocused: false,
|
||||
constrainHeight: true,
|
||||
shouldShowLine1: false,
|
||||
staticAreaMaxItemHeight: 15,
|
||||
},
|
||||
{
|
||||
name: 'Normal mode - Constrained height',
|
||||
@@ -508,13 +510,15 @@ describe('MainContent', () => {
|
||||
embeddedShellFocused: false,
|
||||
constrainHeight: true,
|
||||
shouldShowLine1: false,
|
||||
staticAreaMaxItemHeight: 15,
|
||||
},
|
||||
{
|
||||
name: 'Normal mode - Unconstrained height',
|
||||
isAlternateBuffer: false,
|
||||
embeddedShellFocused: false,
|
||||
constrainHeight: false,
|
||||
shouldShowLine1: false,
|
||||
shouldShowLine1: true,
|
||||
staticAreaMaxItemHeight: 15,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -525,6 +529,7 @@ describe('MainContent', () => {
|
||||
embeddedShellFocused,
|
||||
constrainHeight,
|
||||
shouldShowLine1,
|
||||
staticAreaMaxItemHeight,
|
||||
}) => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer);
|
||||
const ptyId = 123;
|
||||
@@ -554,6 +559,7 @@ describe('MainContent', () => {
|
||||
},
|
||||
],
|
||||
availableTerminalHeight: 30, // In ASB mode, focused shell should get ~28 lines
|
||||
staticAreaMaxItemHeight,
|
||||
terminalHeight: 50,
|
||||
terminalWidth: 100,
|
||||
mainAreaWidth: 100,
|
||||
|
||||
@@ -47,32 +47,61 @@ export const MainContent = () => {
|
||||
pendingHistoryItems,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
cleanUiDetailsVisible,
|
||||
} = uiState;
|
||||
const showHeaderDetails = cleanUiDetailsVisible;
|
||||
|
||||
const lastUserPromptIndex = useMemo(() => {
|
||||
for (let i = uiState.history.length - 1; i >= 0; i--) {
|
||||
const type = uiState.history[i].type;
|
||||
if (type === 'user' || type === 'user_shell') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}, [uiState.history]);
|
||||
|
||||
const historyItems = useMemo(
|
||||
() =>
|
||||
uiState.history.map((h) => (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={staticAreaMaxItemHeight}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={h.id}
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
/>
|
||||
)),
|
||||
uiState.history.map((h, index) => {
|
||||
const isExpandable = index > lastUserPromptIndex;
|
||||
return (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight || !isExpandable
|
||||
? staticAreaMaxItemHeight
|
||||
: undefined
|
||||
}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={h.id}
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
isExpandable={isExpandable}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
[
|
||||
uiState.history,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
uiState.slashCommands,
|
||||
uiState.constrainHeight,
|
||||
lastUserPromptIndex,
|
||||
],
|
||||
);
|
||||
|
||||
const staticHistoryItems = useMemo(
|
||||
() => historyItems.slice(0, lastUserPromptIndex + 1),
|
||||
[historyItems, lastUserPromptIndex],
|
||||
);
|
||||
|
||||
const lastResponseHistoryItems = useMemo(
|
||||
() => historyItems.slice(lastUserPromptIndex + 1),
|
||||
[historyItems, lastUserPromptIndex],
|
||||
);
|
||||
|
||||
const pendingItems = useMemo(
|
||||
() => (
|
||||
<Box flexDirection="column">
|
||||
@@ -80,14 +109,12 @@ export const MainContent = () => {
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
(uiState.constrainHeight && !isAlternateBuffer) ||
|
||||
isAlternateBuffer
|
||||
? availableTerminalHeight
|
||||
: undefined
|
||||
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isExpandable={true}
|
||||
/>
|
||||
))}
|
||||
{showConfirmationQueue && confirmingTool && (
|
||||
@@ -98,8 +125,7 @@ export const MainContent = () => {
|
||||
[
|
||||
pendingHistoryItems,
|
||||
uiState.constrainHeight,
|
||||
isAlternateBuffer,
|
||||
availableTerminalHeight,
|
||||
staticAreaMaxItemHeight,
|
||||
mainAreaWidth,
|
||||
showConfirmationQueue,
|
||||
confirmingTool,
|
||||
@@ -109,10 +135,14 @@ export const MainContent = () => {
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
{ type: 'header' as const },
|
||||
...uiState.history.map((item) => ({ type: 'history' as const, item })),
|
||||
...uiState.history.map((item, index) => ({
|
||||
type: 'history' as const,
|
||||
item,
|
||||
isExpandable: index > lastUserPromptIndex,
|
||||
})),
|
||||
{ type: 'pending' as const },
|
||||
],
|
||||
[uiState.history],
|
||||
[uiState.history, lastUserPromptIndex],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
@@ -129,12 +159,17 @@ export const MainContent = () => {
|
||||
return (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={undefined}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight || !item.isExpandable
|
||||
? staticAreaMaxItemHeight
|
||||
: undefined
|
||||
}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={item.item.id}
|
||||
item={item.item}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
isExpandable={item.isExpandable}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -147,6 +182,8 @@ export const MainContent = () => {
|
||||
mainAreaWidth,
|
||||
uiState.slashCommands,
|
||||
pendingItems,
|
||||
uiState.constrainHeight,
|
||||
staticAreaMaxItemHeight,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -176,7 +213,8 @@ export const MainContent = () => {
|
||||
key={uiState.historyRemountKey}
|
||||
items={[
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
...historyItems,
|
||||
...staticHistoryItems,
|
||||
...lastResponseHistoryItems,
|
||||
]}
|
||||
>
|
||||
{(item) => item}
|
||||
|
||||
@@ -29,7 +29,6 @@ describe('ShowMoreLines', () => {
|
||||
it.each([
|
||||
[new Set(), StreamingState.Idle, true], // No overflow
|
||||
[new Set(['1']), StreamingState.Idle, false], // Not constraining height
|
||||
[new Set(['1']), StreamingState.Responding, true], // Streaming
|
||||
])(
|
||||
'renders nothing when: overflow=%s, streaming=%s, constrain=%s',
|
||||
async (overflowingIds, streamingState, constrainHeight) => {
|
||||
@@ -46,9 +45,28 @@ describe('ShowMoreLines', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([[StreamingState.Idle], [StreamingState.WaitingForConfirmation]])(
|
||||
'renders message when overflowing and state is %s',
|
||||
it('renders nothing in STANDARD mode even if overflowing', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(false);
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(['1']),
|
||||
} as NonNullable<ReturnType<typeof useOverflowState>>);
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<ShowMoreLines constrainHeight={true} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[StreamingState.Idle],
|
||||
[StreamingState.WaitingForConfirmation],
|
||||
[StreamingState.Responding],
|
||||
])(
|
||||
'renders message in ASB mode when overflowing and state is %s',
|
||||
async (streamingState) => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(['1']),
|
||||
} as NonNullable<ReturnType<typeof useOverflowState>>);
|
||||
@@ -57,8 +75,39 @@ describe('ShowMoreLines', () => {
|
||||
<ShowMoreLines constrainHeight={true} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
|
||||
expect(lastFrame().toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('renders message in ASB mode when isOverflowing prop is true even if internal overflow state is empty', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(),
|
||||
} as NonNullable<ReturnType<typeof useOverflowState>>);
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<ShowMoreLines constrainHeight={true} isOverflowing={true} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame().toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders nothing when isOverflowing prop is false even if internal overflow state has IDs', async () => {
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(['1']),
|
||||
} as NonNullable<ReturnType<typeof useOverflowState>>);
|
||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<ShowMoreLines constrainHeight={true} isOverflowing={false} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,31 +9,42 @@ import { useOverflowState } from '../contexts/OverflowContext.js';
|
||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface ShowMoreLinesProps {
|
||||
constrainHeight: boolean;
|
||||
isOverflowing?: boolean;
|
||||
}
|
||||
|
||||
export const ShowMoreLines = ({ constrainHeight }: ShowMoreLinesProps) => {
|
||||
export const ShowMoreLines = ({
|
||||
constrainHeight,
|
||||
isOverflowing: isOverflowingProp,
|
||||
}: ShowMoreLinesProps) => {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const overflowState = useOverflowState();
|
||||
const streamingState = useStreamingContext();
|
||||
|
||||
const isOverflowing =
|
||||
isOverflowingProp ??
|
||||
(overflowState !== undefined && overflowState.overflowingIds.size > 0);
|
||||
|
||||
if (
|
||||
overflowState === undefined ||
|
||||
overflowState.overflowingIds.size === 0 ||
|
||||
!isAlternateBuffer ||
|
||||
!isOverflowing ||
|
||||
!constrainHeight ||
|
||||
!(
|
||||
streamingState === StreamingState.Idle ||
|
||||
streamingState === StreamingState.WaitingForConfirmation
|
||||
streamingState === StreamingState.WaitingForConfirmation ||
|
||||
streamingState === StreamingState.Responding
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
Press ctrl-o to show more lines
|
||||
<Box paddingX={1} marginBottom={1}>
|
||||
<Text color={theme.text.accent} wrap="truncate">
|
||||
Press Ctrl+O to show more lines
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -35,12 +35,22 @@ describe('ToastDisplay', () => {
|
||||
buffer: { text: '' } as TextBuffer,
|
||||
history: [] as HistoryItem[],
|
||||
queueErrorMessage: null,
|
||||
showIsExpandableHint: false,
|
||||
};
|
||||
|
||||
it('returns false for default state', () => {
|
||||
expect(shouldShowToast(baseState as UIState)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when showIsExpandableHint is true', () => {
|
||||
expect(
|
||||
shouldShowToast({
|
||||
...baseState,
|
||||
showIsExpandableHint: true,
|
||||
} as UIState),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when ctrlCPressedOnce is true', () => {
|
||||
expect(
|
||||
shouldShowToast({ ...baseState, ctrlCPressedOnce: true } as UIState),
|
||||
@@ -170,4 +180,22 @@ describe('ToastDisplay', () => {
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders expansion hint when showIsExpandableHint is true', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderToastDisplay({
|
||||
showIsExpandableHint: true,
|
||||
constrainHeight: true,
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Press Ctrl+O to show more lines');
|
||||
});
|
||||
|
||||
it('renders collapse hint when showIsExpandableHint is true and constrainHeight is false', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderToastDisplay({
|
||||
showIsExpandableHint: true,
|
||||
constrainHeight: false,
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Press Ctrl+O to collapse lines');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,8 @@ export function shouldShowToast(uiState: UIState): boolean {
|
||||
uiState.ctrlDPressedOnce ||
|
||||
(uiState.showEscapePrompt &&
|
||||
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
|
||||
Boolean(uiState.queueErrorMessage)
|
||||
Boolean(uiState.queueErrorMessage) ||
|
||||
uiState.showIsExpandableHint
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,5 +74,14 @@ export const ToastDisplay: React.FC = () => {
|
||||
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
|
||||
}
|
||||
|
||||
if (uiState.showIsExpandableHint) {
|
||||
const action = uiState.constrainHeight ? 'show more' : 'collapse';
|
||||
return (
|
||||
<Text color={theme.text.accent}>
|
||||
Press Ctrl+O to {action} lines for the most recent response
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -161,7 +161,7 @@ describe('ToolConfirmationQueue', () => {
|
||||
</Box>,
|
||||
{
|
||||
config: mockConfig,
|
||||
useAlternateBuffer: false,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
terminalWidth: 80,
|
||||
terminalHeight: 20,
|
||||
@@ -173,10 +173,11 @@ describe('ToolConfirmationQueue', () => {
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
|
||||
expect(lastFrame()?.toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -324,7 +325,7 @@ describe('ToolConfirmationQueue', () => {
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Press ctrl-o to show more lines');
|
||||
expect(output).not.toContain('Press CTRL-O to show more lines');
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -71,13 +71,12 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
// - 2 lines for the rounded border
|
||||
// - 2 lines for the Header (text + margin)
|
||||
// - 2 lines for Tool Identity (text + margin)
|
||||
const availableContentHeight =
|
||||
constrainHeight && !isAlternateBuffer
|
||||
? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4)
|
||||
: undefined;
|
||||
const availableContentHeight = constrainHeight
|
||||
? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<OverflowProvider>
|
||||
const content = (
|
||||
<>
|
||||
<Box flexDirection="column" width={mainAreaWidth} flexShrink={0}>
|
||||
<StickyHeader
|
||||
width={mainAreaWidth}
|
||||
@@ -152,6 +151,13 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||
</OverflowProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
return isAlternateBuffer ? (
|
||||
/* Shadow the global provider to maintain isolation in ASB mode. */
|
||||
<OverflowProvider>{content}</OverflowProvider>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
};
|
||||
|
||||
-2
@@ -43,7 +43,6 @@ Tips for getting started:
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
@@ -90,7 +89,6 @@ Tips for getting started:
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
|
||||
@@ -18,20 +18,8 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -27,33 +27,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"Overview
|
||||
|
||||
@@ -81,33 +54,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `
|
||||
" Error reading plan: File not found
|
||||
"
|
||||
@@ -194,33 +140,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"Overview
|
||||
|
||||
@@ -248,33 +167,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `
|
||||
" Error reading plan: File not found
|
||||
"
|
||||
|
||||
@@ -6,27 +6,17 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
│ Line 3 │
|
||||
│ Line 4 │
|
||||
│ Line 5 │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 15 │
|
||||
│ Line 16 │
|
||||
│ Line 17 │
|
||||
│ Line 18 │
|
||||
│ Line 19 │
|
||||
│ Line 20 │
|
||||
│ │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
│ Line 18 █ │
|
||||
│ Line 19 █ │
|
||||
│ Line 20 █ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
ShowMoreLines
|
||||
"
|
||||
@@ -38,15 +28,11 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 ▄ │
|
||||
│ Line 10 █ │
|
||||
│ Line 11 █ │
|
||||
│ Line 12 █ │
|
||||
│ Line 13 █ │
|
||||
│ Line 14 █ │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
@@ -63,12 +49,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ ... first 11 lines hidden ... │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
@@ -88,6 +69,11 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
│ Line 3 │
|
||||
│ Line 4 │
|
||||
│ Line 5 │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
|
||||
@@ -16,7 +16,6 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Press ctrl-o to show more lines
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -107,7 +106,7 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Press ctrl-o to show more lines
|
||||
Press Ctrl+O to show more lines
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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';
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
|
||||
interface GeminiMessageProps {
|
||||
text: string;
|
||||
@@ -31,7 +32,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
return (
|
||||
const content = (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.text.accent} aria-label={SCREEN_READER_MODEL_PREFIX}>
|
||||
@@ -61,4 +62,11 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return isAlternateBuffer ? (
|
||||
/* Shadow the global provider to maintain isolation in ASB mode. */
|
||||
<OverflowProvider>{content}</OverflowProvider>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
};
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('<ShellToolMessage />', () => {
|
||||
true,
|
||||
],
|
||||
[
|
||||
'defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined',
|
||||
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
|
||||
undefined,
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
false,
|
||||
@@ -219,5 +219,75 @@ describe('<ShellToolMessage />', () => {
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
|
||||
const { lastFrame } = renderShell(
|
||||
{
|
||||
resultDisplay: LONG_OUTPUT,
|
||||
renderOutputAsMarkdown: false,
|
||||
availableTerminalHeight: undefined,
|
||||
status: CoreToolCallStatus.Executing,
|
||||
},
|
||||
{ useAlternateBuffer: false },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should show all 100 lines
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderShell(
|
||||
{
|
||||
resultDisplay: LONG_OUTPUT,
|
||||
renderOutputAsMarkdown: false,
|
||||
availableTerminalHeight: undefined,
|
||||
status: CoreToolCallStatus.Success,
|
||||
isExpandable: true,
|
||||
},
|
||||
{
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should show all 100 lines because constrainHeight is false and isExpandable is true
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(100);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderShell(
|
||||
{
|
||||
resultDisplay: LONG_OUTPUT,
|
||||
renderOutputAsMarkdown: false,
|
||||
availableTerminalHeight: undefined,
|
||||
status: CoreToolCallStatus.Success,
|
||||
isExpandable: false,
|
||||
},
|
||||
{
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should still be constrained to ACTIVE_SHELL_MAX_LINES (15) because isExpandable is false
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(15);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,24 +15,21 @@ import {
|
||||
ToolStatusIndicator,
|
||||
ToolInfo,
|
||||
TrailingIndicator,
|
||||
STATUS_INDICATOR_WIDTH,
|
||||
isThisShellFocusable as checkIsShellFocusable,
|
||||
isThisShellFocused as checkIsShellFocused,
|
||||
useFocusHint,
|
||||
FocusHint,
|
||||
} from './ToolShared.js';
|
||||
import type { ToolMessageProps } from './ToolMessage.js';
|
||||
import {
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
COMPLETED_SHELL_MAX_LINES,
|
||||
} from '../../constants.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { calculateShellMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
|
||||
export interface ShellToolMessageProps extends ToolMessageProps {
|
||||
config?: Config;
|
||||
isExpandable?: boolean;
|
||||
}
|
||||
|
||||
export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
@@ -61,9 +58,15 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
borderColor,
|
||||
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
}) => {
|
||||
const { activePtyId: activeShellPtyId, embeddedShellFocused } = useUIState();
|
||||
const {
|
||||
activePtyId: activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
constrainHeight,
|
||||
} = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const isThisShellFocused = checkIsShellFocused(
|
||||
name,
|
||||
status,
|
||||
@@ -155,59 +158,23 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
terminalWidth={terminalWidth}
|
||||
renderOutputAsMarkdown={renderOutputAsMarkdown}
|
||||
hasFocus={isThisShellFocused}
|
||||
maxLines={getShellMaxLines(
|
||||
maxLines={calculateShellMaxLines({
|
||||
status,
|
||||
isAlternateBuffer,
|
||||
isThisShellFocused,
|
||||
availableTerminalHeight,
|
||||
)}
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
})}
|
||||
/>
|
||||
{isThisShellFocused && config && (
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
|
||||
<ShellInputPrompt
|
||||
activeShellPtyId={activeShellPtyId ?? null}
|
||||
focus={embeddedShellFocused}
|
||||
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
|
||||
/>
|
||||
</Box>
|
||||
<ShellInputPrompt
|
||||
activeShellPtyId={activeShellPtyId ?? null}
|
||||
focus={embeddedShellFocused}
|
||||
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the maximum number of lines to display for shell output.
|
||||
*
|
||||
* For completed processes (Success, Error, Canceled), it returns COMPLETED_SHELL_MAX_LINES.
|
||||
* For active processes, it returns the available terminal height if in alternate buffer mode
|
||||
* and focused. Otherwise, it returns ACTIVE_SHELL_MAX_LINES.
|
||||
*
|
||||
* This function ensures a finite number of lines is always returned to prevent performance issues.
|
||||
*/
|
||||
function getShellMaxLines(
|
||||
status: CoreToolCallStatus,
|
||||
isAlternateBuffer: boolean,
|
||||
isThisShellFocused: boolean,
|
||||
availableTerminalHeight: number | undefined,
|
||||
): number {
|
||||
if (
|
||||
status === CoreToolCallStatus.Success ||
|
||||
status === CoreToolCallStatus.Error ||
|
||||
status === CoreToolCallStatus.Cancelled
|
||||
) {
|
||||
return COMPLETED_SHELL_MAX_LINES;
|
||||
}
|
||||
|
||||
if (availableTerminalHeight === undefined) {
|
||||
return ACTIVE_SHELL_MAX_LINES;
|
||||
}
|
||||
|
||||
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
|
||||
|
||||
if (isAlternateBuffer && isThisShellFocused) {
|
||||
return maxLinesBasedOnHeight;
|
||||
}
|
||||
|
||||
return Math.min(maxLinesBasedOnHeight, ACTIVE_SHELL_MAX_LINES);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import type {
|
||||
HistoryItem,
|
||||
@@ -678,4 +679,194 @@ describe('<ToolGroupMessage />', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Manual Overflow Detection', () => {
|
||||
it('detects overflow for string results exceeding available height', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={6} // Very small height
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()?.toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('detects overflow for array results exceeding available height', async () => {
|
||||
// resultDisplay when array is expected to be AnsiLine[]
|
||||
// AnsiLine is AnsiToken[]
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
resultDisplay: Array(5).fill([{ text: 'line', fg: 'default' }]),
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={6}
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()?.toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('respects ACTIVE_SHELL_MAX_LINES for focused shell tools', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
name: 'run_shell_command',
|
||||
status: CoreToolCallStatus.Executing,
|
||||
ptyId: 1,
|
||||
resultDisplay: Array(20).fill('line').join('\n'), // 20 lines > 15 (limit)
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={100} // Plenty of terminal height
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
activePtyId: 1,
|
||||
embeddedShellFocused: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()?.toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not show expansion hint when content is within limits', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
resultDisplay: 'small result',
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={20}
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides expansion hint when constrainHeight is false', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={6}
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('isolates overflow hint in ASB mode (ignores global overflow state)', async () => {
|
||||
// In this test, the tool output is SHORT (no local overflow).
|
||||
// We will inject a dummy ID into the global overflow state.
|
||||
// ToolGroupMessage should still NOT show the hint because it calculates
|
||||
// overflow locally and passes it as a prop.
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
resultDisplay: 'short result',
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount, waitUntilReady, capturedOverflowActions } =
|
||||
renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={100}
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Manually trigger a global overflow
|
||||
act(() => {
|
||||
expect(capturedOverflowActions).toBeDefined();
|
||||
capturedOverflowActions!.addOverflowingId('unrelated-global-id');
|
||||
});
|
||||
|
||||
// The hint should NOT appear because ToolGroupMessage is isolated by its prop logic
|
||||
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,10 +17,15 @@ import { ToolMessage } from './ToolMessage.js';
|
||||
import { ShellToolMessage } from './ShellToolMessage.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { isShellTool } from './ToolShared.js';
|
||||
import { isShellTool, isThisShellFocused } from './ToolShared.js';
|
||||
import { shouldHideToolCall } from '@google/gemini-cli-core';
|
||||
import { ShowMoreLines } from '../ShowMoreLines.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import {
|
||||
calculateShellMaxLines,
|
||||
calculateToolContentMaxLines,
|
||||
} from '../../utils/toolLayoutUtils.js';
|
||||
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
@@ -31,6 +36,7 @@ interface ToolGroupMessageProps {
|
||||
onShellInputSubmit?: (input: string) => void;
|
||||
borderTop?: boolean;
|
||||
borderBottom?: boolean;
|
||||
isExpandable?: boolean;
|
||||
}
|
||||
|
||||
// Main component renders the border and maps the tools using ToolMessage
|
||||
@@ -43,6 +49,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
terminalWidth,
|
||||
borderTop: borderTopOverride,
|
||||
borderBottom: borderBottomOverride,
|
||||
isExpandable,
|
||||
}) => {
|
||||
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
|
||||
const toolCalls = useMemo(
|
||||
@@ -67,6 +74,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
backgroundShells,
|
||||
pendingHistoryItems,
|
||||
} = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const { borderColor, borderDimColor } = useMemo(
|
||||
() =>
|
||||
@@ -106,14 +114,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
const staticHeight = /* border */ 2;
|
||||
|
||||
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
|
||||
// only render if we need to close a border from previous
|
||||
// tool groups. borderBottomOverride=true means we must render the closing border;
|
||||
// undefined or false means there's nothing to display.
|
||||
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let countToolCallsWithResults = 0;
|
||||
for (const tool of visibleToolCalls) {
|
||||
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
|
||||
@@ -134,21 +134,91 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
|
||||
|
||||
return (
|
||||
// This box doesn't have a border even though it conceptually does because
|
||||
// we need to allow the sticky headers to render the borders themselves so
|
||||
// that the top border can be sticky.
|
||||
/*
|
||||
* ToolGroupMessage calculates its own overflow state locally and passes
|
||||
* it as a prop to ShowMoreLines. This isolates it from global overflow
|
||||
* reports in ASB mode, while allowing it to contribute to the global
|
||||
* 'Toast' hint in Standard mode.
|
||||
*
|
||||
* Because of this prop-based isolation and the explicit mode-checks in
|
||||
* AppContainer, we do not need to shadow the OverflowProvider here.
|
||||
*/
|
||||
const hasOverflow = useMemo(() => {
|
||||
if (!availableTerminalHeightPerToolMessage) return false;
|
||||
return visibleToolCalls.some((tool) => {
|
||||
const isShellToolCall = isShellTool(tool.name);
|
||||
const isFocused = isThisShellFocused(
|
||||
tool.name,
|
||||
tool.status,
|
||||
tool.ptyId,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
);
|
||||
|
||||
let maxLines: number | undefined;
|
||||
|
||||
if (isShellToolCall) {
|
||||
maxLines = calculateShellMaxLines({
|
||||
status: tool.status,
|
||||
isAlternateBuffer,
|
||||
isThisShellFocused: isFocused,
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
});
|
||||
}
|
||||
|
||||
// Standard tools and Shell tools both eventually use ToolResultDisplay's logic.
|
||||
// ToolResultDisplay uses calculateToolContentMaxLines to find the final line budget.
|
||||
const contentMaxLines = calculateToolContentMaxLines({
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
isAlternateBuffer,
|
||||
maxLinesLimit: maxLines,
|
||||
});
|
||||
|
||||
if (!contentMaxLines) return false;
|
||||
|
||||
if (typeof tool.resultDisplay === 'string') {
|
||||
const text = tool.resultDisplay;
|
||||
const hasTrailingNewline = text.endsWith('\n');
|
||||
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
|
||||
const lineCount = contentText.split('\n').length;
|
||||
return lineCount > contentMaxLines;
|
||||
}
|
||||
if (Array.isArray(tool.resultDisplay)) {
|
||||
return tool.resultDisplay.length > contentMaxLines;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [
|
||||
visibleToolCalls,
|
||||
availableTerminalHeightPerToolMessage,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
isAlternateBuffer,
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
]);
|
||||
|
||||
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
|
||||
// only render if we need to close a border from previous
|
||||
// tool groups. borderBottomOverride=true means we must render the closing border;
|
||||
// undefined or false means there's nothing to display.
|
||||
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
/*
|
||||
This width constraint is highly important and protects us from an Ink rendering bug.
|
||||
Since the ToolGroup can typically change rendering states frequently, it can cause
|
||||
Ink to render the border of the box incorrectly and span multiple lines and even
|
||||
cause tearing.
|
||||
*/
|
||||
This width constraint is highly important and protects us from an Ink rendering bug.
|
||||
Since the ToolGroup can typically change rendering states frequently, it can cause
|
||||
Ink to render the border of the box incorrectly and span multiple lines and even
|
||||
cause tearing.
|
||||
*/
|
||||
width={terminalWidth}
|
||||
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
|
||||
marginBottom={borderBottomOverride === false ? 0 : 1}
|
||||
>
|
||||
{visibleToolCalls.map((tool, index) => {
|
||||
const isFirst = index === 0;
|
||||
@@ -165,6 +235,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
: isFirst,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -179,34 +250,34 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
) : (
|
||||
<ToolMessage {...commonProps} />
|
||||
)}
|
||||
<Box
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
{tool.outputFile && (
|
||||
{tool.outputFile && (
|
||||
<Box
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
Output too long and was saved to: {tool.outputFile}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{
|
||||
/*
|
||||
We have to keep the bottom border separate so it doesn't get
|
||||
drawn over by the sticky header directly inside it.
|
||||
*/
|
||||
We have to keep the bottom border separate so it doesn't get
|
||||
drawn over by the sticky header directly inside it.
|
||||
*/
|
||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
|
||||
<Box
|
||||
height={0}
|
||||
@@ -222,8 +293,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
)
|
||||
}
|
||||
{(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||
<ShowMoreLines
|
||||
constrainHeight={constrainHeight && !!isExpandable}
|
||||
isOverflowing={hasOverflow}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { StreamingState, type IndividualToolCallDisplay } from '../../types.js';
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
|
||||
describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => {
|
||||
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Alternate Buffer (ASB) mode', async () => {
|
||||
/**
|
||||
* Logic:
|
||||
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
|
||||
* 2. ASB mode reserves 1 + 6 = 7 lines.
|
||||
* 3. Line budget = 10 - 7 = 3 lines.
|
||||
* 4. 5 lines of output > 3 lines budget => hasOverflow should be TRUE.
|
||||
*/
|
||||
|
||||
const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`);
|
||||
const resultDisplay = lines.join('\n');
|
||||
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'test-tool',
|
||||
description: 'a test tool',
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<ToolGroupMessage
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={13}
|
||||
terminalWidth={80}
|
||||
isExpandable={true}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
constrainHeight: true,
|
||||
},
|
||||
useAlternateBuffer: true,
|
||||
},
|
||||
);
|
||||
|
||||
// In ASB mode, the hint should appear because hasOverflow is now correctly calculated.
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()?.toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Standard mode', async () => {
|
||||
/**
|
||||
* Logic:
|
||||
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
|
||||
* 2. Standard mode reserves 1 + 2 = 3 lines.
|
||||
* 3. Line budget = 10 - 3 = 7 lines.
|
||||
* 4. 9 lines of output > 7 lines budget => hasOverflow should be TRUE.
|
||||
*/
|
||||
|
||||
const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`);
|
||||
const resultDisplay = lines.join('\n');
|
||||
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'test-tool',
|
||||
description: 'a test tool',
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<ToolGroupMessage
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={13}
|
||||
terminalWidth={80}
|
||||
isExpandable={true}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
constrainHeight: true,
|
||||
},
|
||||
useAlternateBuffer: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify truncation is occurring (standard mode uses MaxSizedBox)
|
||||
await waitFor(() => expect(lastFrame()).toContain('hidden ...'));
|
||||
|
||||
// In Standard mode, ToolGroupMessage calculates hasOverflow correctly now.
|
||||
// While Standard mode doesn't render the inline hint (ShowMoreLines returns null),
|
||||
// the logic inside ToolGroupMessage is now synchronized.
|
||||
});
|
||||
});
|
||||
@@ -277,21 +277,47 @@ describe('ToolResultDisplay', () => {
|
||||
inverse: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'Line 4',
|
||||
fg: '',
|
||||
bg: '',
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'Line 5',
|
||||
fg: '',
|
||||
bg: '',
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
},
|
||||
],
|
||||
];
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={ansiResult}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
maxLines={2}
|
||||
maxLines={3}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).not.toContain('Line 1');
|
||||
expect(output).toContain('Line 2');
|
||||
expect(output).toContain('Line 3');
|
||||
expect(output).not.toContain('Line 2');
|
||||
expect(output).not.toContain('Line 3');
|
||||
expect(output).toContain('Line 4');
|
||||
expect(output).toContain('Line 5');
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -19,10 +19,7 @@ import { Scrollable } from '../shared/Scrollable.js';
|
||||
import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
|
||||
const STATIC_HEIGHT = 1;
|
||||
const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint
|
||||
const MIN_LINES_SHOWN = 2; // show at least this many lines
|
||||
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
|
||||
// Large threshold to ensure we don't cause performance issues for very large
|
||||
// outputs that will get truncated further MaxSizedBox anyway.
|
||||
@@ -53,16 +50,11 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
let availableHeight = availableTerminalHeight
|
||||
? Math.max(
|
||||
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
|
||||
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (maxLines && availableHeight) {
|
||||
availableHeight = Math.min(availableHeight, maxLines);
|
||||
}
|
||||
const availableHeight = calculateToolContentMaxLines({
|
||||
availableTerminalHeight,
|
||||
isAlternateBuffer,
|
||||
maxLinesLimit: maxLines,
|
||||
});
|
||||
|
||||
const combinedPaddingAndBorderWidth = 4;
|
||||
const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
|
||||
@@ -81,7 +73,8 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const truncatedResultDisplay = React.useMemo(() => {
|
||||
const { truncatedResultDisplay, hiddenLinesCount } = React.useMemo(() => {
|
||||
let hiddenLines = 0;
|
||||
// Only truncate string output if not in alternate buffer mode to ensure
|
||||
// we can scroll through the full output.
|
||||
if (typeof resultDisplay === 'string' && !isAlternateBuffer) {
|
||||
@@ -94,14 +87,29 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
|
||||
const lines = contentText.split('\n');
|
||||
if (lines.length > maxLines) {
|
||||
// We will have a label from MaxSizedBox. Reserve space for it.
|
||||
const targetLines = Math.max(1, maxLines - 1);
|
||||
hiddenLines = lines.length - targetLines;
|
||||
text =
|
||||
lines.slice(-maxLines).join('\n') +
|
||||
lines.slice(-targetLines).join('\n') +
|
||||
(hasTrailingNewline ? '\n' : '');
|
||||
}
|
||||
}
|
||||
return text;
|
||||
return { truncatedResultDisplay: text, hiddenLinesCount: hiddenLines };
|
||||
}
|
||||
return resultDisplay;
|
||||
|
||||
if (Array.isArray(resultDisplay) && !isAlternateBuffer && maxLines) {
|
||||
if (resultDisplay.length > maxLines) {
|
||||
// We will have a label from MaxSizedBox. Reserve space for it.
|
||||
const targetLines = Math.max(1, maxLines - 1);
|
||||
return {
|
||||
truncatedResultDisplay: resultDisplay.slice(-targetLines),
|
||||
hiddenLinesCount: resultDisplay.length - targetLines,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { truncatedResultDisplay: resultDisplay, hiddenLinesCount: 0 };
|
||||
}, [resultDisplay, isAlternateBuffer, maxLines]);
|
||||
|
||||
if (!truncatedResultDisplay) return null;
|
||||
@@ -229,7 +237,11 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column">
|
||||
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
|
||||
<MaxSizedBox
|
||||
maxHeight={availableHeight}
|
||||
maxWidth={childWidth}
|
||||
additionalHiddenLinesCount={hiddenLinesCount}
|
||||
>
|
||||
{content}
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('ToolResultDisplay Overflow', () => {
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={15} // Small height to force overflow
|
||||
terminalWidth={80}
|
||||
isExpandable={true}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{
|
||||
@@ -46,26 +47,28 @@ describe('ToolResultDisplay Overflow', () => {
|
||||
streamingState: StreamingState.Idle,
|
||||
constrainHeight: true,
|
||||
},
|
||||
useAlternateBuffer: false,
|
||||
useAlternateBuffer: true,
|
||||
},
|
||||
);
|
||||
|
||||
// ResizeObserver might take a tick
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
|
||||
expect(lastFrame()?.toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
),
|
||||
);
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toBeDefined();
|
||||
if (frame) {
|
||||
expect(frame).toContain('Press ctrl-o to show more lines');
|
||||
expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines');
|
||||
// Ensure it's AFTER the bottom border
|
||||
const linesOfOutput = frame.split('\n');
|
||||
const bottomBorderIndex = linesOfOutput.findLastIndex((l) =>
|
||||
l.includes('╰─'),
|
||||
);
|
||||
const hintIndex = linesOfOutput.findIndex((l) =>
|
||||
l.includes('Press ctrl-o to show more lines'),
|
||||
l.toLowerCase().includes('press ctrl+o to show more lines'),
|
||||
);
|
||||
expect(hintIndex).toBeGreaterThan(bottomBorderIndex);
|
||||
expect(frame).toMatchSnapshot();
|
||||
|
||||
+130
-3
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined 1`] = `
|
||||
exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command A shell command │
|
||||
│ │
|
||||
@@ -22,6 +22,113 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ShellToolMessage /> > Height Constraints > fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
│ Line 3 │
|
||||
│ Line 4 │
|
||||
│ Line 5 │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 15 │
|
||||
│ Line 16 │
|
||||
│ Line 17 │
|
||||
│ Line 18 │
|
||||
│ Line 19 │
|
||||
│ Line 20 │
|
||||
│ Line 21 │
|
||||
│ Line 22 │
|
||||
│ Line 23 │
|
||||
│ Line 24 │
|
||||
│ Line 25 │
|
||||
│ Line 26 │
|
||||
│ Line 27 │
|
||||
│ Line 28 │
|
||||
│ Line 29 │
|
||||
│ Line 30 │
|
||||
│ Line 31 │
|
||||
│ Line 32 │
|
||||
│ Line 33 │
|
||||
│ Line 34 │
|
||||
│ Line 35 │
|
||||
│ Line 36 │
|
||||
│ Line 37 │
|
||||
│ Line 38 │
|
||||
│ Line 39 │
|
||||
│ Line 40 │
|
||||
│ Line 41 │
|
||||
│ Line 42 │
|
||||
│ Line 43 │
|
||||
│ Line 44 │
|
||||
│ Line 45 │
|
||||
│ Line 46 │
|
||||
│ Line 47 │
|
||||
│ Line 48 │
|
||||
│ Line 49 │
|
||||
│ Line 50 │
|
||||
│ Line 51 │
|
||||
│ Line 52 │
|
||||
│ Line 53 │
|
||||
│ Line 54 │
|
||||
│ Line 55 │
|
||||
│ Line 56 │
|
||||
│ Line 57 │
|
||||
│ Line 58 │
|
||||
│ Line 59 │
|
||||
│ Line 60 │
|
||||
│ Line 61 │
|
||||
│ Line 62 │
|
||||
│ Line 63 │
|
||||
│ Line 64 │
|
||||
│ Line 65 │
|
||||
│ Line 66 │
|
||||
│ Line 67 │
|
||||
│ Line 68 │
|
||||
│ Line 69 │
|
||||
│ Line 70 │
|
||||
│ Line 71 │
|
||||
│ Line 72 │
|
||||
│ Line 73 │
|
||||
│ Line 74 │
|
||||
│ Line 75 │
|
||||
│ Line 76 │
|
||||
│ Line 77 │
|
||||
│ Line 78 │
|
||||
│ Line 79 │
|
||||
│ Line 80 │
|
||||
│ Line 81 │
|
||||
│ Line 82 │
|
||||
│ Line 83 │
|
||||
│ Line 84 │
|
||||
│ Line 85 │
|
||||
│ Line 86 │
|
||||
│ Line 87 │
|
||||
│ Line 88 │
|
||||
│ Line 89 │
|
||||
│ Line 90 │
|
||||
│ Line 91 │
|
||||
│ Line 92 │
|
||||
│ Line 93 │
|
||||
│ Line 94 │
|
||||
│ Line 95 │
|
||||
│ Line 96 │
|
||||
│ Line 97 │
|
||||
│ Line 98 │
|
||||
│ Line 99 │
|
||||
│ Line 100 │
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command A shell command │
|
||||
@@ -37,6 +144,28 @@ exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalH
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ShellToolMessage /> > Height Constraints > stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 86 │
|
||||
│ Line 87 │
|
||||
│ Line 88 │
|
||||
│ Line 89 │
|
||||
│ Line 90 │
|
||||
│ Line 91 │
|
||||
│ Line 92 │
|
||||
│ Line 93 │
|
||||
│ Line 94 │
|
||||
│ Line 95 │
|
||||
│ Line 96 │
|
||||
│ Line 97 │
|
||||
│ Line 98 ▄ │
|
||||
│ Line 99 █ │
|
||||
│ Line 100 █ │
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command A shell command │
|
||||
@@ -161,7 +290,6 @@ exports[`<ShellToolMessage /> > Height Constraints > uses full availableTerminal
|
||||
│ Line 98 █ │
|
||||
│ Line 99 █ │
|
||||
│ Line 100 █ │
|
||||
│ │
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -170,7 +298,6 @@ exports[`<ShellToolMessage /> > Snapshots > renders in Alternate Buffer mode whi
|
||||
│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
|
||||
│ │
|
||||
│ Test result │
|
||||
│ │
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
+6
-6
@@ -55,13 +55,13 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool-1 Description 1. This is a long description that will need to b… │
|
||||
│──────────────────────────────────────────────────────────────────────────│
|
||||
│ │ ▄
|
||||
│ line5 │ █
|
||||
│ │ █
|
||||
│ ✓ tool-2 Description 2 │ █
|
||||
│ │ █
|
||||
│ line1 │ █
|
||||
│ line2 │ █
|
||||
╰──────────────────────────────────────────────────────────────────────────╯ █
|
||||
█
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -111,12 +111,12 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with output
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
"╰──────────────────────────────────────────────────────────────────────────╯
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool-2 Description 2 │
|
||||
│ │
|
||||
│ line1 │ ▄
|
||||
│ │ ▄
|
||||
│ line1 │ █
|
||||
╰──────────────────────────────────────────────────────────────────────────╯ █
|
||||
█
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
+5
-1
@@ -37,7 +37,11 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
|
||||
`;
|
||||
|
||||
exports[`ToolResultDisplay > truncates very long string results 1`] = `
|
||||
"... first 252 lines hidden ...
|
||||
"... first 248 lines hidden ...
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
|
||||
+3
-3
@@ -4,13 +4,13 @@ exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when co
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ test-tool a test tool │
|
||||
│ │
|
||||
│ ... first 45 lines hidden ... │
|
||||
│ line 45 │
|
||||
│ line 46 │
|
||||
│ line 47 │
|
||||
│ line 48 │
|
||||
│ line 49 │
|
||||
│ line 50 │
|
||||
│ line 50 █ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
Press ctrl-o to show more lines
|
||||
Press Ctrl+O to show more lines
|
||||
"
|
||||
`;
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────────╮ █
|
||||
│ ✓ Shell Command Description for Shell Command │ ▀
|
||||
│ ✓ Shell Command Description for Shell Command │ █
|
||||
│ │
|
||||
│ shell-01 │
|
||||
│ shell-02 │
|
||||
@@ -11,7 +11,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Shell Command Description for Shell Command │
|
||||
│ ✓ Shell Command Description for Shell Command │ ▄
|
||||
│────────────────────────────────────────────────────────────────────────│ █
|
||||
│ shell-06 │ ▀
|
||||
│ shell-07 │
|
||||
|
||||
@@ -33,6 +33,7 @@ export const WARNING_PROMPT_DURATION_MS = 3000;
|
||||
export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
|
||||
export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
|
||||
export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000;
|
||||
export const EXPAND_HINT_DURATION_MS = 5000;
|
||||
|
||||
export const DEFAULT_BACKGROUND_OPACITY = 0.16;
|
||||
export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24;
|
||||
|
||||
@@ -13,13 +13,14 @@ import {
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
interface OverflowState {
|
||||
export interface OverflowState {
|
||||
overflowingIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface OverflowActions {
|
||||
export interface OverflowActions {
|
||||
addOverflowingId: (id: string) => void;
|
||||
removeOverflowingId: (id: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const OverflowStateContext = createContext<OverflowState | undefined>(
|
||||
@@ -63,6 +64,10 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setOverflowingIds(new Set());
|
||||
}, []);
|
||||
|
||||
const stateValue = useMemo(
|
||||
() => ({
|
||||
overflowingIds,
|
||||
@@ -74,8 +79,9 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
() => ({
|
||||
addOverflowingId,
|
||||
removeOverflowingId,
|
||||
reset,
|
||||
}),
|
||||
[addOverflowingId, removeOverflowingId],
|
||||
[addOverflowingId, removeOverflowingId, reset],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -182,6 +182,7 @@ export interface UIState {
|
||||
isBackgroundShellListOpen: boolean;
|
||||
adminSettingsChanged: boolean;
|
||||
newAgents: AgentDefinition[] | null;
|
||||
showIsExpandableHint: boolean;
|
||||
hintMode: boolean;
|
||||
hintBuffer: string;
|
||||
transientMessage: {
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from '../components/shared/MaxSizedBox.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
// Configure theming and parsing utilities.
|
||||
const lowlight = createLowlight(common);
|
||||
@@ -152,7 +151,6 @@ export function colorizeCode({
|
||||
? false
|
||||
: settings.merged.ui.showLineNumbers;
|
||||
|
||||
const useMaxSizedBox = !isAlternateBufferEnabled(settings);
|
||||
try {
|
||||
// Render the HAST tree using the adapted theme
|
||||
// Apply the theme's default foreground color to the top-level Text element
|
||||
@@ -162,7 +160,7 @@ export function colorizeCode({
|
||||
let hiddenLinesCount = 0;
|
||||
|
||||
// Optimization to avoid highlighting lines that cannot possibly be displayed.
|
||||
if (availableHeight !== undefined && useMaxSizedBox) {
|
||||
if (availableHeight !== undefined) {
|
||||
availableHeight = Math.max(availableHeight, MINIMUM_MAX_HEIGHT);
|
||||
if (lines.length > availableHeight) {
|
||||
const sliceIndex = lines.length - availableHeight;
|
||||
@@ -200,7 +198,7 @@ export function colorizeCode({
|
||||
);
|
||||
});
|
||||
|
||||
if (useMaxSizedBox) {
|
||||
if (availableHeight !== undefined) {
|
||||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={availableHeight}
|
||||
@@ -244,7 +242,7 @@ export function colorizeCode({
|
||||
</Box>
|
||||
));
|
||||
|
||||
if (useMaxSizedBox) {
|
||||
if (availableHeight !== undefined) {
|
||||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={availableHeight}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
COMPLETED_SHELL_MAX_LINES,
|
||||
} from '../constants.js';
|
||||
import { CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Constants used for calculating available height for tool results.
|
||||
* These MUST be kept in sync between ToolGroupMessage (for overflow detection)
|
||||
* and ToolResultDisplay (for actual truncation).
|
||||
*/
|
||||
export const TOOL_RESULT_STATIC_HEIGHT = 1;
|
||||
export const TOOL_RESULT_ASB_RESERVED_LINE_COUNT = 6;
|
||||
export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 2;
|
||||
export const TOOL_RESULT_MIN_LINES_SHOWN = 2;
|
||||
|
||||
/**
|
||||
* Calculates the final height available for the content of a tool result display.
|
||||
*
|
||||
* This accounts for:
|
||||
* 1. The static height of the tool message (name, status line).
|
||||
* 2. Reserved space for hints and padding (different in ASB vs Standard mode).
|
||||
* 3. Enforcing a minimum number of lines shown.
|
||||
*/
|
||||
export function calculateToolContentMaxLines(options: {
|
||||
availableTerminalHeight: number | undefined;
|
||||
isAlternateBuffer: boolean;
|
||||
maxLinesLimit?: number;
|
||||
}): number | undefined {
|
||||
const { availableTerminalHeight, isAlternateBuffer, maxLinesLimit } = options;
|
||||
|
||||
const reservedLines = isAlternateBuffer
|
||||
? TOOL_RESULT_ASB_RESERVED_LINE_COUNT
|
||||
: TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT;
|
||||
|
||||
let contentHeight = availableTerminalHeight
|
||||
? Math.max(
|
||||
availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines,
|
||||
TOOL_RESULT_MIN_LINES_SHOWN + 1,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (maxLinesLimit) {
|
||||
contentHeight =
|
||||
contentHeight !== undefined
|
||||
? Math.min(contentHeight, maxLinesLimit)
|
||||
: maxLinesLimit;
|
||||
}
|
||||
|
||||
return contentHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the maximum number of lines to display for shell output.
|
||||
*
|
||||
* This logic distinguishes between:
|
||||
* 1. Process Status: Active (Executing) vs Completed.
|
||||
* 2. UI Focus: Whether the user is currently interacting with the shell.
|
||||
* 3. Expansion State: Whether the user has explicitly requested to "Show More Lines" (CTRL+O).
|
||||
*/
|
||||
export function calculateShellMaxLines(options: {
|
||||
status: CoreToolCallStatus;
|
||||
isAlternateBuffer: boolean;
|
||||
isThisShellFocused: boolean;
|
||||
availableTerminalHeight: number | undefined;
|
||||
constrainHeight: boolean;
|
||||
isExpandable: boolean | undefined;
|
||||
}): number | undefined {
|
||||
const {
|
||||
status,
|
||||
isAlternateBuffer,
|
||||
isThisShellFocused,
|
||||
availableTerminalHeight,
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
} = options;
|
||||
|
||||
// 1. If the user explicitly requested expansion (unconstrained), remove all caps.
|
||||
if (!constrainHeight && isExpandable) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 2. Handle cases where height is unknown (Standard mode history).
|
||||
if (availableTerminalHeight === undefined) {
|
||||
return isAlternateBuffer ? ACTIVE_SHELL_MAX_LINES : undefined;
|
||||
}
|
||||
|
||||
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
|
||||
|
||||
// 3. Handle ASB mode focus expansion.
|
||||
// We allow a focused shell in ASB mode to take up the full available height,
|
||||
// BUT only if we aren't trying to maintain a constrained view (e.g., history items).
|
||||
if (isAlternateBuffer && isThisShellFocused && !constrainHeight) {
|
||||
return maxLinesBasedOnHeight;
|
||||
}
|
||||
|
||||
// 4. Fall back to process-based constants.
|
||||
const isExecuting = status === CoreToolCallStatus.Executing;
|
||||
const shellMaxLinesLimit = isExecuting
|
||||
? ACTIVE_SHELL_MAX_LINES
|
||||
: COMPLETED_SHELL_MAX_LINES;
|
||||
|
||||
return Math.min(maxLinesBasedOnHeight, shellMaxLinesLimit);
|
||||
}
|
||||
Reference in New Issue
Block a user