feat(arena): improve cancellation handling and simplify to in-process mode

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

- Track user-initiated cancellation separately from failures

- Cancel round immediately when user denies a tool call

- Add message queue to handle input during streaming

- Add info messages during Arena operations (apply, stop, cleanup)

- Disable tmux/iTerm2 backends (only in-process mode supported)

- Polish UI: green tool count, updated warning prefix

This improves the Arena UX by providing clearer feedback and

properly handling user cancellations without treating them as failures.
This commit is contained in:
tanzhenxin 2026-03-12 16:57:44 +08:00
parent 3233d16b5c
commit 4ee94715df
13 changed files with 153 additions and 65 deletions

View file

@ -249,10 +249,7 @@ function executeArenaCommand(
} else if (event.type === 'info') {
addAndRecordArenaMessage(MessageType.INFO, event.message);
} else {
addAndRecordArenaMessage(
MessageType.WARNING,
`Arena warning: ${event.message}`,
);
addAndRecordArenaMessage(MessageType.WARNING, event.message);
}
};

View file

@ -18,9 +18,10 @@
*/
import { Box, Text, useStdin } from 'ink';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
AgentStatus,
isTerminalStatus,
ApprovalMode,
APPROVAL_MODES,
} from '@qwen-code/qwen-code-core';
@ -38,6 +39,7 @@ import { useTextBuffer } from '../shared/text-buffer.js';
import { calculatePromptWidths } from '../../utils/layoutUtils.js';
import { BaseTextInput } from '../BaseTextInput.js';
import { LoadingIndicator } from '../LoadingIndicator.js';
import { QueuedMessageDisplay } from '../QueuedMessageDisplay.js';
import { AgentFooter } from './AgentFooter.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { theme } from '../../semantic-colors.js';
@ -182,13 +184,35 @@ export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
[buffer, agentTabBarFocused, setAgentTabBarFocused],
);
// ── Message queue (accumulate while streaming, flush as one prompt on idle) ──
const [messageQueue, setMessageQueue] = useState<string[]>([]);
// When agent becomes idle (and not terminal), flush queued messages.
useEffect(() => {
if (
streamingState === StreamingState.Idle &&
messageQueue.length > 0 &&
status !== undefined &&
!isTerminalStatus(status)
) {
const combined = messageQueue.join('\n');
setMessageQueue([]);
interactiveAgent?.enqueueMessage(combined);
}
}, [streamingState, messageQueue, interactiveAgent, status]);
const handleSubmit = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed || !interactiveAgent) return;
interactiveAgent.enqueueMessage(trimmed);
if (streamingState === StreamingState.Idle) {
interactiveAgent.enqueueMessage(trimmed);
} else {
setMessageQueue((prev) => [...prev, trimmed]);
}
},
[interactiveAgent],
[interactiveAgent, streamingState],
);
// ── Render ──
@ -255,6 +279,8 @@ export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
</Box>
)}
<QueuedMessageDisplay messageQueue={messageQueue} />
{/* Input prompt — always visible, like the main Composer */}
<BaseTextInput
buffer={buffer}

View file

@ -74,6 +74,10 @@ export function ArenaSelectDialog({
mgr.getAgentStates().find((item) => item.agentId === agentId);
const label = agent?.model.modelId || agentId;
pushMessage({
messageType: 'info',
content: `Applying changes from ${label}`,
});
const result = await mgr.applyAgentResult(agentId);
if (!result.success) {
pushMessage({
@ -111,6 +115,10 @@ export function ArenaSelectDialog({
}
try {
pushMessage({
messageType: 'info',
content: 'Discarding Arena results and cleaning up…',
});
await config.cleanupArenaRuntime(true);
pushMessage({
messageType: 'info',

View file

@ -264,7 +264,11 @@ export function ArenaStatusDialog({
<Text color={theme.status.error}>{failedToolCalls}</Text>
</Text>
) : (
<Text color={theme.text.primary}>
<Text
color={
toolCalls > 0 ? theme.status.success : theme.text.primary
}
>
{pad(String(toolCalls), colTools - 1, 'right')}
</Text>
)}

View file

@ -80,9 +80,17 @@ export function ArenaStopDialog({
sessionStatus === ArenaSessionStatus.RUNNING ||
sessionStatus === ArenaSessionStatus.INITIALIZING
) {
pushMessage({
messageType: 'info',
content: 'Stopping Arena agents…',
});
await mgr.cancel();
}
await mgr.waitForSettled();
pushMessage({
messageType: 'info',
content: 'Cleaning up Arena resources…',
});
if (action === 'preserve') {
await mgr.cleanupRuntime();

View file

@ -75,7 +75,7 @@ export const SuccessMessage: React.FC<StatusTextProps> = ({ text }) => (
export const WarningMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix=""
prefix=""
prefixColor={theme.status.warning}
textColor={theme.status.warning}
/>

View file

@ -124,7 +124,8 @@ export function useAgentStreamingState(
}, [status, hasPendingApprovals]);
const isInputActive =
streamingState === StreamingState.Idle &&
(streamingState === StreamingState.Idle ||
streamingState === StreamingState.Responding) &&
status !== undefined &&
!isTerminalStatus(status);