Merge branch 'main' into task/subprocess_xml_tagging

This commit is contained in:
Aishanee Shah
2026-02-18 12:52:00 -05:00
committed by GitHub
33 changed files with 1055 additions and 183 deletions

View File

@@ -23,6 +23,8 @@ To standardize the process of updating changelog files (`latest.md`,
## Guidelines for `latest.md` and `preview.md` Highlights
- Aim for **3-5 key highlight points**.
- Each highlight point must start with a bold-typed title that summarizes the
change (e.g., `**New Feature:** A brief description...`).
- **Prioritize** summarizing new features over other changes like bug fixes or
chores.
- **Avoid** mentioning features that are "experimental" or "in preview" in
@@ -65,6 +67,8 @@ detailed **highlights** section for the release-specific page.
1. **Create the Announcement for `index.md`**:
- Generate a concise announcement summarizing the most important changes.
Each announcement entry must start with a bold-typed title that
summarizes the change.
- **Important**: The format for this announcement is unique. You **must**
use the existing announcements in `docs/changelogs/index.md` and the
example within

View File

@@ -32,6 +32,7 @@ jobs:
with:
# The user-level skills need to be available to the workflow
fetch-depth: 0
ref: 'main'
- name: 'Set up Node.js'
uses: 'actions/setup-node@v4'
@@ -42,7 +43,6 @@ jobs:
id: 'release_info'
run: |
VERSION="${{ github.event.inputs.version || github.event.release.tag_name }}"
BODY="${{ github.event.inputs.body || github.event.release.body }}"
TIME="${{ github.event.inputs.time || github.event.release.created_at }}"
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
@@ -50,10 +50,11 @@ jobs:
# Use a heredoc to preserve multiline release body
echo 'RAW_CHANGELOG<<EOF' >> "$GITHUB_OUTPUT"
printf "%s\n" "${BODY}" >> "$GITHUB_OUTPUT"
printf "%s\n" "$BODY" >> "$GITHUB_OUTPUT"
echo 'EOF' >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
BODY: '${{ github.event.inputs.body || github.event.release.body }}'
- name: 'Generate Changelog with Gemini'
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0

View File

@@ -47,7 +47,13 @@ powerful tool for developers.
be relative to the workspace root, e.g.,
`-w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`)
- **Full Validation:** `npm run preflight` (Heaviest check; runs clean, install,
build, lint, type check, and tests. Recommended before submitting PRs.)
build, lint, type check, and tests. Recommended before submitting PRs. Due to
its long runtime, only run this at the very end of a code implementation task.
If it fails, use faster, targeted commands (e.g., `npm run test`,
`npm run lint`, or workspace-specific tests) to iterate on fixes before
re-running `preflight`. For simple, non-code changes like documentation or
prompting updates, skip `preflight` at the end of the task and wait for PR
validation.)
- **Individual Checks:** `npm run lint` / `npm run format` / `npm run typecheck`
## Development Conventions

View File

@@ -36,7 +36,7 @@ available combinations.
| Delete from the cursor to the start of the line. | `Ctrl + U` |
| Clear all text in the input field. | `Ctrl + C` |
| Delete the previous word. | `Ctrl + Backspace`<br />`Alt + Backspace`<br />`Ctrl + W` |
| Delete the next word. | `Ctrl + Delete`<br />`Alt + Delete` |
| Delete the next word. | `Ctrl + Delete`<br />`Alt + Delete`<br />`Alt + D` |
| Delete the character to the left. | `Backspace`<br />`Ctrl + H` |
| Delete the character to the right. | `Delete`<br />`Ctrl + D` |
| Undo the most recent text edit. | `Cmd + Z (no Shift)`<br />`Alt + Z (no Shift)` |

View File

@@ -0,0 +1,297 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import path from 'node:path';
import fs from 'node:fs';
import crypto from 'node:crypto';
// Recursive function to find a directory by name
function findDir(base: string, name: string): string | null {
if (!fs.existsSync(base)) return null;
const files = fs.readdirSync(base);
for (const file of files) {
const fullPath = path.join(base, file);
if (fs.statSync(fullPath).isDirectory()) {
if (file === name) return fullPath;
const found = findDir(fullPath, name);
if (found) return found;
}
}
return null;
}
describe('Tool Output Masking Behavioral Evals', () => {
/**
* Scenario: The agent needs information that was masked in a previous turn.
* It should recognize the <tool_output_masked> tag and use a tool to read the file.
*/
evalTest('USUALLY_PASSES', {
name: 'should attempt to read the redirected full output file when information is masked',
params: {
security: {
folderTrust: {
enabled: true,
},
},
},
prompt: '/help',
assert: async (rig) => {
// 1. Initialize project directories
await rig.run({ args: '/help' });
// 2. Discover the project temp dir
const chatsDir = findDir(path.join(rig.homeDir!, '.gemini'), 'chats');
if (!chatsDir) throw new Error('Could not find chats directory');
const projectTempDir = path.dirname(chatsDir);
const sessionId = crypto.randomUUID();
const toolOutputsDir = path.join(
projectTempDir,
'tool-outputs',
`session-${sessionId}`,
);
fs.mkdirSync(toolOutputsDir, { recursive: true });
const secretValue = 'THE_RECOVERED_SECRET_99';
const outputFileName = `masked_output_${crypto.randomUUID()}.txt`;
const outputFilePath = path.join(toolOutputsDir, outputFileName);
fs.writeFileSync(
outputFilePath,
`Some padding...\nThe secret key is: ${secretValue}\nMore padding...`,
);
const maskedSnippet = `<tool_output_masked>
Output: [PREVIEW]
Output too large. Full output available at: ${outputFilePath}
</tool_output_masked>`;
// 3. Inject manual session file
const conversation = {
sessionId: sessionId,
projectHash: path.basename(projectTempDir),
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [
{
id: 'msg_1',
timestamp: new Date().toISOString(),
type: 'user',
content: [{ text: 'Get secret.' }],
},
{
id: 'msg_2',
timestamp: new Date().toISOString(),
type: 'gemini',
model: 'gemini-3-flash-preview',
toolCalls: [
{
id: 'call_1',
name: 'run_shell_command',
args: { command: 'get_secret' },
status: 'success',
timestamp: new Date().toISOString(),
result: [
{
functionResponse: {
id: 'call_1',
name: 'run_shell_command',
response: { output: maskedSnippet },
},
},
],
},
],
content: [{ text: 'I found a masked output.' }],
},
],
};
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
conversation.startTime = futureDate.toISOString();
conversation.lastUpdated = futureDate.toISOString();
const timestamp = futureDate
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
const sessionFile = path.join(
chatsDir,
`session-${timestamp}-${sessionId.slice(0, 8)}.json`,
);
fs.writeFileSync(sessionFile, JSON.stringify(conversation, null, 2));
// 4. Trust folder
const settingsDir = path.join(rig.homeDir!, '.gemini');
fs.writeFileSync(
path.join(settingsDir, 'trustedFolders.json'),
JSON.stringify(
{
[path.resolve(rig.homeDir!)]: 'TRUST_FOLDER',
},
null,
2,
),
);
// 5. Run agent with --resume
const result = await rig.run({
args: [
'--resume',
'latest',
'What was the secret key in that last masked shell output?',
],
approvalMode: 'yolo',
timeout: 120000,
});
// ASSERTION: Verify agent accessed the redirected file
const logs = rig.readToolLogs();
const accessedFile = logs.some((log) =>
log.toolRequest.args.includes(outputFileName),
);
expect(
accessedFile,
`Agent should have attempted to access the masked output file: ${outputFileName}`,
).toBe(true);
expect(result.toLowerCase()).toContain(secretValue.toLowerCase());
},
});
/**
* Scenario: Information is in the preview.
*/
evalTest('USUALLY_PASSES', {
name: 'should NOT read the full output file when the information is already in the preview',
params: {
security: {
folderTrust: {
enabled: true,
},
},
},
prompt: '/help',
assert: async (rig) => {
await rig.run({ args: '/help' });
const chatsDir = findDir(path.join(rig.homeDir!, '.gemini'), 'chats');
if (!chatsDir) throw new Error('Could not find chats directory');
const projectTempDir = path.dirname(chatsDir);
const sessionId = crypto.randomUUID();
const toolOutputsDir = path.join(
projectTempDir,
'tool-outputs',
`session-${sessionId}`,
);
fs.mkdirSync(toolOutputsDir, { recursive: true });
const secretValue = 'PREVIEW_SECRET_123';
const outputFileName = `masked_output_${crypto.randomUUID()}.txt`;
const outputFilePath = path.join(toolOutputsDir, outputFileName);
fs.writeFileSync(
outputFilePath,
`Full content containing ${secretValue}`,
);
const maskedSnippet = `<tool_output_masked>
Output: The secret key is: ${secretValue}
... lines omitted ...
Output too large. Full output available at: ${outputFilePath}
</tool_output_masked>`;
const conversation = {
sessionId: sessionId,
projectHash: path.basename(projectTempDir),
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [
{
id: 'msg_1',
timestamp: new Date().toISOString(),
type: 'user',
content: [{ text: 'Find secret.' }],
},
{
id: 'msg_2',
timestamp: new Date().toISOString(),
type: 'gemini',
model: 'gemini-3-flash-preview',
toolCalls: [
{
id: 'call_1',
name: 'run_shell_command',
args: { command: 'get_secret' },
status: 'success',
timestamp: new Date().toISOString(),
result: [
{
functionResponse: {
id: 'call_1',
name: 'run_shell_command',
response: { output: maskedSnippet },
},
},
],
},
],
content: [{ text: 'Masked output found.' }],
},
],
};
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
conversation.startTime = futureDate.toISOString();
conversation.lastUpdated = futureDate.toISOString();
const timestamp = futureDate
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
const sessionFile = path.join(
chatsDir,
`session-${timestamp}-${sessionId.slice(0, 8)}.json`,
);
fs.writeFileSync(sessionFile, JSON.stringify(conversation, null, 2));
const settingsDir = path.join(rig.homeDir!, '.gemini');
fs.writeFileSync(
path.join(settingsDir, 'trustedFolders.json'),
JSON.stringify(
{
[path.resolve(rig.homeDir!)]: 'TRUST_FOLDER',
},
null,
2,
),
);
const result = await rig.run({
args: [
'--resume',
'latest',
'What was the secret key mentioned in the previous output?',
],
approvalMode: 'yolo',
timeout: 120000,
});
const logs = rig.readToolLogs();
const accessedFile = logs.some((log) =>
log.toolRequest.args.includes(outputFileName),
);
expect(
accessedFile,
'Agent should NOT have accessed the masked output file',
).toBe(false);
expect(result.toLowerCase()).toContain(secretValue.toLowerCase());
},
});
});

