Merge branch 'main' into feature/arena-agent-collaboration

This commit is contained in:
tanzhenxin 2026-03-17 14:00:47 +08:00
commit edd8388b27
122 changed files with 3731 additions and 2201 deletions

View file

@ -22,6 +22,7 @@ import {
toJsonl,
generateExportFilename,
} from '../utils/export/index.js';
import { t } from '../../i18n/index.js';
/**
* Action for the 'md' subcommand - exports session to markdown.
@ -320,30 +321,40 @@ async function exportJsonlAction(
*/
export const exportCommand: SlashCommand = {
name: 'export',
description: 'Export current session message history to a file',
get description() {
return t('Export current session message history to a file');
},
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'html',
description: 'Export session to HTML format',
get description() {
return t('Export session to HTML format');
},
kind: CommandKind.BUILT_IN,
action: exportHtmlAction,
},
{
name: 'md',
description: 'Export session to markdown format',
get description() {
return t('Export session to markdown format');
},
kind: CommandKind.BUILT_IN,
action: exportMarkdownAction,
},
{
name: 'json',
description: 'Export session to JSON format',
get description() {
return t('Export session to JSON format');
},
kind: CommandKind.BUILT_IN,
action: exportJsonAction,
},
{
name: 'jsonl',
description: 'Export session to JSONL format (one message per line)',
get description() {
return t('Export session to JSONL format (one message per line)');
},
kind: CommandKind.BUILT_IN,
action: exportJsonlAction,
},

View file

@ -13,6 +13,7 @@ import {
CommandKind,
} from './types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
async function restoreAction(
context: CommandContext,
@ -144,8 +145,11 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
return {
name: 'restore',
description:
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
get description() {
return t(
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
);
},
kind: CommandKind.BUILT_IN,
action: restoreAction,
completion,

View file

@ -215,6 +215,7 @@ export enum CommandKind {
BUILT_IN = 'built-in',
FILE = 'file',
MCP_PROMPT = 'mcp-prompt',
SKILL = 'skill',
}
export interface CommandCompletionItem {

View file

@ -21,12 +21,13 @@ export const PlanSummaryDisplay: React.FC<PlanSummaryDisplayProps> = ({
availableHeight,
childWidth,
}) => {
const { message, plan } = data;
const { message, plan, rejected } = data;
const messageColor = rejected ? Colors.AccentYellow : Colors.AccentGreen;
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text color={Colors.AccentGreen} wrap="wrap">
<Text color={messageColor} wrap="wrap">
{message}
</Text>
</Box>

View file

@ -6,7 +6,7 @@
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { Box } from 'ink';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
@ -136,13 +136,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
contentWidth={innerWidth}
/>
)}
{tool.outputFile && (
<Box marginX={1}>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
)}
</Box>
);
})}

View file

@ -300,4 +300,55 @@ describe('<ToolMessage />', () => {
);
expect(lastFrame()).toContain('MockAnsiOutput:hello');
});
it('renders rejected plan content with plan text still visible', () => {
const planResultDisplay = {
type: 'plan_summary' as const,
message: 'Plan was rejected. Remaining in plan mode.',
plan: '# My Plan\n- Step 1: Do something\n- Step 2: Do another thing',
rejected: true,
};
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ExitPlanMode"
description="Plan:"
status={ToolCallStatus.Canceled}
resultDisplay={planResultDisplay}
/>,
StreamingState.Idle,
);
const output = lastFrame();
expect(output).toContain('Plan was rejected. Remaining in plan mode.');
expect(output).toContain('MockMarkdown:# My Plan');
expect(output).toContain('- Step 1: Do something');
expect(output).toContain('- Step 2: Do another thing');
});
it('renders approved plan content with approval message', () => {
const planResultDisplay = {
type: 'plan_summary' as const,
message: 'User approved the plan.',
plan: '# My Plan\n- Step 1\n- Step 2',
};
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ExitPlanMode"
description="Plan:"
status={ToolCallStatus.Success}
resultDisplay={planResultDisplay}
/>,
StreamingState.Idle,
);
const output = lastFrame();
expect(output).toContain('User approved the plan.');
expect(output).toContain('MockMarkdown:# My Plan');
expect(output).toContain('- Step 1');
expect(output).toContain('- Step 2');
});
});

