mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
feat(cli): add agent composer UI and refactor text input handling
- Extract shared BaseTextInput component with readline keyboard handling - Add AgentComposer and AgentFooter components for agent interaction - Add useAgentStreamingState hook for managing agent streaming state - Refactor InputPrompt to use BaseTextInput with agent tab bar focus support - Move calculatePromptWidths to shared layoutUtils - Disable auto-accept indicator on agent tabs (agents handle their own) This enables a dedicated input experience for agent tabs with proper focus management and keyboard navigation between main input and agent tabs. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
eaef9efe90
commit
89f8751233
19 changed files with 1273 additions and 337 deletions
284
packages/cli/src/ui/components/agent-view/AgentComposer.tsx
Normal file
284
packages/cli/src/ui/components/agent-view/AgentComposer.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
/**
|
||||
* @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 } from 'react';
|
||||
import {
|
||||
AgentStatus,
|
||||
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 { 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],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || !interactiveAgent) return;
|
||||
interactiveAgent.enqueueMessage(trimmed);
|
||||
},
|
||||
[interactiveAgent],
|
||||
);
|
||||
|
||||
// ── 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('Agent is working…')
|
||||
: undefined
|
||||
}
|
||||
elapsedTime={elapsedTime}
|
||||
/>
|
||||
|
||||
{/* Terminal status for completed/failed agents */}
|
||||
{statusLabel && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={statusLabel.color}>{statusLabel.text}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
{isInputActive && (
|
||||
<AgentFooter
|
||||
approvalMode={agentApprovalMode}
|
||||
promptTokenCount={lastPromptTokenCount}
|
||||
contextWindowSize={
|
||||
config.getContentGeneratorConfig()?.contextWindowSize
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</StreamingContext.Provider>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue