mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-02 13:40:46 +00:00
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.
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* @fileoverview AgentComposer — footer area for in-process agent tabs.
|
|
*
|
|
* Replaces the main Composer when an agent tab is active so that:
|
|
* - The loading indicator reflects the agent's status (not the main agent)
|
|
* - The input prompt sends messages to the agent (via enqueueMessage)
|
|
* - Keyboard events are scoped — no conflict with the main InputPrompt
|
|
*
|
|
* Wraps its content in a local StreamingContext.Provider so reusable
|
|
* components like LoadingIndicator and GeminiRespondingSpinner read the
|
|
* agent's derived streaming state instead of the main agent's.
|
|
*/
|
|
|
|
import { Box, Text, useStdin } from 'ink';
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
AgentStatus,
|
|
isTerminalStatus,
|
|
ApprovalMode,
|
|
APPROVAL_MODES,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import {
|
|
useAgentViewState,
|
|
useAgentViewActions,
|
|
} from '../../contexts/AgentViewContext.js';
|
|
import { useConfig } from '../../contexts/ConfigContext.js';
|
|
import { StreamingContext } from '../../contexts/StreamingContext.js';
|
|
import { StreamingState } from '../../types.js';
|
|
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
|
import { useAgentStreamingState } from '../../hooks/useAgentStreamingState.js';
|
|
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
|
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';
|
|
import { t } from '../../../i18n/index.js';
|
|
|
|
// ─── Types ──────────────────────────────────────────────────
|
|
|
|
interface AgentComposerProps {
|
|
agentId: string;
|
|
}
|
|
|
|
// ─── Component ──────────────────────────────────────────────
|
|
|
|
export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
|
|
const { agents, agentTabBarFocused, agentShellFocused, agentApprovalModes } =
|
|
useAgentViewState();
|
|
const {
|
|
setAgentInputBufferText,
|
|
setAgentTabBarFocused,
|
|
setAgentApprovalMode,
|
|
} = useAgentViewActions();
|
|
const agent = agents.get(agentId);
|
|
const interactiveAgent = agent?.interactiveAgent;
|
|
|
|
const config = useConfig();
|
|
const { columns: terminalWidth } = useTerminalSize();
|
|
const { inputWidth } = calculatePromptWidths(terminalWidth);
|
|
const { stdin, setRawMode } = useStdin();
|
|
|
|
const {
|
|
status,
|
|
streamingState,
|
|
isInputActive,
|
|
elapsedTime,
|
|
lastPromptTokenCount,
|
|
} = useAgentStreamingState(interactiveAgent);
|
|
|
|
// ── Escape to cancel the active agent round ──
|
|
|
|
useKeypress(
|
|
(key) => {
|
|
if (
|
|
key.name === 'escape' &&
|
|
streamingState === StreamingState.Responding
|
|
) {
|
|
interactiveAgent?.cancelCurrentRound();
|
|
}
|
|
},
|
|
{
|
|
isActive:
|
|
streamingState === StreamingState.Responding && !agentShellFocused,
|
|
},
|
|
);
|
|
|
|
// ── Shift+Tab to cycle this agent's approval mode ──
|
|
|
|
const agentApprovalMode =
|
|
agentApprovalModes.get(agentId) ?? ApprovalMode.DEFAULT;
|
|
|
|
useKeypress(
|
|
(key) => {
|
|
const isShiftTab = key.shift && key.name === 'tab';
|
|
const isWindowsTab =
|
|
process.platform === 'win32' &&
|
|
key.name === 'tab' &&
|
|
!key.ctrl &&
|
|
!key.meta;
|
|
if (isShiftTab || isWindowsTab) {
|
|
const currentIndex = APPROVAL_MODES.indexOf(agentApprovalMode);
|
|
const nextIndex =
|
|
currentIndex === -1 ? 0 : (currentIndex + 1) % APPROVAL_MODES.length;
|
|
setAgentApprovalMode(agentId, APPROVAL_MODES[nextIndex]!);
|
|
}
|
|
},
|
|
{ isActive: !agentShellFocused },
|
|
);
|
|
|
|
// ── Input buffer (independent from main agent) ──
|
|
|
|
const isValidPath = useCallback((): boolean => false, []);
|
|
|
|
const buffer = useTextBuffer({
|
|
initialText: '',
|
|
viewport: { height: 3, width: inputWidth },
|
|
stdin,
|
|
setRawMode,
|
|
isValidPath,
|
|
});
|
|
|
|
// Sync agent buffer text to context so AgentTabBar can guard tab switching
|
|
useEffect(() => {
|
|
setAgentInputBufferText(buffer.text);
|
|
return () => setAgentInputBufferText('');
|
|
}, [buffer.text, setAgentInputBufferText]);
|
|
|
|
// When agent input is not active (agent running, completed, etc.),
|
|
// auto-focus the tab bar so arrow keys switch tabs directly.
|
|
// We also depend on streamingState so that transitions like
|
|
// WaitingForConfirmation → Responding re-trigger the effect — the
|
|
// approval keypress releases tab-bar focus (printable char handler),
|
|
// but isInputActive stays false throughout, so without this extra
|
|
// dependency the focus would never be restored.
|
|
useEffect(() => {
|
|
if (!isInputActive) {
|
|
setAgentTabBarFocused(true);
|
|
}
|
|
}, [isInputActive, streamingState, setAgentTabBarFocused]);
|
|
|
|
// ── Focus management between input and tab bar ──
|
|
|
|
const handleKeypress = useCallback(
|
|
(key: Key): boolean => {
|
|
// When tab bar has focus, block all non-printable keys so they don't
|
|
// act on the hidden buffer. Printable characters fall through to
|
|
// BaseTextInput naturally; the tab bar handler releases focus on the
|
|
// same event so the keystroke appears in the input immediately.
|
|
if (agentTabBarFocused) {
|
|
if (
|
|
key.sequence &&
|
|
key.sequence.length === 1 &&
|
|
!key.ctrl &&
|
|
!key.meta
|
|
) {
|
|
return false; // let BaseTextInput type the character
|
|
}
|
|
return true; // consume non-printable keys
|
|
}
|
|
|
|
// Down arrow at the bottom edge (or empty buffer) → focus the tab bar
|
|
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
|
if (
|
|
buffer.text === '' ||
|
|
buffer.allVisualLines.length === 1 ||
|
|
buffer.visualCursor[0] === buffer.allVisualLines.length - 1
|
|
) {
|
|
setAgentTabBarFocused(true);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
[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;
|
|
if (streamingState === StreamingState.Idle) {
|
|
interactiveAgent.enqueueMessage(trimmed);
|
|
} else {
|
|
setMessageQueue((prev) => [...prev, trimmed]);
|
|
}
|
|
},
|
|
[interactiveAgent, streamingState],
|
|
);
|
|
|
|
// ── Render ──
|
|
|
|
const statusLabel = useMemo(() => {
|
|
switch (status) {
|
|
case AgentStatus.COMPLETED:
|
|
return { text: t('Completed'), color: theme.status.success };
|
|
case AgentStatus.FAILED:
|
|
return {
|
|
text: t('Failed: {{error}}', {
|
|
error:
|
|
interactiveAgent?.getError() ??
|
|
interactiveAgent?.getLastRoundError() ??
|
|
'unknown',
|
|
}),
|
|
color: theme.status.error,
|
|
};
|
|
case AgentStatus.CANCELLED:
|
|
return { text: t('Cancelled'), color: theme.text.secondary };
|
|
default:
|
|
return null;
|
|
}
|
|
}, [status, interactiveAgent]);
|
|
|
|
// ── Approval-mode styling (mirrors main InputPrompt) ──
|
|
|
|
const isYolo = agentApprovalMode === ApprovalMode.YOLO;
|
|
const isAutoAccept = agentApprovalMode !== ApprovalMode.DEFAULT;
|
|
|
|
const statusColor = isYolo
|
|
? theme.status.errorDim
|
|
: isAutoAccept
|
|
? theme.status.warningDim
|
|
: undefined;
|
|
|
|
const inputBorderColor =
|
|
!isInputActive || agentTabBarFocused
|
|
? theme.border.default
|
|
: (statusColor ?? theme.border.focused);
|
|
|
|
const prefixNode = (
|
|
<Text color={statusColor ?? theme.text.accent}>{isYolo ? '*' : '>'} </Text>
|
|
);
|
|
|
|
return (
|
|
<StreamingContext.Provider value={streamingState}>
|
|
<Box flexDirection="column" marginTop={1}>
|
|
{/* Loading indicator — mirrors main Composer but reads agent's
|
|
streaming state via the overridden StreamingContext. */}
|
|
<LoadingIndicator
|
|
currentLoadingPhrase={
|
|
streamingState === StreamingState.Responding
|
|
? t('Thinking…')
|
|
: undefined
|
|
}
|
|
elapsedTime={elapsedTime}
|
|
/>
|
|
|
|
{/* Terminal status for completed/failed agents */}
|
|
{statusLabel && (
|
|
<Box marginLeft={2}>
|
|
<Text color={statusLabel.color}>{statusLabel.text}</Text>
|
|
</Box>
|
|
)}
|
|
|
|
<QueuedMessageDisplay messageQueue={messageQueue} />
|
|
|
|
{/* Input prompt — always visible, like the main Composer */}
|
|
<BaseTextInput
|
|
buffer={buffer}
|
|
onSubmit={handleSubmit}
|
|
onKeypress={handleKeypress}
|
|
showCursor={isInputActive && !agentTabBarFocused}
|
|
placeholder={' ' + t('Send a message to this agent')}
|
|
prefix={prefixNode}
|
|
borderColor={inputBorderColor}
|
|
isActive={isInputActive && !agentShellFocused}
|
|
/>
|
|
|
|
{/* Footer: approval mode + context usage */}
|
|
<AgentFooter
|
|
approvalMode={agentApprovalMode}
|
|
promptTokenCount={lastPromptTokenCount}
|
|
contextWindowSize={
|
|
config.getContentGeneratorConfig()?.contextWindowSize
|
|
}
|
|
terminalWidth={terminalWidth}
|
|
/>
|
|
</Box>
|
|
</StreamingContext.Provider>
|
|
);
|
|
};
|