View file

@ -94,7 +94,7 @@ export function CreationSummary({
}
// Check length warnings
if (state.generatedDescription.length > 300) {
if (state.generatedDescription.length > 1000) {
allWarnings.push(
t('Description is over {{length}} characters', {
length: state.generatedDescription.length.toString(),

View file

@ -32,6 +32,7 @@ import type { LoadedSettings } from '../../config/settings.js';
import { type CommandContext, type SlashCommand } from '../commands/types.js';
import { CommandService } from '../../services/CommandService.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { BundledSkillLoader } from '../../services/BundledSkillLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import { parseSlashCommand } from '../../utils/commands.js';
@ -313,6 +314,7 @@ export const useSlashCommandProcessor = (
const loaders = [
new McpPromptLoader(config),
new BuiltinCommandLoader(config),
new BundledSkillLoader(config),
new FileCommandLoader(config),
];
const commandService = await CommandService.create(

View file

@ -28,6 +28,7 @@ import {
ApprovalMode,
AuthType,
GeminiEventType as ServerGeminiEventType,
SendMessageType,
ToolErrorType,
ToolConfirmationOutcome,
} from '@qwen-code/qwen-code-core';
@ -483,7 +484,7 @@ describe('useGeminiStream', () => {
expectedMergedResponse,
expect.any(AbortSignal),
'prompt-id-2',
{ isContinuation: true },
{ type: SendMessageType.ToolResult },
);
});
@ -807,7 +808,7 @@ describe('useGeminiStream', () => {
toolCallResponseParts,
expect.any(AbortSignal),
'prompt-id-4',
{ isContinuation: true },
{ type: SendMessageType.ToolResult },
);
});
@ -1123,7 +1124,7 @@ describe('useGeminiStream', () => {
'This is the actual prompt from the command file.',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
@ -1150,7 +1151,7 @@ describe('useGeminiStream', () => {
'',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
});
@ -1169,7 +1170,7 @@ describe('useGeminiStream', () => {
'// This is a line comment',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
});
@ -1188,7 +1189,7 @@ describe('useGeminiStream', () => {
'/* This is a block comment */',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
});
@ -2092,7 +2093,7 @@ describe('useGeminiStream', () => {
processedQueryParts, // Argument 1: The parts array directly
expect.any(AbortSignal), // Argument 2: An AbortSignal
expect.any(String), // Argument 3: The prompt_id string
undefined, // Argument 4: Options (undefined for normal prompts)
{ type: SendMessageType.UserQuery }, // Argument 4: The options
);
});
@ -2777,7 +2778,7 @@ describe('useGeminiStream', () => {
'First query',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
// Verify only the first query was added to history
@ -2829,14 +2830,14 @@ describe('useGeminiStream', () => {
'First query',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
expect(mockSendMessageStream).toHaveBeenNthCalledWith(
2,
'Second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
@ -2859,7 +2860,7 @@ describe('useGeminiStream', () => {
'Second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
});

View file

@ -19,14 +19,17 @@ import type {
} from '@qwen-code/qwen-code-core';
import {
GeminiEventType as ServerGeminiEventType,
SendMessageType,
createDebugLogger,
getErrorMessage,
isNodeError,
MessageSenderType,
logUserPrompt,
logUserRetry,
GitService,
UnauthorizedError,
UserPromptEvent,
UserRetryEvent,
logConversationFinishedEvent,
ConversationFinishedEvent,
ApprovalMode,
@ -1088,19 +1091,22 @@ export const useGeminiStream = (
const submitQuery = useCallback(
async (
query: PartListUnion,
options?: { isContinuation: boolean; skipPreparation?: boolean },
submitType: SendMessageType = SendMessageType.UserQuery,
prompt_id?: string,
) => {
// Prevent concurrent executions of submitQuery, but allow continuations
// which are part of the same logical flow (tool responses)
if (isSubmittingQueryRef.current && !options?.isContinuation) {
if (
isSubmittingQueryRef.current &&
submitType !== SendMessageType.ToolResult
) {
return;
}
if (
(streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation) &&
!options?.isContinuation
submitType !== SendMessageType.ToolResult
)
return;
@ -1110,7 +1116,7 @@ export const useGeminiStream = (
const userMessageTimestamp = Date.now();
// Reset quota error flag when starting a new query (not a continuation)
if (!options?.isContinuation) {
if (submitType !== SendMessageType.ToolResult) {
setModelSwitchedFromQuotaError(false);
// Commit any pending retry error to history (without hint) since the
// user is starting a new conversation turn.
@ -1133,14 +1139,15 @@ export const useGeminiStream = (
}
return promptIdContext.run(prompt_id, async () => {
const { queryToSend, shouldProceed } = options?.skipPreparation
? { queryToSend: query, shouldProceed: true }
: await prepareQueryForGemini(
query,
userMessageTimestamp,
abortSignal,
prompt_id!,
);
const { queryToSend, shouldProceed } =
submitType === SendMessageType.Retry
? { queryToSend: query, shouldProceed: true }
: await prepareQueryForGemini(
query,
userMessageTimestamp,
abortSignal,
prompt_id!,
);
if (!shouldProceed || queryToSend === null) {
isSubmittingQueryRef.current = false;
@ -1148,7 +1155,7 @@ export const useGeminiStream = (
}
// Check image format support for non-continuations
if (!options?.isContinuation) {
if (submitType === SendMessageType.UserQuery) {
const formatCheck = checkImageFormatsSupport(queryToSend);
if (formatCheck.hasUnsupportedFormats) {
addItem(
@ -1165,7 +1172,7 @@ export const useGeminiStream = (
lastPromptRef.current = finalQueryToSend;
lastPromptErroredRef.current = false;
if (!options?.isContinuation) {
if (submitType === SendMessageType.UserQuery) {
// trigger new prompt event for session stats in CLI
startNewPrompt();
@ -1186,6 +1193,10 @@ export const useGeminiStream = (
setThought(null);
}
if (submitType === SendMessageType.Retry) {
logUserRetry(config, new UserRetryEvent(prompt_id));
}
setIsResponding(true);
setInitError(null);
@ -1194,7 +1205,7 @@ export const useGeminiStream = (
finalQueryToSend,
abortSignal,
prompt_id!,
options,
{ type: submitType },
);
const processingStatus = await processGeminiStreamEvents(
@ -1282,7 +1293,7 @@ export const useGeminiStream = (
*
* When conditions are met:
* - Clears any pending auto-retry countdown to avoid duplicate retries
* - Re-submits the last query with skipPreparation: true for faster retry
* - Re-submits the last query with isRetry: true, reusing the same prompt_id
*
* This function is exposed via UIActionsContext and triggered by InputPrompt
* when the user presses Ctrl+Y (bound to Command.RETRY_LAST in keyBindings.ts).
@ -1309,10 +1320,7 @@ export const useGeminiStream = (
clearRetryCountdown();
await submitQuery(lastPrompt, {
isContinuation: false,
skipPreparation: true,
});
await submitQuery(lastPrompt, SendMessageType.Retry);
}, [streamingState, addItem, clearRetryCountdown, submitQuery]);
const handleApprovalModeChange = useCallback(
@ -1461,13 +1469,7 @@ export const useGeminiStream = (
return;
}
submitQuery(
responsesToSend,
{
isContinuation: true,
},
prompt_ids[0],
);
submitQuery(responsesToSend, SendMessageType.ToolResult, prompt_ids[0]);
},
[
isResponding,

View file

@ -252,7 +252,6 @@ export function mapToDisplay(
status: mapCoreStatusToDisplayStatus(trackedCall.status),
resultDisplay: trackedCall.response.resultDisplay,
confirmationDetails: undefined,
outputFile: trackedCall.response.outputFile,
};
case 'error':
return {

View file

@ -69,7 +69,6 @@ export interface IndividualToolCallDisplay {
confirmationDetails: ToolCallConfirmationDetails | undefined;
renderOutputAsMarkdown?: boolean;
ptyId?: number;
outputFile?: string;
}
export interface CompressionProps {