From fe65d562de8264b5ae13dcb84214a801225c96d2 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Tue, 17 Feb 2026 18:41:43 -0800 Subject: [PATCH] Fix bottom border color (#19266) --- packages/cli/src/ui/App.test.tsx | 14 +- .../AlternateBufferQuittingDisplay.tsx | 2 - .../src/ui/components/HistoryItemDisplay.tsx | 8 +- .../src/ui/components/MainContent.test.tsx | 207 +++++++++++++++++- .../cli/src/ui/components/MainContent.tsx | 4 - .../messages/ShellToolMessage.test.tsx | 48 ++-- .../components/messages/ShellToolMessage.tsx | 9 +- .../messages/ToolGroupMessage.test.tsx | 161 +++++++++++--- .../components/messages/ToolGroupMessage.tsx | 79 +++---- .../ToolResultDisplayOverflow.test.tsx | 2 +- .../ToolStickyHeaderRegression.test.tsx | 4 +- .../src/ui/hooks/atCommandProcessor.test.ts | 8 +- packages/cli/src/ui/hooks/toolMapping.ts | 11 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 1 + packages/cli/src/ui/hooks/useGeminiStream.ts | 110 ++++++---- packages/cli/src/ui/types.ts | 2 + packages/cli/src/ui/utils/borderStyles.ts | 123 +++++++++++ 17 files changed, 632 insertions(+), 161 deletions(-) create mode 100644 packages/cli/src/ui/utils/borderStyles.ts diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index a668734328..2e40d35260 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -216,8 +216,18 @@ describe('App', () => { const stateWithConfirmingTool = { ...mockUIState, - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], - pendingGeminiHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], + pendingGeminiHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], } as UIState; const configWithExperiment = makeFakeConfig(); diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx index 8e0ede2e09..7b81875c7b 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx @@ -53,8 +53,6 @@ export const AlternateBufferQuittingDisplay = () => { terminalWidth={uiState.mainAreaWidth} item={{ ...item, id: 0 }} isPending={true} - activeShellPtyId={uiState.activePtyId} - embeddedShellFocused={uiState.embeddedShellFocused} /> ))} {showPromptedTool && ( diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f41ee20895..8735566641 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -44,8 +44,6 @@ interface HistoryItemDisplayProps { terminalWidth: number; isPending: boolean; commands?: readonly SlashCommand[]; - activeShellPtyId?: number | null; - embeddedShellFocused?: boolean; availableTerminalHeightGemini?: number; } @@ -55,8 +53,6 @@ export const HistoryItemDisplay: React.FC = ({ terminalWidth, isPending, commands, - activeShellPtyId, - embeddedShellFocused, availableTerminalHeightGemini, }) => { const settings = useSettings(); @@ -173,12 +169,10 @@ export const HistoryItemDisplay: React.FC = ({ )} {itemForDisplay.type === 'tool_group' && ( diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 8b69a0f187..427a1c821e 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -7,6 +7,7 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { MainContent } from './MainContent.js'; +import { getToolGroupBorderAppearance } from '../utils/borderStyles.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Box, Text } from 'ink'; import { act, useState, type JSX } from 'react'; @@ -18,6 +19,7 @@ import { type UIState, } from '../contexts/UIStateContext.js'; import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { type IndividualToolCallDisplay } from '../types.js'; // Mock dependencies vi.mock('../contexts/SettingsContext.js', async () => { @@ -76,6 +78,209 @@ vi.mock('./shared/ScrollableList.js', () => ({ SCROLL_TO_ITEM_END: 0, })); +import { theme } from '../semantic-colors.js'; +import { type BackgroundShell } from '../hooks/shellReducer.js'; + +describe('getToolGroupBorderAppearance', () => { + const mockBackgroundShells = new Map(); + const activeShellPtyId = 123; + + it('returns default empty values for non-tool_group items', () => { + const item = { type: 'user' as const, text: 'Hello', id: 1 }; + const result = getToolGroupBorderAppearance( + item, + null, + false, + [], + mockBackgroundShells, + ); + expect(result).toEqual({ borderColor: '', borderDimColor: false }); + }); + + it('inspects only the last pending tool_group item if current has no tools', () => { + const item = { type: 'tool_group' as const, tools: [], id: 1 }; + const pendingItems = [ + { + type: 'tool_group' as const, + tools: [ + { + callId: '1', + name: 'some_tool', + description: '', + status: CoreToolCallStatus.Executing, + ptyId: undefined, + resultDisplay: undefined, + confirmationDetails: undefined, + } as IndividualToolCallDisplay, + ], + }, + { + type: 'tool_group' as const, + tools: [ + { + callId: '2', + name: 'other_tool', + description: '', + status: CoreToolCallStatus.Success, + ptyId: undefined, + resultDisplay: undefined, + confirmationDetails: undefined, + } as IndividualToolCallDisplay, + ], + }, + ]; + + // Only the last item (Success) should be inspected, so hasPending = false. + // The previous item was Executing (pending) but it shouldn't be counted. + const result = getToolGroupBorderAppearance( + item, + null, + false, + pendingItems, + mockBackgroundShells, + ); + expect(result).toEqual({ + borderColor: theme.border.default, + borderDimColor: false, + }); + }); + + it('returns default border for completed normal tools', () => { + const item = { + type: 'tool_group' as const, + tools: [ + { + callId: '1', + name: 'some_tool', + description: '', + status: CoreToolCallStatus.Success, + ptyId: undefined, + resultDisplay: undefined, + confirmationDetails: undefined, + } as IndividualToolCallDisplay, + ], + id: 1, + }; + const result = getToolGroupBorderAppearance( + item, + null, + false, + [], + mockBackgroundShells, + ); + expect(result).toEqual({ + borderColor: theme.border.default, + borderDimColor: false, + }); + }); + + it('returns warning border for pending normal tools', () => { + const item = { + type: 'tool_group' as const, + tools: [ + { + callId: '1', + name: 'some_tool', + description: '', + status: CoreToolCallStatus.Executing, + ptyId: undefined, + resultDisplay: undefined, + confirmationDetails: undefined, + } as IndividualToolCallDisplay, + ], + id: 1, + }; + const result = getToolGroupBorderAppearance( + item, + null, + false, + [], + mockBackgroundShells, + ); + expect(result).toEqual({ + borderColor: theme.status.warning, + borderDimColor: true, + }); + }); + + it('returns symbol border for executing shell commands', () => { + const item = { + type: 'tool_group' as const, + tools: [ + { + callId: '1', + name: SHELL_COMMAND_NAME, + description: '', + status: CoreToolCallStatus.Executing, + ptyId: activeShellPtyId, + resultDisplay: undefined, + confirmationDetails: undefined, + } as IndividualToolCallDisplay, + ], + id: 1, + }; + // While executing shell commands, it's dim false, border symbol + const result = getToolGroupBorderAppearance( + item, + activeShellPtyId, + true, + [], + mockBackgroundShells, + ); + expect(result).toEqual({ + borderColor: theme.ui.symbol, + borderDimColor: false, + }); + }); + + it('returns symbol border and dims color for background executing shell command when another shell is active', () => { + const item = { + type: 'tool_group' as const, + tools: [ + { + callId: '1', + name: SHELL_COMMAND_NAME, + description: '', + status: CoreToolCallStatus.Executing, + ptyId: 456, // Different ptyId, not active + resultDisplay: undefined, + confirmationDetails: undefined, + } as IndividualToolCallDisplay, + ], + id: 1, + }; + const result = getToolGroupBorderAppearance( + item, + activeShellPtyId, + false, + [], + mockBackgroundShells, + ); + expect(result).toEqual({ + borderColor: theme.ui.symbol, + borderDimColor: true, + }); + }); + + it('handles empty tools with active shell turn (isCurrentlyInShellTurn)', () => { + const item = { type: 'tool_group' as const, tools: [], id: 1 }; + + // active shell turn + const result = getToolGroupBorderAppearance( + item, + activeShellPtyId, + true, + [], + mockBackgroundShells, + ); + // Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true + // so it counts as pending shell. + expect(result.borderColor).toEqual(theme.ui.symbol); + // It shouldn't be dim because there are no tools to say it isEmbeddedShellFocused = false + expect(result.borderDimColor).toBe(false); + }); +}); + describe('MainContent', () => { const defaultMockUiState = { history: [ @@ -258,7 +463,7 @@ describe('MainContent', () => { history: [], pendingHistoryItems: [ { - type: 'tool_group' as const, + type: 'tool_group', id: 1, tools: [ { diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 1dcc32ffd4..cba57756e3 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -88,8 +88,6 @@ export const MainContent = () => { terminalWidth={mainAreaWidth} item={{ ...item, id: 0 }} isPending={true} - activeShellPtyId={uiState.activePtyId} - embeddedShellFocused={uiState.embeddedShellFocused} /> ))} {showConfirmationQueue && confirmingTool && ( @@ -103,8 +101,6 @@ export const MainContent = () => { isAlternateBuffer, availableTerminalHeight, mainAreaWidth, - uiState.activePtyId, - uiState.embeddedShellFocused, showConfirmationQueue, confirmingTool, ], diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index c698445f8f..c1b2ce53b3 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -88,20 +88,16 @@ describe('', () => { CoreToolCallStatus.Executing, ); updateStatus = setStatus; - return ( - - ); + return ; }; const { lastFrame } = renderWithProviders(, { uiActions, - uiState: { streamingState: StreamingState.Idle }, + uiState: { + streamingState: StreamingState.Idle, + embeddedShellFocused: true, + activePtyId: 1, + }, }); // Verify it is initially focused @@ -143,21 +139,29 @@ describe('', () => { 'renders in Alternate Buffer mode while focused', { status: CoreToolCallStatus.Executing, - embeddedShellFocused: true, - activeShellPtyId: 1, ptyId: 1, }, - { useAlternateBuffer: true }, + { + useAlternateBuffer: true, + uiState: { + embeddedShellFocused: true, + activePtyId: 1, + }, + }, ], [ 'renders in Alternate Buffer mode while unfocused', { status: CoreToolCallStatus.Executing, - embeddedShellFocused: false, - activeShellPtyId: 1, ptyId: 1, }, - { useAlternateBuffer: true }, + { + useAlternateBuffer: true, + uiState: { + embeddedShellFocused: false, + activePtyId: 1, + }, + }, ], ])('%s', async (_, props, options) => { const { lastFrame } = renderShell(props, options); @@ -199,12 +203,16 @@ describe('', () => { resultDisplay: LONG_OUTPUT, renderOutputAsMarkdown: false, availableTerminalHeight, - activeShellPtyId: 1, - ptyId: focused ? 1 : 2, + ptyId: 1, status: CoreToolCallStatus.Executing, - embeddedShellFocused: focused, }, - { useAlternateBuffer: true }, + { + useAlternateBuffer: true, + uiState: { + activePtyId: focused ? 1 : 2, + embeddedShellFocused: focused, + }, + }, ); await waitFor(() => { diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index cb6f27b317..50af3bc1e6 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -29,9 +29,9 @@ import { import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core'; +import { useUIState } from '../../contexts/UIStateContext.js'; + export interface ShellToolMessageProps extends ToolMessageProps { - activeShellPtyId?: number | null; - embeddedShellFocused?: boolean; config?: Config; } @@ -52,10 +52,6 @@ export const ShellToolMessage: React.FC = ({ renderOutputAsMarkdown = true, - activeShellPtyId, - - embeddedShellFocused, - ptyId, config, @@ -66,6 +62,7 @@ export const ShellToolMessage: React.FC = ({ borderDimColor, }) => { + const { activePtyId: activeShellPtyId, embeddedShellFocused } = useUIState(); const isAlternateBuffer = useAlternateBuffer(); const isThisShellFocused = checkIsShellFocused( name, diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index deef0cf91f..b02b34ec4e 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -7,7 +7,11 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { ToolGroupMessage } from './ToolGroupMessage.js'; -import type { IndividualToolCallDisplay } from '../../types.js'; +import type { + HistoryItem, + HistoryItemWithoutId, + IndividualToolCallDisplay, +} from '../../types.js'; import { Scrollable } from '../shared/Scrollable.js'; import { makeFakeConfig, @@ -40,10 +44,17 @@ describe('', () => { }); const baseProps = { - groupId: 1, terminalWidth: 80, }; + const createItem = ( + tools: IndividualToolCallDisplay[], + ): HistoryItem | HistoryItemWithoutId => ({ + id: 1, + type: 'tool_group', + tools, + }); + const baseMockConfig = makeFakeConfig({ model: 'gemini-pro', targetDir: os.tmpdir(), @@ -56,12 +67,18 @@ describe('', () => { describe('Golden Snapshots', () => { it('renders single successful tool call', () => { const toolCalls = [createToolCall()]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -81,9 +98,10 @@ describe('', () => { }, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig }, ); @@ -113,13 +131,19 @@ describe('', () => { status: CoreToolCallStatus.Error, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -153,13 +177,19 @@ describe('', () => { status: CoreToolCallStatus.Scheduled, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -188,16 +218,23 @@ describe('', () => { resultDisplay: 'More output here', }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -213,16 +250,23 @@ describe('', () => { 'This is a very long description that might cause wrapping issues', }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -231,12 +275,19 @@ describe('', () => { }); it('renders empty tool calls array', () => { + const toolCalls: IndividualToolCallDisplay[] = []; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: [] }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [], + }, + ], }, }, ); @@ -260,14 +311,20 @@ describe('', () => { resultDisplay: 'line1\nline2', }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -285,12 +342,18 @@ describe('', () => { outputFile: '/path/to/output.txt', }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -307,6 +370,7 @@ describe('', () => { resultDisplay: 'line1\nline2\nline3\nline4\nline5', }), ]; + const item1 = createItem(toolCalls1); const toolCalls2 = [ createToolCall({ callId: '2', @@ -315,18 +379,33 @@ describe('', () => { resultDisplay: 'line1', }), ]; + const item2 = createItem(toolCalls2); const { lastFrame, unmount } = renderWithProviders( - - + + , { config: baseMockConfig, uiState: { pendingHistoryItems: [ - { type: 'tool_group', tools: toolCalls1 }, - { type: 'tool_group', tools: toolCalls2 }, + { + type: 'tool_group', + tools: toolCalls1, + }, + { + type: 'tool_group', + tools: toolCalls2, + }, ], }, }, @@ -344,12 +423,18 @@ describe('', () => { status: CoreToolCallStatus.Success, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -366,12 +451,18 @@ describe('', () => { status: CoreToolCallStatus.Success, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -396,16 +487,23 @@ describe('', () => { resultDisplay: '', // No result }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( , { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -453,9 +551,10 @@ describe('', () => { resultDisplay, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig }, ); @@ -481,9 +580,10 @@ describe('', () => { status: CoreToolCallStatus.Scheduled, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig }, ); @@ -502,10 +602,12 @@ describe('', () => { status: CoreToolCallStatus.Executing, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( , @@ -540,9 +642,10 @@ describe('', () => { approvalMode: mode, }), ]; + const item = createItem(toolCalls); const { lastFrame, unmount } = renderWithProviders( - , + , { config: baseMockConfig }, ); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 18179b6a92..4c8abc3189 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -7,27 +7,27 @@ import type React from 'react'; import { useMemo } from 'react'; import { Box, Text } from 'ink'; -import type { IndividualToolCallDisplay } from '../../types.js'; +import type { + HistoryItem, + HistoryItemWithoutId, + IndividualToolCallDisplay, +} from '../../types.js'; import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; import { ShellToolMessage } from './ShellToolMessage.js'; import { theme } from '../../semantic-colors.js'; import { useConfig } from '../../contexts/ConfigContext.js'; -import { isShellTool, isThisShellFocused } from './ToolShared.js'; -import { - CoreToolCallStatus, - shouldHideToolCall, -} from '@google/gemini-cli-core'; +import { isShellTool } from './ToolShared.js'; +import { shouldHideToolCall } from '@google/gemini-cli-core'; import { ShowMoreLines } from '../ShowMoreLines.js'; import { useUIState } from '../../contexts/UIStateContext.js'; +import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js'; interface ToolGroupMessageProps { - groupId: number; + item: HistoryItem | HistoryItemWithoutId; toolCalls: IndividualToolCallDisplay[]; availableTerminalHeight?: number; terminalWidth: number; - activeShellPtyId?: number | null; - embeddedShellFocused?: boolean; onShellInputSubmit?: (input: string) => void; borderTop?: boolean; borderBottom?: boolean; @@ -37,11 +37,10 @@ interface ToolGroupMessageProps { const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4; export const ToolGroupMessage: React.FC = ({ + item, toolCalls: allToolCalls, availableTerminalHeight, terminalWidth, - activeShellPtyId, - embeddedShellFocused, borderTop: borderTopOverride, borderBottom: borderBottomOverride, }) => { @@ -61,7 +60,31 @@ export const ToolGroupMessage: React.FC = ({ ); const config = useConfig(); - const { constrainHeight } = useUIState(); + const { + constrainHeight, + activePtyId, + embeddedShellFocused, + backgroundShells, + pendingHistoryItems, + } = useUIState(); + + const { borderColor, borderDimColor } = useMemo( + () => + getToolGroupBorderAppearance( + item, + activePtyId, + embeddedShellFocused, + pendingHistoryItems, + backgroundShells, + ), + [ + item, + activePtyId, + embeddedShellFocused, + pendingHistoryItems, + backgroundShells, + ], + ); // We HIDE tools that are still in pre-execution states (Confirming, Pending) // from the History log. They live in the Global Queue or wait for their turn. @@ -80,31 +103,6 @@ export const ToolGroupMessage: React.FC = ({ [toolCalls], ); - const isEmbeddedShellFocused = visibleToolCalls.some((t) => - isThisShellFocused( - t.name, - t.status, - t.ptyId, - activeShellPtyId, - embeddedShellFocused, - ), - ); - - const hasPending = !visibleToolCalls.every( - (t) => t.status === CoreToolCallStatus.Success, - ); - - const isShellCommand = toolCalls.some((t) => isShellTool(t.name)); - const borderColor = - (isShellCommand && hasPending) || isEmbeddedShellFocused - ? theme.ui.symbol - : hasPending - ? theme.status.warning - : theme.border.default; - - const borderDimColor = - hasPending && (!isShellCommand || !isEmbeddedShellFocused); - const staticHeight = /* border */ 2 + /* marginBottom */ 1; // If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools), @@ -175,12 +173,7 @@ export const ToolGroupMessage: React.FC = ({ width={contentWidth} > {isShellToolCall ? ( - + ) : ( )} diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx index bc805d1f1c..dd3184a19c 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx @@ -35,7 +35,7 @@ describe('ToolResultDisplay Overflow', () => { const { lastFrame } = renderWithProviders( { data={['item1']} renderItem={() => ( @@ -165,7 +165,7 @@ describe('ToolMessage Sticky Header Regression', () => { data={['item1']} renderItem={() => ( diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 9e3a056edd..eab3a82962 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -1286,7 +1286,9 @@ describe('handleAtCommand', () => { // Assert // It SHOULD be called for the tool_group expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ type: 'tool_group' }), + expect.objectContaining({ + type: 'tool_group', + }), 999, ); @@ -1343,7 +1345,9 @@ describe('handleAtCommand', () => { }); expect(containsResourceText).toBe(true); expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ type: 'tool_group' }), + expect.objectContaining({ + type: 'tool_group', + }), expect.any(Number), ); }); diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts index d921651e51..bd8718b9bd 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -23,10 +23,15 @@ import { */ export function mapToDisplay( toolOrTools: ToolCall[] | ToolCall, - options: { borderTop?: boolean; borderBottom?: boolean } = {}, + options: { + borderTop?: boolean; + borderBottom?: boolean; + borderColor?: string; + borderDimColor?: boolean; + } = {}, ): HistoryItemToolGroup { const toolCalls = Array.isArray(toolOrTools) ? toolOrTools : [toolOrTools]; - const { borderTop, borderBottom } = options; + const { borderTop, borderBottom, borderColor, borderDimColor } = options; const toolDisplays = toolCalls.map((call): IndividualToolCallDisplay => { let description: string; @@ -104,5 +109,7 @@ export function mapToDisplay( tools: toolDisplays, borderTop, borderBottom, + borderColor, + borderDimColor, }; } diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index eb94b2f51c..54c0c2231f 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -44,6 +44,7 @@ import type { Part, PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import type { SlashCommandProcessorResult } from '../types.js'; import { MessageType, StreamingState } from '../types.js'; + import type { LoadedSettings } from '../../config/settings.js'; // --- MOCKS --- diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index f23574858f..5fc7d628ac 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -78,6 +78,8 @@ import { type TrackedWaitingToolCall, type TrackedExecutingToolCall, } from './useToolScheduler.js'; +import { theme } from '../semantic-colors.js'; +import { getToolGroupBorderAppearance } from '../utils/borderStyles.js'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { useSessionStats } from '../contexts/SessionContext.js'; @@ -250,6 +252,8 @@ export const useGeminiStream = ( mapTrackedToolCallsToDisplay(toolsToPush as TrackedToolCall[], { borderTop: isFirstToolInGroupRef.current, borderBottom: true, + borderColor: theme.border.default, + borderDimColor: false, }), ); } @@ -290,6 +294,45 @@ export const useGeminiStream = ( getPreferredEditor, ); + const activeToolPtyId = useMemo(() => { + const executingShellTool = toolCalls.find( + (tc) => + tc.status === 'executing' && tc.request.name === 'run_shell_command', + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid; + }, [toolCalls]); + + const onExec = useCallback(async (done: Promise) => { + setIsResponding(true); + await done; + setIsResponding(false); + }, []); + + const { + handleShellCommand, + activeShellPtyId, + lastShellOutputTime, + backgroundShellCount, + isBackgroundShellVisible, + toggleBackgroundShell, + backgroundCurrentShell, + registerBackgroundShell, + dismissBackgroundShell, + backgroundShells, + } = useShellCommandProcessor( + addItem, + setPendingHistoryItem, + onExec, + onDebugMessage, + config, + geminiClient, + setShellInputFocused, + terminalWidth, + terminalHeight, + activeToolPtyId, + ); + const streamingState = useMemo( () => calculateStreamingState(isResponding, toolCalls), [isResponding, toolCalls], @@ -347,6 +390,13 @@ export const useGeminiStream = ( const historyItem = mapTrackedToolCallsToDisplay(tc, { borderTop: isFirst, borderBottom: isLastInBatch, + ...getToolGroupBorderAppearance( + { type: 'tool_group', tools: toolCalls }, + activeShellPtyId, + !!isShellFocused, + [], + backgroundShells, + ), }); addItem(historyItem); isFirst = false; @@ -362,6 +412,9 @@ export const useGeminiStream = ( setPushedToolCallIds, setIsFirstToolInGroup, addItem, + activeShellPtyId, + isShellFocused, + backgroundShells, ]); const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => { @@ -371,11 +424,20 @@ export const useGeminiStream = ( const items: HistoryItemWithoutId[] = []; + const appearance = getToolGroupBorderAppearance( + { type: 'tool_group', tools: toolCalls }, + activeShellPtyId, + !!isShellFocused, + [], + backgroundShells, + ); + if (remainingTools.length > 0) { items.push( mapTrackedToolCallsToDisplay(remainingTools, { borderTop: pushedToolCallIds.size === 0, borderBottom: false, // Stay open to connect with the slice below + ...appearance, }), ); } @@ -423,20 +485,18 @@ export const useGeminiStream = ( tools: [] as IndividualToolCallDisplay[], borderTop: false, borderBottom: true, + ...appearance, }); } return items; - }, [toolCalls, pushedToolCallIds]); - - const activeToolPtyId = useMemo(() => { - const executingShellTool = toolCalls.find( - (tc) => - tc.status === 'executing' && tc.request.name === 'run_shell_command', - ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid; - }, [toolCalls]); + }, [ + toolCalls, + pushedToolCallIds, + activeShellPtyId, + isShellFocused, + backgroundShells, + ]); const lastQueryRef = useRef(null); const lastPromptIdRef = useRef(null); @@ -448,36 +508,6 @@ export const useGeminiStream = ( onComplete: (result: { userSelection: 'disable' | 'keep' }) => void; } | null>(null); - const onExec = useCallback(async (done: Promise) => { - setIsResponding(true); - await done; - setIsResponding(false); - }, []); - - const { - handleShellCommand, - activeShellPtyId, - lastShellOutputTime, - backgroundShellCount, - isBackgroundShellVisible, - toggleBackgroundShell, - backgroundCurrentShell, - registerBackgroundShell, - dismissBackgroundShell, - backgroundShells, - } = useShellCommandProcessor( - addItem, - setPendingHistoryItem, - onExec, - onDebugMessage, - config, - geminiClient, - setShellInputFocused, - terminalWidth, - terminalHeight, - activeToolPtyId, - ); - const activePtyId = activeShellPtyId || activeToolPtyId; const prevActiveShellPtyIdRef = useRef(null); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 8481cca71f..290ab63417 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -221,6 +221,8 @@ export type HistoryItemToolGroup = HistoryItemBase & { tools: IndividualToolCallDisplay[]; borderTop?: boolean; borderBottom?: boolean; + borderColor?: string; + borderDimColor?: boolean; }; export type HistoryItemUserShell = HistoryItemBase & { diff --git a/packages/cli/src/ui/utils/borderStyles.ts b/packages/cli/src/ui/utils/borderStyles.ts new file mode 100644 index 0000000000..b3a0cb52bb --- /dev/null +++ b/packages/cli/src/ui/utils/borderStyles.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { isShellTool } from '../components/messages/ToolShared.js'; +import { theme } from '../semantic-colors.js'; +import type { + HistoryItem, + HistoryItemWithoutId, + HistoryItemToolGroup, + IndividualToolCallDisplay, +} from '../types.js'; +import type { BackgroundShell } from '../hooks/shellReducer.js'; +import type { TrackedToolCall } from '../hooks/useToolScheduler.js'; + +function isTrackedToolCall( + tool: IndividualToolCallDisplay | TrackedToolCall, +): tool is TrackedToolCall { + return 'request' in tool; +} + +/** + * Calculates the border color and dimming state for a tool group message. + */ +export function getToolGroupBorderAppearance( + item: + | HistoryItem + | HistoryItemWithoutId + | { type: 'tool_group'; tools: TrackedToolCall[] }, + activeShellPtyId: number | null | undefined, + embeddedShellFocused: boolean | undefined, + allPendingItems: HistoryItemWithoutId[] = [], + backgroundShells: Map = new Map(), +): { borderColor: string; borderDimColor: boolean } { + if (item.type !== 'tool_group') { + return { borderColor: '', borderDimColor: false }; + } + + // If this item has no tools, it's a closing slice for the current batch. + // We need to look at the last pending item to determine the batch's appearance. + const toolsToInspect: Array = + item.tools.length > 0 + ? item.tools + : allPendingItems + .filter( + (i): i is HistoryItemToolGroup => + i !== null && i !== undefined && i.type === 'tool_group', + ) + .slice(-1) + .flatMap((i) => i.tools); + + const hasPending = toolsToInspect.some((t) => { + if (isTrackedToolCall(t)) { + return ( + t.status !== 'success' && + t.status !== 'error' && + t.status !== 'cancelled' + ); + } else { + return ( + t.status !== CoreToolCallStatus.Success && + t.status !== CoreToolCallStatus.Error && + t.status !== CoreToolCallStatus.Cancelled + ); + } + }); + + const isEmbeddedShellFocused = toolsToInspect.some((t) => { + if (isTrackedToolCall(t)) { + return ( + isShellTool(t.request.name) && + t.status === 'executing' && + t.pid === activeShellPtyId && + !!embeddedShellFocused + ); + } else { + return ( + isShellTool(t.name) && + t.status === CoreToolCallStatus.Executing && + t.ptyId === activeShellPtyId && + !!embeddedShellFocused + ); + } + }); + + const isShellCommand = toolsToInspect.some((t) => { + if (isTrackedToolCall(t)) { + return isShellTool(t.request.name); + } else { + return isShellTool(t.name); + } + }); + + // If we have an active PTY that isn't a background shell, then the current + // pending batch is definitely a shell batch. + const isCurrentlyInShellTurn = + !!activeShellPtyId && !backgroundShells.has(activeShellPtyId); + + const isShell = + isShellCommand || (item.tools.length === 0 && isCurrentlyInShellTurn); + const isPending = + hasPending || (item.tools.length === 0 && isCurrentlyInShellTurn); + + const isEffectivelyFocused = + isEmbeddedShellFocused || + (item.tools.length === 0 && + isCurrentlyInShellTurn && + !!embeddedShellFocused); + + const borderColor = + (isShell && isPending) || isEffectivelyFocused + ? theme.ui.symbol + : isPending + ? theme.status.warning + : theme.border.default; + + const borderDimColor = isPending && (!isShell || !isEffectivelyFocused); + + return { borderColor, borderDimColor }; +}