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/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f41ee20895..3b99bf3d07 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -47,6 +47,8 @@ interface HistoryItemDisplayProps { activeShellPtyId?: number | null; embeddedShellFocused?: boolean; availableTerminalHeightGemini?: number; + borderColor?: string; + borderDimColor?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -58,6 +60,8 @@ export const HistoryItemDisplay: React.FC = ({ activeShellPtyId, embeddedShellFocused, availableTerminalHeightGemini, + borderColor, + borderDimColor, }) => { const settings = useSettings(); const inlineThinkingMode = getInlineThinkingMode(settings); @@ -181,6 +185,8 @@ export const HistoryItemDisplay: React.FC = ({ embeddedShellFocused={embeddedShellFocused} borderTop={itemForDisplay.borderTop} borderBottom={itemForDisplay.borderBottom} + borderColor={borderColor ?? ''} + borderDimColor={borderDimColor ?? false} /> )} {itemForDisplay.type === 'compression' && ( diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 8b69a0f187..2403f0f330 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -6,7 +6,7 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; -import { MainContent } from './MainContent.js'; +import { MainContent, getToolGroupBorderAppearance } from './MainContent.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Box, Text } from 'ink'; import { act, useState, type JSX } from 'react'; @@ -18,6 +18,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 +77,208 @@ 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 + }); +}); + describe('MainContent', () => { const defaultMockUiState = { history: [ @@ -258,7 +461,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..c69be8858a 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -19,10 +19,85 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react'; import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; +import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { isShellTool } from './messages/ToolShared.js'; +import { theme } from '../semantic-colors.js'; +import type { + HistoryItem, + HistoryItemWithoutId, + HistoryItemToolGroup, +} from '../types.js'; +import type { UIState } from '../contexts/UIStateContext.js'; const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay); const MemoizedAppHeader = memo(AppHeader); +/** + * Calculates the border color and dimming state for a tool group message. + */ +export function getToolGroupBorderAppearance( + item: HistoryItem | HistoryItemWithoutId, + activeShellPtyId: number | null | undefined, + embeddedShellFocused: boolean | undefined, + allPendingItems: HistoryItemWithoutId[], + backgroundShells: UIState['backgroundShells'], +): { 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 = + 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) => + t.status !== CoreToolCallStatus.Success && + t.status !== CoreToolCallStatus.Error && + t.status !== CoreToolCallStatus.Cancelled, + ); + + const isEmbeddedShellFocused = toolsToInspect.some( + (t) => + isShellTool(t.name) && + t.status === CoreToolCallStatus.Executing && + t.ptyId === activeShellPtyId && + !!embeddedShellFocused, + ); + + const isShellCommand = toolsToInspect.some((t) => 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 borderColor = + (isShell && isPending) || isEmbeddedShellFocused + ? theme.ui.symbol + : isPending + ? theme.status.warning + : theme.border.default; + + const borderDimColor = isPending && (!isShell || !isEmbeddedShellFocused); + + return { borderColor, borderDimColor }; +} + // Limit Gemini messages to a very high number of lines to mitigate performance // issues in the worst case if we somehow get an enormous response from Gemini. // This threshold is arbitrary but should be high enough to never impact normal @@ -49,49 +124,77 @@ export const MainContent = () => { staticAreaMaxItemHeight, availableTerminalHeight, cleanUiDetailsVisible, + activePtyId, + embeddedShellFocused, + backgroundShells, } = uiState; const showHeaderDetails = cleanUiDetailsVisible; const historyItems = useMemo( () => - uiState.history.map((h) => ( - - )), + uiState.history.map((h) => { + const { borderColor, borderDimColor } = getToolGroupBorderAppearance( + h, + activePtyId, + embeddedShellFocused, + [], + backgroundShells, + ); + return ( + + ); + }), [ uiState.history, mainAreaWidth, staticAreaMaxItemHeight, uiState.slashCommands, + activePtyId, + embeddedShellFocused, + backgroundShells, ], ); const pendingItems = useMemo( () => ( - {pendingHistoryItems.map((item, i) => ( - - ))} + {pendingHistoryItems.map((item, i) => { + const { borderColor, borderDimColor } = getToolGroupBorderAppearance( + item, + activePtyId, + embeddedShellFocused, + pendingHistoryItems, + backgroundShells, + ); + return ( + + ); + })} {showConfirmationQueue && confirmingTool && ( )} @@ -103,8 +206,9 @@ export const MainContent = () => { isAlternateBuffer, availableTerminalHeight, mainAreaWidth, - uiState.activePtyId, - uiState.embeddedShellFocused, + activePtyId, + embeddedShellFocused, + backgroundShells, showConfirmationQueue, confirmingTool, ], @@ -130,6 +234,13 @@ export const MainContent = () => { /> ); } else if (item.type === 'history') { + const { borderColor, borderDimColor } = getToolGroupBorderAppearance( + item.item, + activePtyId, + embeddedShellFocused, + [], + backgroundShells, + ); return ( { item={item.item} isPending={false} commands={uiState.slashCommands} + borderColor={borderColor} + borderDimColor={borderDimColor} /> ); } else { @@ -151,6 +264,9 @@ export const MainContent = () => { mainAreaWidth, uiState.slashCommands, pendingItems, + activePtyId, + embeddedShellFocused, + backgroundShells, ], ); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index deef0cf91f..c2342a0d5b 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -42,6 +42,8 @@ describe('', () => { const baseProps = { groupId: 1, terminalWidth: 80, + borderColor: 'grey', + borderDimColor: false, }; const baseMockConfig = makeFakeConfig({ @@ -61,7 +63,12 @@ describe('', () => { { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -119,7 +126,12 @@ describe('', () => { { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -159,7 +171,12 @@ describe('', () => { { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -197,7 +214,12 @@ describe('', () => { { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -222,7 +244,12 @@ describe('', () => { { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -236,7 +263,12 @@ describe('', () => { { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: [] }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [], + }, + ], }, }, ); @@ -267,7 +299,12 @@ describe('', () => { { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -290,7 +327,12 @@ describe('', () => { { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -325,8 +367,14 @@ describe('', () => { config: baseMockConfig, uiState: { pendingHistoryItems: [ - { type: 'tool_group', tools: toolCalls1 }, - { type: 'tool_group', tools: toolCalls2 }, + { + type: 'tool_group', + tools: toolCalls1, + }, + { + type: 'tool_group', + tools: toolCalls2, + }, ], }, }, @@ -349,7 +397,12 @@ describe('', () => { { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -371,7 +424,12 @@ describe('', () => { { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); @@ -405,7 +463,12 @@ describe('', () => { { config: baseMockConfig, uiState: { - pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingHistoryItems: [ + { + type: 'tool_group', + tools: toolCalls, + }, + ], }, }, ); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 18179b6a92..aade976e0b 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -13,11 +13,8 @@ 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'; @@ -31,6 +28,8 @@ interface ToolGroupMessageProps { onShellInputSubmit?: (input: string) => void; borderTop?: boolean; borderBottom?: boolean; + borderColor: string; + borderDimColor: boolean; } // Main component renders the border and maps the tools using ToolMessage @@ -44,6 +43,8 @@ export const ToolGroupMessage: React.FC = ({ embeddedShellFocused, borderTop: borderTopOverride, borderBottom: borderBottomOverride, + borderColor, + borderDimColor, }) => { // Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations). const toolCalls = useMemo( @@ -80,31 +81,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), diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx index bc805d1f1c..f52ee6892b 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx @@ -35,6 +35,8 @@ describe('ToolResultDisplay Overflow', () => { const { lastFrame } = renderWithProviders( { data={['item1']} renderItem={() => ( { data={['item1']} renderItem={() => ( { // 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..1d0ab40ce9 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -23,7 +23,10 @@ import { */ export function mapToDisplay( toolOrTools: ToolCall[] | ToolCall, - options: { borderTop?: boolean; borderBottom?: boolean } = {}, + options: { + borderTop?: boolean; + borderBottom?: boolean; + } = {}, ): HistoryItemToolGroup { const toolCalls = Array.isArray(toolOrTools) ? toolOrTools : [toolOrTools]; const { borderTop, borderBottom } = options; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 8b5a312d37..d63f86cd70 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..1d4289f335 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -78,6 +78,11 @@ import { type TrackedWaitingToolCall, type TrackedExecutingToolCall, } from './useToolScheduler.js'; +import { theme } from '../semantic-colors.js'; +import { + isShellTool, + isThisShellFocused, +} from '../components/messages/ToolShared.js'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { useSessionStats } from '../contexts/SessionContext.js'; @@ -120,6 +125,42 @@ function showCitations(settings: LoadedSettings): boolean { return true; } +export function getToolGroupBorderAppearance( + toolCalls: TrackedToolCall[], + activeShellPtyId: number | null, + embeddedShellFocused: boolean, +): { borderColor: string; borderDimColor: boolean } { + const hasPending = toolCalls.some( + (t) => + t.status !== 'success' && + t.status !== 'error' && + t.status !== 'cancelled', + ); + + const isEmbeddedShellFocused = toolCalls.some((t) => + isThisShellFocused( + t.request.name, + t.status, + t.status === 'executing' ? t.pid : undefined, + activeShellPtyId, + embeddedShellFocused, + ), + ); + + const isShellCommand = toolCalls.some((t) => isShellTool(t.request.name)); + const borderColor = + (isShellCommand && hasPending) || isEmbeddedShellFocused + ? theme.ui.symbol + : hasPending + ? theme.status.warning + : theme.border.default; + + const borderDimColor = + hasPending && (!isShellCommand || !isEmbeddedShellFocused); + + return { borderColor, borderDimColor }; +} + /** * Calculates the current streaming state based on tool call status and responding flag. */ @@ -250,6 +291,8 @@ export const useGeminiStream = ( mapTrackedToolCallsToDisplay(toolsToPush as TrackedToolCall[], { borderTop: isFirstToolInGroupRef.current, borderBottom: true, + borderColor: theme.border.default, + borderDimColor: false, }), ); } @@ -290,6 +333,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 +429,11 @@ export const useGeminiStream = ( const historyItem = mapTrackedToolCallsToDisplay(tc, { borderTop: isFirst, borderBottom: isLastInBatch, + ...getToolGroupBorderAppearance( + toolCalls, + activeShellPtyId, + !!isShellFocused, + ), }); addItem(historyItem); isFirst = false; @@ -362,6 +449,8 @@ export const useGeminiStream = ( setPushedToolCallIds, setIsFirstToolInGroup, addItem, + activeShellPtyId, + isShellFocused, ]); const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => { @@ -371,11 +460,18 @@ export const useGeminiStream = ( const items: HistoryItemWithoutId[] = []; + const appearance = getToolGroupBorderAppearance( + toolCalls, + activeShellPtyId, + !!isShellFocused, + ); + 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 +519,12 @@ 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]); const lastQueryRef = useRef(null); const lastPromptIdRef = useRef(null); @@ -448,36 +536,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);