diff --git a/packages/cli/src/ui/components/messages/ToolGroupDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupDisplay.test.tsx new file mode 100644 index 0000000000..96cefc0962 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolGroupDisplay.test.tsx @@ -0,0 +1,304 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderWithProviders } from '../../../test-utils/render.js'; +import { createMockSettings } from '../../../test-utils/settings.js'; +import { ToolGroupDisplay } from './ToolGroupDisplay.js'; +import { + CoreToolCallStatus, + UPDATE_TOPIC_DISPLAY_NAME, +} from '@google/gemini-cli-core'; +import type { + HistoryItemToolDisplayGroup, + ToolDisplayItem, +} from '../../types.js'; + +describe('', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const createToolItem = ( + overrides: Partial = {}, + ): ToolDisplayItem => ({ + status: CoreToolCallStatus.Success, + name: 'test-tool', + description: 'Test description', + ...overrides, + }); + + const createHistoryItem = ( + tools: ToolDisplayItem[], + overrides: Partial = {}, + ): HistoryItemToolDisplayGroup => ({ + type: 'tool_display_group', + tools, + borderColor: 'gray', + borderDimColor: true, + borderTop: true, + borderBottom: true, + ...overrides, + }); + + const fullVerbositySettings = createMockSettings({ + ui: { errorVerbosity: 'full', compactToolOutput: false }, + }); + const compactSettings = createMockSettings({ + ui: { compactToolOutput: true }, + }); + + describe('Golden Snapshots', () => { + it('renders notices at the top (hoisting)', async () => { + const tools = [ + createToolItem({ name: 'Tool A', format: 'box' }), + createToolItem({ + name: UPDATE_TOPIC_DISPLAY_NAME, + description: 'New Topic', + format: 'notice', + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + // Notice should be before Tool A + expect(output.indexOf(UPDATE_TOPIC_DISPLAY_NAME)).toBeLessThan( + output.indexOf('Tool A'), + ); + expect(output).toMatchSnapshot(); + }); + + it('renders in compact mode (no box borders)', async () => { + const tools = [ + createToolItem({ name: 'Tool A' }), + createToolItem({ name: 'Tool B' }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: compactSettings }, + ); + + const output = lastFrame(); + // Should not contain box drawing characters for the outer box + expect(output).not.toContain('╭'); + expect(output).not.toContain('╰'); + expect(output).toMatchSnapshot(); + }); + + it('renders in boxed mode (full verbosity)', async () => { + const tools = [createToolItem({ name: 'Tool A' })]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + expect(output).toContain('╭'); + expect(output).toContain('╰'); + expect(output).toMatchSnapshot(); + }); + + it('renders standalone notices without a box', async () => { + const tools = [ + createToolItem({ + name: 'Notice Only', + format: 'notice', + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + expect(output).not.toContain('╭'); + expect(output).toMatchSnapshot(); + }); + + it('renders error message when display info is missing', async () => { + // Create an item that effectively has no display properties + const tools = [ + { + status: CoreToolCallStatus.Executing, + originalRequestName: 'missing-tool', + } as ToolDisplayItem, + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('Error: Tool display missing'); + expect(output).toMatchSnapshot(); + }); + + it('hides tools awaiting approval (confirming)', async () => { + const tools = [ + createToolItem({ + name: 'Confirming Tool', + status: CoreToolCallStatus.AwaitingApproval, + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + ); + + // Should render nothing (null) + expect(lastFrame({ allowEmpty: true })).toBe(''); + }); + }); + + describe('Result Formatting', () => { + it('renders text results with summary below', async () => { + const tools = [ + createToolItem({ + result: { type: 'text', text: 'Detailed output' }, + resultSummary: 'Short summary', + format: 'box', + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + expect(output).toContain('Detailed output'); + expect(output).toContain('Short summary'); + // Summary should be below detailed output + expect(output.indexOf('Detailed output')).toBeLessThan( + output.indexOf('Short summary'), + ); + expect(output).toMatchSnapshot(); + }); + + it('renders compact tools with summary on same line', async () => { + const tools = [ + createToolItem({ + resultSummary: 'Success summary', + format: 'compact', + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('→ Success summary'); + expect(output).toMatchSnapshot(); + }); + + it('renders placeholder for diff results', async () => { + const tools = [ + createToolItem({ + result: { + type: 'diff', + beforeText: 'old', + afterText: 'new', + path: 'file.ts', + }, + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + expect(output).toContain('[Diff Display: 3 -> 3 chars]'); + expect(output).toMatchSnapshot(); + }); + + it('renders placeholder for terminal results', async () => { + const tools = [ + createToolItem({ + result: { type: 'terminal' }, + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + expect(lastFrame()).toContain('[Terminal Output]'); + }); + + it('renders placeholder for agent results', async () => { + const tools = [ + createToolItem({ + result: { type: 'agent', threadId: 'thread-123' }, + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + expect(lastFrame()).toContain('[Subagent: thread-123]'); + }); + }); + + describe('Border & Margin Logic', () => { + it('forces top border on box when it follows a notice', async () => { + const tools = [ + createToolItem({ name: 'Notice', format: 'notice' }), + createToolItem({ name: 'Tool in Box', format: 'box' }), + ]; + // Even if item.borderTop is false (continuing a group), + // the box should have a top border because it follows a notice. + const item = createHistoryItem(tools, { borderTop: false }); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + expect(output).toContain('Notice'); + expect(output).toContain('╭'); // Top border for the box + expect(output).toMatchSnapshot(); + }); + + it('applies bottom margin in compact mode when group is at boundary', async () => { + const tools = [createToolItem({ name: 'Compact Tool' })]; + const item = createHistoryItem(tools, { borderBottom: true }); + + const { lastFrame } = await renderWithProviders( + , + { settings: compactSettings }, + ); + + // This is hard to assert via string check, but ensure match snapshot + // captures the vertical spacing. + expect(lastFrame()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx b/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx index 8e937964c5..137e6391d4 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx @@ -6,6 +6,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; +import { CoreToolCallStatus } from '@google/gemini-cli-core'; import type { HistoryItem, HistoryItemWithoutId, @@ -35,14 +36,23 @@ export const ToolGroupDisplay: React.FC = ({ const { tools, borderColor, borderDimColor, borderTop, borderBottom } = item as HistoryItemToolDisplayGroup; - const noticeTools = tools.filter((t) => t.format === 'notice'); - const otherTools = tools.filter( + const visibleTools = tools.filter( + (t) => t.status !== CoreToolCallStatus.AwaitingApproval, + ); + + const noticeTools = visibleTools.filter((t) => t.format === 'notice'); + const otherTools = visibleTools.filter( (t) => t.format !== 'notice' && t.format !== 'hidden', ); const hasOtherTools = otherTools.length > 0; const isClosingSlice = tools.length === 0 && borderBottom; + // If no tools are visible and it's not an explicit closing slice, hide the group + if (visibleTools.length === 0 && !isClosingSlice) { + return null; + } + // Standard view behavior: If compact mode is enabled, non-notice tools // are typically rendered without an outer box. const shouldShowBox =