Merge branch 'main' into fix/subagent-update

This commit is contained in:
tanzhenxin 2025-09-17 19:12:22 +08:00
commit 50199288ec
607 changed files with 27283 additions and 9387 deletions

View file

@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { CompressionProps } from '../../types.js';
import type { CompressionProps } from '../../types.js';
import Spinner from 'ink-spinner';
import { Colors } from '../../colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
export interface CompressionDisplayProps {
compression: CompressionProps;
@ -40,6 +41,7 @@ export const CompressionMessage: React.FC<CompressionDisplayProps> = ({
color={
compression.isPending ? Colors.AccentPurple : Colors.AccentGreen
}
aria-label={SCREEN_READER_MODEL_PREFIX}
>
{text}
</Text>

View file

@ -4,10 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import type React from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { Colors } from '../../colors.js';
import crypto from 'crypto';
import crypto from 'node:crypto';
import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
@ -107,6 +107,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
terminalWidth,
theme,
}) => {
const screenReaderEnabled = useIsScreenReaderEnabled();
if (!diffContent || typeof diffContent !== 'string') {
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
}
@ -120,6 +121,17 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
</Box>
);
}
if (screenReaderEnabled) {
return (
<Box flexDirection="column">
{parsedLines.map((line, index) => (
<Text key={index}>
{line.type}: {line.content}
</Text>
))}
</Box>
);
}
// Check if the diff represents a new file (only additions and header lines)
const isNewFile = parsedLines.every(

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';

View file

@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { Colors } from '../../colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
interface GeminiMessageProps {
text: string;
@ -28,7 +29,12 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={Colors.AccentPurple}>{prefix}</Text>
<Text
color={Colors.AccentPurple}
aria-label={SCREEN_READER_MODEL_PREFIX}
>
{prefix}
</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';

View file

@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { SummaryProps } from '../../types.js';
import type { SummaryProps } from '../../types.js';
import Spinner from 'ink-spinner';
import { Colors } from '../../colors.js';

View file

@ -6,10 +6,18 @@
import { describe, it, expect, vi } from 'vitest';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { ToolCallConfirmationDetails } from '@qwen-code/qwen-code-core';
import type {
ToolCallConfirmationDetails,
Config,
} from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../../test-utils/render.js';
describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
it('should not display urls if prompt and url are the same', () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
@ -22,6 +30,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@ -45,6 +54,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@ -55,4 +65,119 @@ describe('ToolConfirmationMessage', () => {
'- https://raw.githubusercontent.com/google/gemini-react/main/README.md',
);
});
describe('with folder trust', () => {
const editConfirmationDetails: ToolCallConfirmationDetails = {
type: 'edit',
title: 'Confirm Edit',
fileName: 'test.txt',
filePath: '/test.txt',
fileDiff: '...diff...',
originalContent: 'a',
newContent: 'b',
onConfirm: vi.fn(),
};
const execConfirmationDetails: ToolCallConfirmationDetails = {
type: 'exec',
title: 'Confirm Execution',
command: 'echo "hello"',
rootCommand: 'echo',
onConfirm: vi.fn(),
};
const infoConfirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'https://example.com',
urls: ['https://example.com'],
onConfirm: vi.fn(),
};
const mcpConfirmationDetails: ToolCallConfirmationDetails = {
type: 'mcp',
title: 'Confirm MCP Tool',
serverName: 'test-server',
toolName: 'test-tool',
toolDisplayName: 'Test Tool',
onConfirm: vi.fn(),
};
describe.each([
{
description: 'for edit confirmations',
details: editConfirmationDetails,
alwaysAllowText: 'Yes, allow always',
},
{
description: 'for exec confirmations',
details: execConfirmationDetails,
alwaysAllowText: 'Yes, allow always',
},
{
description: 'for info confirmations',
details: infoConfirmationDetails,
alwaysAllowText: 'Yes, allow always',
},
{
description: 'for mcp confirmations',
details: mcpConfirmationDetails,
alwaysAllowText: 'always allow',
},
])('$description', ({ details, alwaysAllowText }) => {
it('should show "allow always" when folder is trusted', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={details}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain(alwaysAllowText);
});
it('should show "allow always" when folder trust is undefined', () => {
const mockConfig = {
isTrustedFolder: () => undefined,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={details}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain(alwaysAllowText);
});
it('should NOT show "allow always" when folder is untrusted', () => {
const mockConfig = {
isTrustedFolder: () => false,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={details}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
expect(lastFrame()).not.toContain(alwaysAllowText);
});
});
});
});