View File

@@ -178,6 +178,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.DELETE_WORD_FORWARD]: [
{ key: 'delete', ctrl: true },
{ key: 'delete', alt: true },
{ key: 'd', alt: true },
],
[Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }],
[Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }],

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

@@ -14,5 +14,9 @@ interface SessionSummaryDisplayProps {
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
}) => (
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
<StatsDisplay
title="Agent powering down. Goodbye!"
duration={duration}
footer="Tip: Resume a previous session using gemini --resume or /resume"
/>
);

View File

@@ -178,7 +178,7 @@ const ModelUsageTable: React.FC<{
: `Model Usage`;
return (
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="column" marginBottom={1}>
{/* Header */}
<Box alignItems="flex-end">
<Box width={nameWidth}>
@@ -379,6 +379,7 @@ interface StatsDisplayProps {
duration: string;
title?: string;
quotas?: RetrieveUserQuotaResponse;
footer?: string;
selectedAuthType?: string;
userEmail?: string;
tier?: string;
@@ -390,6 +391,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
duration,
title,
quotas,
footer,
selectedAuthType,
userEmail,
tier,
@@ -433,6 +435,13 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
);
};
const renderFooter = () => {
if (!footer) {
return null;
}
return <ThemedGradient bold>{footer}</ThemedGradient>;
};
return (
<Box
borderStyle="round"
@@ -536,6 +545,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
pooledLimit={pooledLimit}
pooledResetTime={pooledResetTime}
/>
{renderFooter()}
</Box>
);
};

View File

