Merge branch 'main' into fix_commands

This commit is contained in:
g-samroberts
2026-02-17 20:54:39 -08:00
committed by GitHub
17 changed files with 632 additions and 161 deletions

View File

@@ -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();

View File

@@ -53,8 +53,6 @@ export const AlternateBufferQuittingDisplay = () => {
terminalWidth={uiState.mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
{showPromptedTool && (

View File

@@ -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<HistoryItemDisplayProps> = ({
terminalWidth,
isPending,
commands,
activeShellPtyId,
embeddedShellFocused,
availableTerminalHeightGemini,
}) => {
const settings = useSettings();
@@ -173,12 +169,10 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
)}
{itemForDisplay.type === 'tool_group' && (
<ToolGroupMessage
item={itemForDisplay}
toolCalls={itemForDisplay.tools}
groupId={itemForDisplay.id}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
borderTop={itemForDisplay.borderTop}
borderBottom={itemForDisplay.borderBottom}
/>

View File

@@ -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<number, BackgroundShell>();
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: [
{

View File

@@ -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,
],

View File

@@ -88,20 +88,16 @@ describe('<ShellToolMessage />', () => {
CoreToolCallStatus.Executing,
);
updateStatus = setStatus;
return (
<ShellToolMessage
{...baseProps}
status={status}
embeddedShellFocused={true}
activeShellPtyId={1}
ptyId={1}
/>
);
return <ShellToolMessage {...baseProps} status={status} ptyId={1} />;
};
const { lastFrame } = renderWithProviders(<Wrapper />, {
uiActions,
uiState: { streamingState: StreamingState.Idle },
uiState: {
streamingState: StreamingState.Idle,
embeddedShellFocused: true,
activePtyId: 1,
},
});
// Verify it is initially focused
@@ -143,21 +139,29 @@ describe('<ShellToolMessage />', () => {
'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('<ShellToolMessage />', () => {
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(() => {

View File

@@ -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<ShellToolMessageProps> = ({
renderOutputAsMarkdown = true,
activeShellPtyId,
embeddedShellFocused,
ptyId,
config,
@@ -66,6 +62,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
borderDimColor,
}) => {
const { activePtyId: activeShellPtyId, embeddedShellFocused } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const isThisShellFocused = checkIsShellFocused(
name,

View File

@@ -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('<ToolGroupMessage />', () => {
});
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('<ToolGroupMessage />', () => {
describe('Golden Snapshots', () => {
it('renders single successful tool call', () => {
const toolCalls = [createToolCall()];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -81,9 +98,10 @@ describe('<ToolGroupMessage />', () => {
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);
@@ -113,13 +131,19 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Error,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -153,13 +177,19 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Scheduled,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -188,16 +218,23 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'More output here',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
availableTerminalHeight={10}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -213,16 +250,23 @@ describe('<ToolGroupMessage />', () => {
'This is a very long description that might cause wrapping issues',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
terminalWidth={40}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -231,12 +275,19 @@ describe('<ToolGroupMessage />', () => {
});
it('renders empty tool calls array', () => {
const toolCalls: IndividualToolCallDisplay[] = [];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={[]} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: [] }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: [],
},
],
},
},
);
@@ -260,14 +311,20 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'line1\nline2',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<Scrollable height={10} hasFocus={true} scrollToBottom={true}>
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />
</Scrollable>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -285,12 +342,18 @@ describe('<ToolGroupMessage />', () => {
outputFile: '/path/to/output.txt',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -307,6 +370,7 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'line1\nline2\nline3\nline4\nline5',
}),
];
const item1 = createItem(toolCalls1);
const toolCalls2 = [
createToolCall({
callId: '2',
@@ -315,18 +379,33 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'line1',
}),
];
const item2 = createItem(toolCalls2);
const { lastFrame, unmount } = renderWithProviders(
<Scrollable height={6} hasFocus={true} scrollToBottom={true}>
<ToolGroupMessage {...baseProps} toolCalls={toolCalls1} />
<ToolGroupMessage {...baseProps} toolCalls={toolCalls2} />
<ToolGroupMessage
{...baseProps}
item={item1}
toolCalls={toolCalls1}
/>
<ToolGroupMessage
{...baseProps}
item={item2}
toolCalls={toolCalls2}
/>
</Scrollable>,
{
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('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Success,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -366,12 +451,18 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Success,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -396,16 +487,23 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: '', // No result
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
availableTerminalHeight={20}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -453,9 +551,10 @@ describe('<ToolGroupMessage />', () => {
resultDisplay,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);
@@ -481,9 +580,10 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Scheduled,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);
@@ -502,10 +602,12 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Executing,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
borderBottom={false}
/>,
@@ -540,9 +642,10 @@ describe('<ToolGroupMessage />', () => {
approvalMode: mode,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);

View File

@@ -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<ToolGroupMessageProps> = ({
item,
toolCalls: allToolCalls,
availableTerminalHeight,
terminalWidth,
activeShellPtyId,
embeddedShellFocused,
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
}) => {
@@ -61,7 +60,31 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
);
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<ToolGroupMessageProps> = ({
[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<ToolGroupMessageProps> = ({
width={contentWidth}
>
{isShellToolCall ? (
<ShellToolMessage
{...commonProps}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
config={config}
/>
<ShellToolMessage {...commonProps} config={config} />
) : (
<ToolMessage {...commonProps} />
)}

View File

@@ -35,7 +35,7 @@ describe('ToolResultDisplay Overflow', () => {
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
groupId={1}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}

View File

@@ -79,7 +79,7 @@ describe('ToolMessage Sticky Header Regression', () => {
data={['item1']}
renderItem={() => (
<ToolGroupMessage
groupId={1}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
terminalWidth={terminalWidth - 2} // Account for ScrollableList padding
/>
@@ -165,7 +165,7 @@ describe('ToolMessage Sticky Header Regression', () => {
data={['item1']}
renderItem={() => (
<ToolGroupMessage
groupId={1}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
terminalWidth={terminalWidth - 2}
/>

View File

@@ -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),
);
});

View File

@@ -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,
};
}

View File

@@ -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 ---

View File

@@ -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<void>) => {
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<PartListUnion | null>(null);
const lastPromptIdRef = useRef<string | null>(null);
@@ -448,36 +508,6 @@ export const useGeminiStream = (
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
} | null>(null);
const onExec = useCallback(async (done: Promise<void>) => {
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<number | null>(null);

View File

@@ -221,6 +221,8 @@ export type HistoryItemToolGroup = HistoryItemBase & {
tools: IndividualToolCallDisplay[];
borderTop?: boolean;
borderBottom?: boolean;
borderColor?: string;
borderDimColor?: boolean;
};
export type HistoryItemUserShell = HistoryItemBase & {

View File

@@ -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<number, BackgroundShell> = 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<IndividualToolCallDisplay | TrackedToolCall> =
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 };
}