Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions packages/cli/src/ui/components/messages/ToolMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -375,20 +375,25 @@ describe('<ToolMessage />', () => {
unmount();
});

it('renders progress information appended to description for executing tools', async () => {
it('renders McpProgressIndicator with percentage and message for executing tools', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithContext(
<ToolMessage
{...baseProps}
status={CoreToolCallStatus.Executing}
progress={42}
progressTotal={100}
progressMessage="Working on it..."
progressPercent={42}
/>,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'A tool for testing (Working on it... - 42%)',
);
const output = lastFrame();
expect(output).toContain('42%');
expect(output).toContain('Working on it...');
expect(output).toContain('\u2588');
expect(output).toContain('\u2591');
expect(output).not.toContain('A tool for testing (Working on it... - 42%)');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The snapshot test here is good, but it might be beneficial to explicitly assert the presence of the progress bar characters (e.g., \u2588 and \u2591) to ensure the visual representation is as expected, rather than just relying on the percentage and message. This would make the test more robust against accidental changes to the progress bar's rendering.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion. Added explicit \u2588 and \u2591 character assertions to all three progress bar test cases.

expect(output).toMatchSnapshot();
unmount();
});

Expand All @@ -397,12 +402,37 @@ describe('<ToolMessage />', () => {
<ToolMessage
{...baseProps}
status={CoreToolCallStatus.Executing}
progressPercent={75}
progress={75}
progressTotal={100}
/>,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('75%');
expect(output).toContain('\u2588');
expect(output).toContain('\u2591');
expect(output).not.toContain('A tool for testing (75%)');
expect(output).toMatchSnapshot();
Comment thread
jasmeetsb marked this conversation as resolved.
unmount();
});

it('renders indeterminate progress when total is missing', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithContext(
<ToolMessage
{...baseProps}
status={CoreToolCallStatus.Executing}
progress={7}
/>,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('A tool for testing (75%)');
const output = lastFrame();
expect(output).toContain('7');
expect(output).toContain('\u2588');
expect(output).toContain('\u2591');
expect(output).not.toContain('%');
expect(output).toMatchSnapshot();
Comment thread
jasmeetsb marked this conversation as resolved.
unmount();
});
});
16 changes: 12 additions & 4 deletions packages/cli/src/ui/components/messages/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ import {
ToolStatusIndicator,
ToolInfo,
TrailingIndicator,
McpProgressIndicator,
type TextEmphasis,
STATUS_INDICATOR_WIDTH,
isThisShellFocusable as checkIsShellFocusable,
isThisShellFocused as checkIsShellFocused,
useFocusHint,
FocusHint,
} from './ToolShared.js';
import { type Config } from '@google/gemini-cli-core';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
import { ShellInputPrompt } from '../ShellInputPrompt.js';

export type { TextEmphasis };
Expand Down Expand Up @@ -56,8 +57,9 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
ptyId,
config,
progressMessage,
progressPercent,
originalRequestName,
progress,
progressTotal,
}) => {
const isThisShellFocused = checkIsShellFocused(
name,
Expand Down Expand Up @@ -92,8 +94,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
status={status}
description={description}
emphasis={emphasis}
progressMessage={progressMessage}
progressPercent={progressPercent}
originalRequestName={originalRequestName}
/>
<FocusHint
Expand All @@ -114,6 +114,14 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
paddingX={1}
flexDirection="column"
>
{status === CoreToolCallStatus.Executing && progress !== undefined && (
<McpProgressIndicator
progress={progress}
total={progressTotal}
message={progressMessage}
barWidth={20}
/>
)}
<ToolResultDisplay
resultDisplay={resultDisplay}
availableTerminalHeight={availableTerminalHeight}
Expand Down
72 changes: 72 additions & 0 deletions packages/cli/src/ui/components/messages/ToolShared.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi } from 'vitest';
import { render } from '../../../test-utils/render.js';
import { Text } from 'ink';
import { McpProgressIndicator } from './ToolShared.js';

vi.mock('../GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: () => <Text>MockSpinner</Text>,
}));