@@ -7,7 +7,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Box } from 'ink';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { StreamingState } from '../types.js';
import { StreamingState, ToolCallStatus } from '../types.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
@@ -223,6 +223,58 @@ describe('ToolConfirmationQueue', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('provides more height for ask_user by subtracting less overhead', async () => {
const confirmingTool = {
tool: {
callId: 'call-1',
name: 'ask_user',
description: 'ask user',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'ask_user' as const,
questions: [
{
type: 'choice',
header: 'Height Test',
question: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6',
options: [{ label: 'Option 1', description: 'Desc' }],
},
],
},
},
index: 1,
total: 1,
};
const { lastFrame } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
{
config: mockConfig,
uiState: {
terminalWidth: 80,
terminalHeight: 40,
availableTerminalHeight: 20,
constrainHeight: true,
streamingState: StreamingState.WaitingForConfirmation,
},
},
);
// Calculation:
// availableTerminalHeight: 20 -> maxHeight: 19 (20-1)
// hideToolIdentity is true for ask_user -> subtracts 4 instead of 6
// availableContentHeight = 19 - 4 = 15
// ToolConfirmationMessage handlesOwnUI=true -> returns full 15
// AskUserDialog uses 15 lines to render its multi-line question and options.
await waitFor(() => {
expect(lastFrame()).toContain('Line 6');
expect(lastFrame()).not.toContain('lines hidden');
});
expect(lastFrame()).toMatchSnapshot();
});
it('does not render expansion hint when constrainHeight is false', () => {
const longDiff = 'line\n'.repeat(50);
const confirmingTool = {

View File

@@ -60,6 +60,12 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
? Math.max(uiAvailableHeight - 1, 4)
: Math.floor(terminalHeight * 0.5);
const isRoutine =
tool.confirmationDetails?.type === 'ask_user' ||
tool.confirmationDetails?.type === 'exit_plan_mode';
const borderColor = isRoutine ? theme.status.success : theme.status.warning;
const hideToolIdentity = isRoutine;
// ToolConfirmationMessage needs to know the height available for its OWN content.
// We subtract the lines used by the Queue wrapper:
// - 2 lines for the rounded border
@@ -67,15 +73,9 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
// - 2 lines for Tool Identity (text + margin)
const availableContentHeight =
constrainHeight && !isAlternateBuffer
? Math.max(maxHeight - 6, 4)
? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4)
: undefined;
const isRoutine =
tool.confirmationDetails?.type === 'ask_user' ||
tool.confirmationDetails?.type === 'exit_plan_mode';
const borderColor = isRoutine ? theme.status.success : theme.status.warning;
const hideToolIdentity = isRoutine;
return (
<OverflowProvider>
<Box flexDirection="column" width={mainAreaWidth} flexShrink={0}>

View File

@@ -17,12 +17,13 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ » API Time: 50.2s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 10 500 500 2,000 │
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
│ Tip: Resume a previous session using gemini --resume or /resume │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -112,11 +112,11 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 100 0 100 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -155,7 +155,6 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ auto Usage │
│ 65% usage remaining │
│ Usage limit: 1,100 │
@@ -166,6 +165,7 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
│ ──────────────────────────────────────────────────────────── │
│ gemini-2.5-pro - │
│ gemini-2.5-flash - │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -185,11 +185,11 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information for unused
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Usage remaining │
│ ──────────────────────────────────────────────────────────── │
│ gemini-2.5-flash - 50.0% resets in 2h │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -209,11 +209,11 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Usage remaining │
│ ──────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 75.0% resets in 1h 30m │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -271,7 +271,6 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ » API Time: 19.5s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
@@ -279,6 +278,7 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ gemini-2.5-flash 5 15,000 10,000 15,000 │
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -299,13 +299,13 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
│ » API Time: 100ms (44.8%) │
│ » Tool Time: 123ms (55.2%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 50 50 100 │
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -40,6 +40,25 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`ToolConfirmationQueue > provides more height for ask_user by subtracting less overhead 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Answer Questions │
│ │
│ Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ │
│ ● 1. Option 1 │
│ Desc │
│ 2. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`ToolConfirmationQueue > renders AskUser tool confirmation with Success color 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Answer Questions │

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

@@ -235,6 +235,10 @@ export const ToolConfirmationMessage: React.FC<
return undefined;
}
if (handlesOwnUI) {
return availableTerminalHeight;
}
// Calculate the vertical space (in lines) consumed by UI elements
// surrounding the main body content.
const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
@@ -253,7 +257,7 @@ export const ToolConfirmationMessage: React.FC<
1; // Reserve one line for 'ShowMoreLines' hint
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}, [availableTerminalHeight, getOptions]);
}, [availableTerminalHeight, getOptions, handlesOwnUI]);
const { question, bodyContent, options } = useMemo(() => {
let bodyContent: React.ReactNode | null = null;

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

@@ -141,6 +141,7 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u00B5': 'm', // "µ" toggle markup view
'\u03A9': 'z', // "Ω" Option+z
'\u00B8': 'Z', // "¸" Option+Shift+z
'\u2202': 'd', // "∂" delete word forward
};
function nonKeyboardEventFilter(

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

@@ -130,6 +130,7 @@ describe('keyMatchers', () => {
positive: [
createKey('delete', { ctrl: true }),
createKey('delete', { alt: true }),
createKey('d', { alt: true }),
],
negative: [createKey('delete'), createKey('backspace', { ctrl: true })],
},

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