Add low/full CLI error verbosity mode for cleaner UI (#20399)

This commit is contained in:
Dmitry Lyalin
2026-02-27 14:15:10 -05:00
committed by GitHub
parent 1c8951334a
commit 7f8ce8657c
25 changed files with 689 additions and 32 deletions

View File

@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { describe, it, expect, vi } from 'vitest';
import type { ConsoleMessageItem } from '../types.js';
import { Box } from 'ink';
import type React from 'react';
import { createMockSettings } from '../../test-utils/settings.js';
vi.mock('./shared/ScrollableList.js', () => ({
ScrollableList: ({
@@ -29,13 +30,18 @@ vi.mock('./shared/ScrollableList.js', () => ({
describe('DetailedMessagesDisplay', () => {
it('renders nothing when messages are empty', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DetailedMessagesDisplay
messages={[]}
maxHeight={10}
width={80}
hasFocus={false}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
@@ -50,13 +56,18 @@ describe('DetailedMessagesDisplay', () => {
{ type: 'debug', content: 'Debug message', count: 1 },
];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DetailedMessagesDisplay
messages={messages}
maxHeight={20}
width={80}
hasFocus={true}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
const output = lastFrame();
@@ -65,18 +76,69 @@ describe('DetailedMessagesDisplay', () => {
unmount();
});
it('hides the F12 hint in low error verbosity mode', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DetailedMessagesDisplay
messages={messages}
maxHeight={20}
width={80}
hasFocus={true}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('(F12 to close)');
unmount();
});
it('shows the F12 hint in full error verbosity mode', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DetailedMessagesDisplay
messages={messages}
maxHeight={20}
width={80}
hasFocus={true}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('(F12 to close)');
unmount();
});
it('renders message counts', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'log', content: 'Repeated message', count: 5 },
];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DetailedMessagesDisplay
messages={messages}
maxHeight={10}
width={80}
hasFocus={false}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
const output = lastFrame();

View File

@@ -13,6 +13,8 @@ import {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface DetailedMessagesDisplayProps {
messages: ConsoleMessageItem[];
@@ -27,6 +29,10 @@ export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps
> = ({ messages, maxHeight, width, hasFocus }) => {
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
const config = useConfig();
const settings = useSettings();
const showHotkeyHint =
settings.merged.ui.errorVerbosity === 'full' || config.getDebugMode();
const borderAndPadding = 3;
@@ -65,7 +71,10 @@ export const DetailedMessagesDisplay: React.FC<
>
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
Debug Console{' '}
{showHotkeyHint && (
<Text color={theme.text.secondary}>(F12 to close)</Text>
)}
</Text>
</Box>
<Box height={maxHeight} width={width - borderAndPadding}>

View File

@@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { Footer } from './Footer.js';
import { tildeifyPath, ToolCallDecision } from '@google/gemini-cli-core';
import {
makeFakeConfig,
tildeifyPath,
ToolCallDecision,
} from '@google/gemini-cli-core';
import type { SessionStatsState } from '../contexts/SessionContext.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -503,6 +507,75 @@ describe('<Footer />', () => {
unmount();
});
});
describe('error summary visibility', () => {
it('hides error summary in low verbosity mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('F12 for details');
expect(lastFrame()).not.toContain('2 errors');
unmount();
});
it('shows error summary in full verbosity mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('2 errors');
unmount();
});
it('shows error summary in debug mode even when verbosity is low', async () => {
const debugConfig = makeFakeConfig();
vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
config: debugConfig,
uiState: {
sessionStats: mockSessionStats,
errorCount: 1,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('1 error');
unmount();
});
});
});
describe('fallback mode display', () => {

View File

@@ -60,6 +60,9 @@ export const Footer: React.FC = () => {
const showMemoryUsage =
config.getDebugMode() || settings.merged.ui.showMemoryUsage;
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
const showErrorSummary =
!showErrorDetails && errorCount > 0 && (isFullErrorVerbosity || debugMode);
const hideCWD = settings.merged.ui.footer.hideCWD;
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
@@ -180,7 +183,7 @@ export const Footer: React.FC = () => {
</Text>
</Box>
)}
{!showErrorDetails && errorCount > 0 && (
{showErrorSummary && (
<Box paddingLeft={1} flexDirection="row">
<Text color={theme.ui.comment}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} />

View File

@@ -25,6 +25,7 @@ import {
GLOB_DISPLAY_NAME,
} from '@google/gemini-cli-core';
import os from 'node:os';
import { createMockSettings } from '../../../test-utils/settings.js';
describe('<ToolGroupMessage />', () => {
afterEach(() => {
@@ -64,6 +65,11 @@ describe('<ToolGroupMessage />', () => {
ideMode: false,
enableInteractiveShell: true,
});
const fullVerbositySettings = createMockSettings({
merged: {
ui: { errorVerbosity: 'full' },
},
});
describe('Golden Snapshots', () => {
it('renders single successful tool call', async () => {
@@ -73,6 +79,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -104,7 +111,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
{ config: baseMockConfig, settings: fullVerbositySettings },
);
// Should render nothing because all tools in the group are confirming
@@ -140,6 +147,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -160,6 +168,76 @@ describe('<ToolGroupMessage />', () => {
unmount();
});
it('hides errored tool calls in low error verbosity mode', async () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'successful-tool',
status: CoreToolCallStatus.Success,
}),
createToolCall({
callId: 'tool-2',
name: 'error-tool',
status: CoreToolCallStatus.Error,
resultDisplay: 'Tool failed',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('successful-tool');
expect(output).not.toContain('error-tool');
unmount();
});
it('keeps client-initiated errored tool calls visible in low error verbosity mode', async () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'client-error-tool',
status: CoreToolCallStatus.Error,
isClientInitiated: true,
resultDisplay: 'Client tool failed',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('client-error-tool');
unmount();
});
it('renders mixed tool calls including shell command', async () => {
const toolCalls = [
createToolCall({
@@ -187,6 +265,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -233,6 +312,7 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -266,6 +346,7 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -288,6 +369,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -326,6 +408,7 @@ describe('<ToolGroupMessage />', () => {
</Scrollable>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -356,6 +439,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -406,6 +490,7 @@ describe('<ToolGroupMessage />', () => {
</Scrollable>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -439,6 +524,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -468,6 +554,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -510,6 +597,7 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
uiState: {
pendingHistoryItems: [
{
@@ -569,7 +657,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
{ config: baseMockConfig, settings: fullVerbositySettings },
);
await waitUntilReady();
@@ -599,7 +687,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
{ config: baseMockConfig, settings: fullVerbositySettings },
);
await waitUntilReady();
@@ -627,7 +715,7 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls}
borderBottom={false}
/>,
{ config: baseMockConfig },
{ config: baseMockConfig, settings: fullVerbositySettings },
);
// AskUser tools in progress are rendered by AskUserDialog, so we expect nothing.
await waitUntilReady();
@@ -665,7 +753,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
{ config: baseMockConfig, settings: fullVerbositySettings },
);
await waitUntilReady();
@@ -697,6 +785,7 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
@@ -728,6 +817,7 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
@@ -760,6 +850,7 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
@@ -791,6 +882,7 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
@@ -818,6 +910,7 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
@@ -850,6 +943,7 @@ describe('<ToolGroupMessage />', () => {
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,

View File

@@ -18,7 +18,10 @@ import { ShellToolMessage } from './ShellToolMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js';
import { shouldHideToolCall } from '@google/gemini-cli-core';
import {
shouldHideToolCall,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
@@ -27,6 +30,7 @@ import {
calculateToolContentMaxLines,
} from '../../utils/toolLayoutUtils.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
import { useSettings } from '../../contexts/SettingsContext.js';
interface ToolGroupMessageProps {
item: HistoryItem | HistoryItemWithoutId;
@@ -51,19 +55,29 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
borderBottom: borderBottomOverride,
isExpandable,
}) => {
const settings = useSettings();
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
const toolCalls = useMemo(
() =>
allToolCalls.filter(
(t) =>
!shouldHideToolCall({
displayName: t.name,
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
}),
),
[allToolCalls],
allToolCalls.filter((t) => {
if (
isLowErrorVerbosity &&
t.status === CoreToolCallStatus.Error &&
!t.isClientInitiated
) {
return false;
}
return !shouldHideToolCall({
displayName: t.name,
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
});
}),
[allToolCalls, isLowErrorVerbosity],
);
const config = useConfig();

View File

@@ -50,10 +50,14 @@ describe('ToolResultDisplay Overflow', () => {
await waitUntilReady();
// ResizeObserver might take a tick, though ToolGroupMessage calculates overflow synchronously
// In ASB mode the overflow hint can render before the scroll position
// settles. Wait for both the hint and the tail of the content so this
// snapshot is deterministic across slower CI runners.
await waitFor(() => {
const frame = lastFrame();
expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines');
expect(frame).toBeDefined();
expect(frame?.toLowerCase()).toContain('press ctrl+o to show more lines');
expect(frame).toContain('line 50');
});
const frame = lastFrame();