describe('McpProgressIndicator', () => {
it('renders determinate progress at 50%', async () => {
const { lastFrame, waitUntilReady } = render(
<McpProgressIndicator progress={50} total={100} barWidth={20} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('50%');
});

it('renders complete progress at 100%', async () => {
const { lastFrame, waitUntilReady } = render(
<McpProgressIndicator progress={100} total={100} barWidth={20} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('100%');
});

it('renders indeterminate progress with raw count', async () => {
const { lastFrame, waitUntilReady } = render(
<McpProgressIndicator progress={7} barWidth={20} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('7');
expect(output).not.toContain('%');
});

it('renders progress with a message', async () => {
const { lastFrame, waitUntilReady } = render(
<McpProgressIndicator
progress={30}
total={100}
message="Downloading..."
barWidth={20}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('Downloading...');
});

it('clamps progress exceeding total to 100%', async () => {
const { lastFrame, waitUntilReady } = render(
<McpProgressIndicator progress={150} total={100} barWidth={20} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('100%');
expect(output).not.toContain('150%');
});
});
72 changes: 49 additions & 23 deletions packages/cli/src/ui/components/messages/ToolShared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,6 @@ type ToolInfoProps = {
description: string;
status: CoreToolCallStatus;
emphasis: TextEmphasis;
progressMessage?: string;
progressPercent?: number;
originalRequestName?: string;
};

Expand All @@ -197,8 +195,6 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
description,
status: coreStatus,
emphasis,
progressMessage,
progressPercent,
originalRequestName,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
Expand All @@ -220,24 +216,6 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
// Hide description for completed Ask User tools (the result display speaks for itself)
const isCompletedAskUser = isCompletedAskUserTool(name, status);

let displayDescription = description;
if (status === ToolCallStatus.Executing) {
const parts: string[] = [];
if (progressMessage) {
parts.push(progressMessage);
}
if (progressPercent !== undefined) {
parts.push(`${Math.round(progressPercent)}%`);
}

if (parts.length > 0) {
const progressInfo = parts.join(' - ');
displayDescription = description
? `${description} (${progressInfo})`
: progressInfo;
}
}

return (
<Box overflow="hidden" height={1} flexGrow={1} flexShrink={1}>
<Text strikethrough={status === ToolCallStatus.Canceled} wrap="truncate">
Expand All @@ -253,14 +231,62 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
{!isCompletedAskUser && (
<>
{' '}
<Text color={theme.text.secondary}>{displayDescription}</Text>
<Text color={theme.text.secondary}>{description}</Text>
</>
)}
</Text>
</Box>
);
};

export interface McpProgressIndicatorProps {
progress: number;
total?: number;
message?: string;
barWidth: number;
}

export const McpProgressIndicator: React.FC<McpProgressIndicatorProps> = ({
progress,
total,
message,
barWidth,
}) => {
const percentage =
total && total > 0
? Math.min(100, Math.round((progress / total) * 100))
: null;

let rawFilled: number;
if (total && total > 0) {
rawFilled = Math.round((progress / total) * barWidth);
} else {
rawFilled = Math.floor(progress) % (barWidth + 1);
}

const filled = Math.max(
0,
Math.min(Number.isFinite(rawFilled) ? rawFilled : 0, barWidth),
Comment thread
jasmeetsb marked this conversation as resolved.
);
const empty = Math.max(0, barWidth - filled);
const progressBar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);

return (
<Box flexDirection="column">
<Box>
<Text color={theme.text.accent}>
{progressBar} {percentage !== null ? `${percentage}%` : `${progress}`}
</Text>
</Box>
{message && (
<Text color={theme.text.secondary} wrap="truncate">
{message}
</Text>
)}
</Box>
);
};

export const TrailingIndicator: React.FC = () => (
<Text color={theme.text.primary} wrap="truncate">
{' '}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ exports[`<ToolMessage /> > renders DiffRenderer for diff results 1`] = `
"
`;

exports[`<ToolMessage /> > renders McpProgressIndicator with percentage and message for executing tools 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ MockRespondingSpinnertest-tool A tool for testing │
│ │
│ ████████░░░░░░░░░░░░ 42% │
│ Working on it... │
│ Test result │
"
`;

exports[`<ToolMessage /> > renders basic tool information 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
Expand All @@ -115,3 +125,21 @@ exports[`<ToolMessage /> > renders emphasis correctly 2`] = `
│ Test result │
"
`;

exports[`<ToolMessage /> > renders indeterminate progress when total is missing 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ MockRespondingSpinnertest-tool A tool for testing │
│ │
│ ███████░░░░░░░░░░░░░ 7 │
│ Test result │
"
`;

exports[`<ToolMessage /> > renders only percentage when progressMessage is missing 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ MockRespondingSpinnertest-tool A tool for testing │
│ │
│ ███████████████░░░░░ 75% │
│ Test result │
"
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`McpProgressIndicator > renders complete progress at 100% 1`] = `
"████████████████████ 100%
"
`;

exports[`McpProgressIndicator > renders determinate progress at 50% 1`] = `
"██████████░░░░░░░░░░ 50%
"
`;

exports[`McpProgressIndicator > renders indeterminate progress with raw count 1`] = `
"███████░░░░░░░░░░░░░ 7
"
`;

exports[`McpProgressIndicator > renders progress with a message 1`] = `
"██████░░░░░░░░░░░░░░ 30%
Downloading...
"
`;
Loading
Loading