View file

@ -4,28 +4,26 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import {
import type {
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolExecuteConfirmationDetails,
ToolMcpConfirmationDetails,
Config,
} from '@qwen-code/qwen-code-core';
import {
RadioButtonSelect,
RadioSelectItem,
} from '../shared/RadioButtonSelect.js';
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
config?: Config;
config: Config;
isFocused?: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
@ -47,8 +45,8 @@ export const ToolConfirmationMessage: React.FC<
const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
if (confirmationDetails.type === 'edit') {
const ideClient = config?.getIdeClient();
if (config?.getIdeMode()) {
const ideClient = config.getIdeClient();
if (config.getIdeMode()) {
const cliOutcome =
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
await ideClient?.resolveDiffFromCli(
@ -60,6 +58,8 @@ export const ToolConfirmationMessage: React.FC<
onConfirm(outcome);
};
const isTrustedFolder = config.isTrustedFolder() !== false;
useKeypress(
(key) => {
if (!isFocused) return;
@ -163,17 +163,17 @@ export const ToolConfirmationMessage: React.FC<
}
question = `Apply this change?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
options.push({
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
});
if (isTrustedFolder) {
options.push({
label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways,
},
);
if (config?.getIdeMode()) {
});
}
if (config.getIdeMode()) {
options.push({
label: 'No (esc)',
value: ToolConfirmationOutcome.Cancel,
@ -202,20 +202,20 @@ export const ToolConfirmationMessage: React.FC<
confirmationDetails as ToolExecuteConfirmationDetails;
question = `Allow execution of: '${executionProps.rootCommand}'?`;
options.push(
{
label: `Yes, allow once`,
value: ToolConfirmationOutcome.ProceedOnce,
},
{
options.push({
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
});
if (isTrustedFolder) {
options.push({
label: `Yes, allow always ...`,
value: ToolConfirmationOutcome.ProceedAlways,
},
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
});
}
options.push({
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
});
let bodyContentHeight = availableBodyContentHeight();
if (bodyContentHeight !== undefined) {
@ -242,20 +242,20 @@ export const ToolConfirmationMessage: React.FC<
!(infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt);
question = `Do you want to proceed?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
options.push({
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
});
if (isTrustedFolder) {
options.push({
label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways,
},
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
});
}
options.push({
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
});
bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}>
@ -287,24 +287,24 @@ export const ToolConfirmationMessage: React.FC<
);
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
options.push({
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
});
if (isTrustedFolder) {
options.push({
label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
},
{
});
options.push({
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
value: ToolConfirmationOutcome.ProceedAlwaysServer,
},
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
});
}
options.push({
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
});
}
return (

View file

@ -0,0 +1,350 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import { type IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
import type {
Config,
ToolCallConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import { TOOL_STATUS } from '../../constants.js';
// Mock child components to isolate ToolGroupMessage behavior
vi.mock('./ToolMessage.js', () => ({
ToolMessage: function MockToolMessage({
callId,
name,
description,
status,
emphasis,
}: {
callId: string;
name: string;
description: string;
status: ToolCallStatus;
emphasis: string;
}) {
// Use the same constants as the real component
const statusSymbolMap: Record<ToolCallStatus, string> = {
[ToolCallStatus.Success]: TOOL_STATUS.SUCCESS,
[ToolCallStatus.Pending]: TOOL_STATUS.PENDING,
[ToolCallStatus.Executing]: TOOL_STATUS.EXECUTING,
[ToolCallStatus.Confirming]: TOOL_STATUS.CONFIRMING,
[ToolCallStatus.Canceled]: TOOL_STATUS.CANCELED,
[ToolCallStatus.Error]: TOOL_STATUS.ERROR,
};
const statusSymbol = statusSymbolMap[status] || '?';
return (
<Text>
MockTool[{callId}]: {statusSymbol} {name} - {description} ({emphasis})
</Text>
);
},
}));
vi.mock('./ToolConfirmationMessage.js', () => ({
ToolConfirmationMessage: function MockToolConfirmationMessage({
confirmationDetails,
}: {
confirmationDetails: ToolCallConfirmationDetails;
}) {
const displayText =
confirmationDetails?.type === 'info'
? (confirmationDetails as { prompt: string }).prompt
: confirmationDetails?.title || 'confirm';
return <Text>MockConfirmation: {displayText}</Text>;
},
}));
describe('<ToolGroupMessage />', () => {
const mockConfig: Config = {} as Config;
const createToolCall = (
overrides: Partial<IndividualToolCallDisplay> = {},
): IndividualToolCallDisplay => ({
callId: 'tool-123',
name: 'test-tool',
description: 'A tool for testing',
resultDisplay: 'Test result',
status: ToolCallStatus.Success,
confirmationDetails: undefined,
renderOutputAsMarkdown: false,
...overrides,
});
const baseProps = {
groupId: 1,
terminalWidth: 80,
config: mockConfig,
isFocused: true,
};
describe('Golden Snapshots', () => {
it('renders single successful tool call', () => {
const toolCalls = [createToolCall()];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders multiple tool calls with different statuses', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'successful-tool',
description: 'This tool succeeded',
status: ToolCallStatus.Success,
}),
createToolCall({
callId: 'tool-2',
name: 'pending-tool',
description: 'This tool is pending',
status: ToolCallStatus.Pending,
}),
createToolCall({
callId: 'tool-3',
name: 'error-tool',
description: 'This tool failed',
status: ToolCallStatus.Error,
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders tool call awaiting confirmation', () => {
const toolCalls = [
createToolCall({
callId: 'tool-confirm',
name: 'confirmation-tool',
description: 'This tool needs confirmation',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm Tool Execution',
prompt: 'Are you sure you want to proceed?',
onConfirm: vi.fn(),
},
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders shell command with yellow border', () => {
const toolCalls = [
createToolCall({
callId: 'shell-1',
name: 'run_shell_command',
description: 'Execute shell command',
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders mixed tool calls including shell command', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'read_file',
description: 'Read a file',
status: ToolCallStatus.Success,
}),
createToolCall({
callId: 'tool-2',
name: 'run_shell_command',
description: 'Run command',
status: ToolCallStatus.Executing,
}),
createToolCall({
callId: 'tool-3',
name: 'write_file',
description: 'Write to file',
status: ToolCallStatus.Pending,
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with limited terminal height', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'tool-with-result',
description: 'Tool with output',
resultDisplay:
'This is a long result that might need height constraints',
}),
createToolCall({
callId: 'tool-2',
name: 'another-tool',
description: 'Another tool',
resultDisplay: 'More output here',
}),
];
const { lastFrame } = render(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
availableTerminalHeight={10}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders when not focused', () => {
const toolCalls = [createToolCall()];
const { lastFrame } = render(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
isFocused={false}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with narrow terminal width', () => {
const toolCalls = [
createToolCall({
name: 'very-long-tool-name-that-might-wrap',
description:
'This is a very long description that might cause wrapping issues',
}),
];
const { lastFrame } = render(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
terminalWidth={40}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders empty tool calls array', () => {
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={[]} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});
describe('Border Color Logic', () => {
it('uses yellow border when tools are pending', () => {
const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
// The snapshot will capture the visual appearance including border color
expect(lastFrame()).toMatchSnapshot();
});
it('uses yellow border for shell commands even when successful', () => {
const toolCalls = [
createToolCall({
name: 'run_shell_command',
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('uses gray border when all tools are successful and no shell commands', () => {
const toolCalls = [
createToolCall({ status: ToolCallStatus.Success }),
createToolCall({
callId: 'tool-2',
name: 'another-tool',
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});
describe('Height Calculation', () => {
it('calculates available height correctly with multiple tools with results', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
resultDisplay: 'Result 1',
}),
createToolCall({
callId: 'tool-2',
resultDisplay: 'Result 2',
}),
createToolCall({
callId: 'tool-3',
resultDisplay: '', // No result
}),
];
const { lastFrame } = render(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
availableTerminalHeight={20}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
});
describe('Confirmation Handling', () => {
it('shows confirmation dialog for first confirming tool only', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'first-confirm',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm First Tool',
prompt: 'Confirm first tool',
onConfirm: vi.fn(),
},
}),
createToolCall({
callId: 'tool-2',
name: 'second-confirm',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm Second Tool',
prompt: 'Confirm second tool',
onConfirm: vi.fn(),
},
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
// Should only show confirmation for the first tool
expect(lastFrame()).toMatchSnapshot();
});
});
});

View file

@ -4,13 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useMemo } from 'react';
import type React from 'react';
import { useMemo } from 'react';
import { Box } from 'ink';
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { Colors } from '../../colors.js';
import { Config } from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core';
import { SHELL_COMMAND_NAME } from '../../constants.js';
interface ToolGroupMessageProps {
@ -18,7 +20,7 @@ interface ToolGroupMessageProps {
toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight?: number;
terminalWidth: number;
config?: Config;
config: Config;
isFocused?: boolean;
}
@ -104,6 +106,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
: 'medium'
}
renderOutputAsMarkdown={tool.renderOutputAsMarkdown}
config={config}
/>
</Box>
{tool.status === ToolCallStatus.Confirming &&

View file

@ -6,10 +6,12 @@
import React from 'react';
import { render } from 'ink-testing-library';
import { ToolMessage, ToolMessageProps } from './ToolMessage.js';
import type { ToolMessageProps } from './ToolMessage.js';
import { ToolMessage } from './ToolMessage.js';
import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import type { Config } from '@qwen-code/qwen-code-core';
// Mock child components or utilities if they are complex or have side effects
vi.mock('../GeminiRespondingSpinner.js', () => ({
@ -67,6 +69,8 @@ const renderWithContext = (
};
describe('<ToolMessage />', () => {
const mockConfig = {} as Config;
const baseProps: ToolMessageProps = {
callId: 'tool-123',
name: 'test-tool',
@ -76,6 +80,7 @@ describe('<ToolMessage />', () => {
terminalWidth: 80,
confirmationDetails: undefined,
emphasis: 'medium',
config: mockConfig,
};
it('renders basic tool information', () => {
@ -211,6 +216,7 @@ describe('<ToolMessage />', () => {
terminalWidth: 80,
callId: 'test-call-id-2',
confirmationDetails: undefined,
config: mockConfig,
};
const { lastFrame } = renderWithContext(

View file

@ -6,16 +6,19 @@
import React from 'react';
import { Box, Text } from 'ink';
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { TodoDisplay } from '../TodoDisplay.js';
import {
import { TOOL_STATUS } from '../../constants.js';
import type {
TodoResultDisplay,
TaskResultDisplay,
Config,
} from '@qwen-code/qwen-code-core';
import { AgentExecutionDisplay } from '../subagents/index.js';
@ -106,11 +109,13 @@ const SubagentExecutionRenderer: React.FC<{
data: TaskResultDisplay;
availableHeight?: number;
childWidth: number;
}> = ({ data, availableHeight, childWidth }) => (
config: Config;
}> = ({ data, availableHeight, childWidth, config }) => (
<AgentExecutionDisplay
data={data}
availableHeight={availableHeight}
childWidth={childWidth}
config={config}
/>
);
@ -173,6 +178,7 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
terminalWidth: number;
emphasis?: TextEmphasis;
renderOutputAsMarkdown?: boolean;
config: Config;
}
export const ToolMessage: React.FC<ToolMessageProps> = ({
@ -184,6 +190,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
terminalWidth,
emphasis = 'medium',
renderOutputAsMarkdown = true,
config,
}) => {
const availableHeight = availableTerminalHeight
? Math.max(
@ -227,6 +234,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
data={displayRenderer.data}
availableHeight={availableHeight}
childWidth={childWidth}
config={config}
/>
)}
{displayRenderer.type === 'string' && (
@ -260,28 +268,32 @@ const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
}) => (
<Box minWidth={STATUS_INDICATOR_WIDTH}>
{status === ToolCallStatus.Pending && (
<Text color={Colors.AccentGreen}>o</Text>
<Text color={Colors.AccentGreen}>{TOOL_STATUS.PENDING}</Text>
)}
{status === ToolCallStatus.Executing && (
<GeminiRespondingSpinner
spinnerType="toggle"
nonRespondingDisplay={'⊷'}
nonRespondingDisplay={TOOL_STATUS.EXECUTING}
/>
)}
{status === ToolCallStatus.Success && (
<Text color={Colors.AccentGreen}></Text>
<Text color={Colors.AccentGreen} aria-label={'Success:'}>
{TOOL_STATUS.SUCCESS}
</Text>
)}
{status === ToolCallStatus.Confirming && (
<Text color={Colors.AccentYellow}>?</Text>
<Text color={Colors.AccentYellow} aria-label={'Confirming:'}>
{TOOL_STATUS.CONFIRMING}
</Text>
)}
{status === ToolCallStatus.Canceled && (
<Text color={Colors.AccentYellow} bold>
-
<Text color={Colors.AccentYellow} aria-label={'Canceled:'} bold>
{TOOL_STATUS.CANCELED}
</Text>
)}
{status === ToolCallStatus.Error && (
<Text color={Colors.AccentRed} bold>
x
<Text color={Colors.AccentRed} aria-label={'Error:'} bold>
{TOOL_STATUS.ERROR}
</Text>
)}
</Box>

View file

@ -4,9 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js';
import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.js';
interface UserMessageProps {
text: string;
@ -15,7 +17,7 @@ interface UserMessageProps {
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
const prefix = '> ';
const prefixWidth = prefix.length;
const isSlashCommand = text.startsWith('/');
const isSlashCommand = checkIsSlashCommand(text);
const textColor = isSlashCommand ? Colors.AccentPurple : Colors.Gray;
const borderColor = isSlashCommand ? Colors.AccentPurple : Colors.Gray;
@ -31,7 +33,9 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
alignSelf="flex-start"
>
<Box width={prefixWidth}>
<Text color={textColor}>{prefix}</Text>
<Text color={textColor} aria-label={SCREEN_READER_USER_PREFIX}>
{prefix}
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';

View file

@ -0,0 +1,105 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
│ │
│MockTool[tool-2]: ✓ another-tool - A tool for testing (medium) │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shell commands even when successful 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ run_shell_command - A tool for testing (medium) │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border when tools are pending 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: o test-tool - A tool for testing (medium) │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ? first-confirm - A tool for testing (high) │
│MockConfirmation: Confirm first tool │
│ │
│MockTool[tool-2]: ? second-confirm - A tool for testing (low) │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ read_file - Read a file (medium) │
│ │
│MockTool[tool-2]: ⊷ run_shell_command - Run command (medium) │
│ │
│MockTool[tool-3]: o write_file - Write to file (medium) │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ successful-tool - This tool succeeded (medium) │
│ │
│MockTool[tool-2]: o pending-tool - This tool is pending (medium) │
│ │
│MockTool[tool-3]: x error-tool - This tool failed (medium) │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders shell command with yellow border 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[shell-1]: ✓ run_shell_command - Execute shell command (medium) │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting confirmation 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-confirm]: ? confirmation-tool - This tool needs confirmation (high) │
│MockConfirmation: Are you sure you want to proceed? │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders when not focused 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ tool-with-result - Tool with output (medium) │
│ │
│MockTool[tool-2]: ✓ another-tool - Another tool (medium) │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ very-long-tool-name-that-might-wrap - This is a very long description that │
│might cause wrapping issues (medium) │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Height Calculation > calculates available height correctly with multiple tools with results 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ test-tool - A tool for testing (medium) │
│ │
│MockTool[tool-2]: ✓ test-tool - A tool for testing (medium) │
│ │
│MockTool[tool-3]: ✓ test-tool - A tool for testing (medium) │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;