Merge branch 'main' into feat/support-permission

This commit is contained in:
LaZzyMan 2026-03-19 11:24:30 +08:00
commit f9d9a985ce
249 changed files with 26635 additions and 2729 deletions

View file

@ -0,0 +1,287 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview BaseTextInput shared text input component with rendering
* and common readline keyboard handling.
*
* Provides:
* - Viewport line rendering from a TextBuffer with cursor display
* - Placeholder support when buffer is empty
* - Configurable border/prefix styling
* - Standard readline shortcuts (Ctrl+A/E/K/U/W, Escape, etc.)
* - An `onKeypress` interceptor so consumers can layer custom behavior
*
* Used by both InputPrompt (with syntax highlighting + complex key handling)
* and AgentComposer (with minimal customization).
*/
import type React from 'react';
import { useCallback } from 'react';
import { Box, Text } from 'ink';
import chalk from 'chalk';
import type { TextBuffer } from './shared/text-buffer.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import { theme } from '../semantic-colors.js';
// ─── Types ──────────────────────────────────────────────────
export interface RenderLineOptions {
/** The text content of this visual line. */
lineText: string;
/** Whether the cursor is on this visual line. */
isOnCursorLine: boolean;
/** The cursor column within this visual line (visual col, not logical). */
cursorCol: number;
/** Whether the cursor should be rendered. */
showCursor: boolean;
/** Index of this line within the rendered viewport (0-based). */
visualLineIndex: number;
/** Absolute visual line index (scrollVisualRow + visualLineIndex). */
absoluteVisualIndex: number;
/** The underlying text buffer. */
buffer: TextBuffer;
/** The first visible visual row (scroll offset). */
scrollVisualRow: number;
}
export interface BaseTextInputProps {
/** The text buffer driving this input. */
buffer: TextBuffer;
/** Called when the user submits (Enter). Buffer is cleared automatically. */
onSubmit: (text: string) => void;
/**
* Optional key interceptor. Called before default readline handling.
* Return `true` if the key was handled (skips default processing).
*/
onKeypress?: (key: Key) => boolean;
/** Whether to show the blinking block cursor. Defaults to true. */
showCursor?: boolean;
/** Placeholder text shown when the buffer is empty. */
placeholder?: string;
/** Custom prefix node (defaults to `> `). */
prefix?: React.ReactNode;
/** Border color for the input box. */
borderColor?: string;
/** Whether keyboard handling is active. Defaults to true. */
isActive?: boolean;
/**
* Custom line renderer for advanced rendering (e.g. syntax highlighting).
* When not provided, lines are rendered as plain text with cursor overlay.
*/
renderLine?: (opts: RenderLineOptions) => React.ReactNode;
}
// ─── Default line renderer ──────────────────────────────────
/**
* Renders a single visual line with an inverse-video block cursor.
* Uses codepoint-aware string operations for Unicode/emoji safety.
*/
export function defaultRenderLine({
lineText,
isOnCursorLine,
cursorCol,
showCursor,
}: RenderLineOptions): React.ReactNode {
if (!isOnCursorLine || !showCursor) {
return <Text>{lineText || ' '}</Text>;
}
const len = cpLen(lineText);
// Cursor past end of line — append inverse space
if (cursorCol >= len) {
return (
<Text>
{lineText}
{chalk.inverse(' ') + '\u200B'}
</Text>
);
}
const before = cpSlice(lineText, 0, cursorCol);
const cursorChar = cpSlice(lineText, cursorCol, cursorCol + 1);
const after = cpSlice(lineText, cursorCol + 1);
return (
<Text>
{before}
{chalk.inverse(cursorChar)}
{after}
</Text>
);
}
// ─── Component ──────────────────────────────────────────────
export const BaseTextInput: React.FC<BaseTextInputProps> = ({
buffer,
onSubmit,
onKeypress,
showCursor = true,
placeholder,
prefix,
borderColor,
isActive = true,
renderLine = defaultRenderLine,
}) => {
// ── Keyboard handling ──
const handleKey = useCallback(
(key: Key) => {
// Let the consumer intercept first
if (onKeypress?.(key)) {
return;
}
// ── Standard readline shortcuts ──
// Submit (Enter, no modifiers)
if (keyMatchers[Command.SUBMIT](key)) {
if (buffer.text.trim()) {
const text = buffer.text;
buffer.setText('');
onSubmit(text);
}
return;
}
// Newline (Shift+Enter, Ctrl+Enter, Ctrl+J)
if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return;
}
// Escape → clear input
if (keyMatchers[Command.ESCAPE](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
}
return;
}
// Ctrl+C → clear input
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
}
return;
}
// Ctrl+A → home
if (keyMatchers[Command.HOME](key)) {
buffer.move('home');
return;
}
// Ctrl+E → end
if (keyMatchers[Command.END](key)) {
buffer.move('end');
return;
}
// Ctrl+K → kill to end of line
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
buffer.killLineRight();
return;
}
// Ctrl+U → kill to start of line
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
buffer.killLineLeft();
return;
}
// Ctrl+W / Alt+Backspace → delete word backward
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
buffer.deleteWordLeft();
return;
}
// Ctrl+X Ctrl+E → open in external editor
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
buffer.openInExternalEditor();
return;
}
// Backspace
if (
key.name === 'backspace' ||
key.sequence === '\x7f' ||
(key.ctrl && key.name === 'h')
) {
buffer.backspace();
return;
}
// Fallthrough — delegate to buffer's built-in input handler
buffer.handleInput(key);
},
[buffer, onSubmit, onKeypress],
);
useKeypress(handleKey, { isActive });
// ── Rendering ──
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRow, cursorVisualCol] = buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
const resolvedBorderColor = borderColor ?? theme.border.focused;
const resolvedPrefix = prefix ?? (
<Text color={theme.text.accent}>{'> '}</Text>
);
return (
<Box
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
borderColor={resolvedBorderColor}
>
{resolvedPrefix}
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, idx) => {
const absoluteVisualIndex = scrollVisualRow + idx;
const isOnCursorLine = absoluteVisualIndex === cursorVisualRow;
return (
<Box key={idx} height={1}>
{renderLine({
lineText,
isOnCursorLine,
cursorCol: cursorVisualCol,
showCursor,
visualLineIndex: idx,
absoluteVisualIndex,
buffer,
scrollVisualRow,
})}
</Box>
);
})
)}
</Box>
</Box>
);
};

View file

@ -111,6 +111,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
debugMessage: '',
nightly: false,
isTrustedFolder: true,
taskStartTokens: 0,
...overrides,
}) as UIState;

View file

@ -27,7 +27,17 @@ export const Composer = () => {
const uiActions = useUIActions();
const { vimEnabled } = useVimMode();
const { showAutoAcceptIndicator } = uiState;
const { showAutoAcceptIndicator, sessionStats, taskStartTokens } = uiState;
const tokens = Object.values(sessionStats.metrics?.models ?? {}).reduce(
(acc, model) => ({
prompt: acc.prompt + (model.tokens?.prompt ?? 0),
candidates: acc.candidates + (model.tokens?.candidates ?? 0),
}),
{ prompt: 0, candidates: 0 },
);
const taskTokens = tokens.candidates - taskStartTokens;
// State for keyboard shortcuts display toggle
const [showShortcuts, setShowShortcuts] = useState(false);
@ -64,6 +74,7 @@ export const Composer = () => {
: uiState.currentLoadingPhrase
}
elapsedTime={uiState.elapsedTime}
candidatesTokens={taskTokens}
/>
)}
@ -104,8 +115,8 @@ export const Composer = () => {
{/* Exclusive area: only one component visible at a time */}
{/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */}
{!showSuggestions &&
uiState.streamingState !== StreamingState.WaitingForConfirmation &&
{uiState.isInputActive &&
!showSuggestions &&
(showShortcuts ? (
<KeyboardShortcuts />
) : (

View file

@ -21,6 +21,10 @@ import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { TrustDialog } from './TrustDialog.js';
import { PermissionsDialog } from './PermissionsDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { ArenaStartDialog } from './arena/ArenaStartDialog.js';
import { ArenaSelectDialog } from './arena/ArenaSelectDialog.js';
import { ArenaStopDialog } from './arena/ArenaStopDialog.js';
import { ArenaStatusDialog } from './arena/ArenaStatusDialog.js';
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
@ -238,6 +242,49 @@ export const DialogManager = ({
if (uiState.isModelDialogOpen) {
return <ModelDialog onClose={uiActions.closeModelDialog} />;
}
if (uiState.activeArenaDialog === 'start') {
return (
<ArenaStartDialog
onClose={() => uiActions.closeArenaDialog()}
onConfirm={(models) => uiActions.handleArenaModelsSelected?.(models)}
/>
);
}
if (uiState.activeArenaDialog === 'status') {
const arenaManager = config.getArenaManager();
if (arenaManager) {
return (
<ArenaStatusDialog
manager={arenaManager}
closeArenaDialog={uiActions.closeArenaDialog}
width={mainAreaWidth}
/>
);
}
}
if (uiState.activeArenaDialog === 'stop') {
return (
<ArenaStopDialog
config={config}
addItem={addItem}
closeArenaDialog={uiActions.closeArenaDialog}
/>
);
}
if (uiState.activeArenaDialog === 'select') {
const arenaManager = config.getArenaManager();
if (arenaManager) {
return (
<ArenaSelectDialog
manager={arenaManager}
config={config}
addItem={addItem}
closeArenaDialog={uiActions.closeArenaDialog}
/>
);
}
}
if (uiState.isAuthDialogOpen || uiState.authError) {
return (
<Box flexDirection="column">

View file

@ -24,6 +24,7 @@ import {
WarningMessage,
ErrorMessage,
RetryCountdownMessage,
SuccessMessage,
} from './messages/StatusMessages.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
@ -38,6 +39,8 @@ import { getMCPServerStatus } from '@qwen-code/qwen-code-core';
import { SkillsList } from './views/SkillsList.js';
import { ToolsList } from './views/ToolsList.js';
import { McpStatus } from './views/McpStatus.js';
import { ContextUsage } from './views/ContextUsage.js';
import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
interface HistoryItemDisplayProps {
@ -132,6 +135,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'info' && (
<InfoMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'success' && (
<SuccessMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'warning' && (
<WarningMessage text={itemForDisplay.text} />
)}
@ -191,6 +197,32 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}
{itemForDisplay.type === 'context_usage' && (
<ContextUsage
modelName={itemForDisplay.modelName}
totalTokens={itemForDisplay.totalTokens}
contextWindowSize={itemForDisplay.contextWindowSize}
breakdown={itemForDisplay.breakdown}
builtinTools={itemForDisplay.builtinTools}
mcpTools={itemForDisplay.mcpTools}
memoryFiles={itemForDisplay.memoryFiles}
skills={itemForDisplay.skills}
isEstimated={itemForDisplay.isEstimated}
showDetails={itemForDisplay.showDetails}
/>
)}
{itemForDisplay.type === 'arena_agent_complete' && (
<ArenaAgentCard agent={itemForDisplay.agent} width={boxWidth} />
)}
{itemForDisplay.type === 'arena_session_complete' && (
<ArenaSessionCard
sessionStatus={itemForDisplay.sessionStatus}
task={itemForDisplay.task}
totalDurationMs={itemForDisplay.totalDurationMs}
agents={itemForDisplay.agents}
width={boxWidth}
/>
)}
{itemForDisplay.type === 'insight_progress' && (
<InsightProgressMessage progress={itemForDisplay.progress} />
)}

View file

@ -1957,6 +1957,25 @@ describe('InputPrompt', () => {
});
describe('command search (Ctrl+R when not in shell)', () => {
it('passes newest-first user history to command search', async () => {
props.shellModeActive = false;
props.userMessages = ['oldest', 'middle', 'newest'];
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
const commandSearchCall =
mockedUseReverseSearchCompletion.mock.calls.find(
([, history]) =>
Array.isArray(history) &&
history.length === 3 &&
history.includes('newest'),
);
expect(commandSearchCall?.[1]).toEqual(['newest', 'middle', 'oldest']);
unmount();
});
it('enters command search on Ctrl+R and shows suggestions', async () => {
props.shellModeActive = false;

View file

@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useCallback, useEffect, useState, useRef } from 'react';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { Box, Text } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
@ -18,7 +18,6 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
@ -43,7 +42,13 @@ import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';
import {
useAgentViewState,
useAgentViewActions,
} from '../contexts/AgentViewContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
import { BaseTextInput } from './BaseTextInput.js';
import type { RenderLineOptions } from './BaseTextInput.js';
/**
* Represents an attachment (e.g., pasted image) displayed above the input prompt
@ -78,30 +83,8 @@ export interface InputPromptProps {
isEmbeddedShellFocused?: boolean;
}
// The input content, input container, and input suggestions list may have different widths
export const calculatePromptWidths = (terminalWidth: number) => {
const widthFraction = 0.9;
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
const MIN_CONTENT_WIDTH = 2;
const innerContentWidth =
Math.floor(terminalWidth * widthFraction) -
FRAME_PADDING_AND_BORDER -
PROMPT_PREFIX_WIDTH;
const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth);
const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
const containerWidth = inputWidth + FRAME_OVERHEAD;
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0));
return {
inputWidth,
containerWidth,
suggestionsWidth,
frameOverhead: FRAME_OVERHEAD,
} as const;
};
// Re-export from shared utils for backwards compatibility
export { calculatePromptWidths } from '../utils/layoutUtils.js';
// Large paste placeholder thresholds
const LARGE_PASTE_CHAR_THRESHOLD = 1000;
@ -132,6 +115,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const uiState = useUIState();
const uiActions = useUIActions();
const { pasteWorkaround } = useKeypressContext();
const { agents, agentTabBarFocused } = useAgentViewState();
const { setAgentTabBarFocused } = useAgentViewActions();
const hasAgents = agents.size > 0;
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@ -213,9 +199,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchActive,
);
const commandSearchHistory = useMemo(
() => [...userMessages].reverse(),
[userMessages],
);
const commandSearchCompletion = useReverseSearchCompletion(
buffer,
userMessages,
commandSearchHistory,
commandSearchActive,
);
@ -225,7 +216,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const resetCommandSearchCompletionState =
commandSearchCompletion.resetCompletionState;
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
const showCursor =
focus && isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused;
const resetEscapeState = useCallback(() => {
if (escapeTimerRef.current) {
@ -351,6 +343,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onChange: customSetTextAndResetCompletionSignal,
});
// When an arena session starts (agents appear), reset history position so
// that pressing down-arrow immediately focuses the agent tab bar instead
// of cycling through input history.
const prevHasAgentsRef = useRef(hasAgents);
useEffect(() => {
if (hasAgents && !prevHasAgentsRef.current) {
inputHistory.resetHistoryNav();
}
prevHasAgentsRef.current = hasAgents;
}, [hasAgents, inputHistory]);
// Effect to reset completion if history navigation just occurred and set the text
useEffect(() => {
if (justNavigatedHistory) {
@ -411,13 +414,30 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}, []);
const handleInput = useCallback(
(key: Key) => {
(key: Key): boolean => {
// When the tab bar has focus, block all non-printable keys so arrow
// keys and shortcuts don't interfere. Printable characters fall
// through to BaseTextInput's default handler so the first keystroke
// appears in the input immediately (the tab bar handler releases
// focus on the same event).
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
}
// TODO(jacobr): this special case is likely not needed anymore.
// We should probably stop supporting paste if the InputPrompt is not
// focused.
/// We want to handle paste even when not focused to support drag and drop.
if (!focus && !key.paste) {
return;
return true;
}
if (key.paste) {
@ -459,18 +479,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Normal paste handling for small content
buffer.handleInput(key);
}
return;
return true;
}
if (vimHandleInput && vimHandleInput(key)) {
return;
return true;
}
// Handle feedback dialog keyboard interactions when dialog is open
if (uiState.isFeedbackDialogOpen) {
// If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
return;
return true;
} else {
// For any other key, close feedback dialog temporarily and continue with normal processing
uiActions.temporaryCloseFeedbackDialog();
@ -496,7 +516,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input
return;
return true;
}
// Toggle keyboard shortcuts display with "?" when buffer is empty
@ -507,7 +527,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onToggleShortcuts
) {
onToggleShortcuts();
return;
return true;
}
// Hide shortcuts on any other key press
@ -537,33 +557,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setReverseSearchActive,
reverseSearchCompletion.resetCompletionState,
);
return;
return true;
}
if (commandSearchActive) {
cancelSearch(
setCommandSearchActive,
commandSearchCompletion.resetCompletionState,
);
return;
return true;
}
if (shellModeActive) {
setShellModeActive(false);
resetEscapeState();
return;
return true;
}
if (completion.showSuggestions) {
completion.resetCompletionState();
setExpandedSuggestionIndex(-1);
resetEscapeState();
return;
return true;
}
// Handle double ESC for clearing input
if (escPressCount === 0) {
if (buffer.text === '') {
return;
return true;
}
setEscPressCount(1);
setShowEscapePrompt(true);
@ -579,7 +599,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCompletionState();
resetEscapeState();
}
return;
return true;
}
// Ctrl+Y: Retry the last failed request.
@ -589,19 +609,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// If no failed request exists, a message will be shown to the user.
if (keyMatchers[Command.RETRY_LAST](key)) {
uiActions.handleRetryLastPrompt();
return;
return true;
}
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
return true;
}
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
onClearScreen();
return;
return true;
}
if (reverseSearchActive || commandSearchActive) {
@ -626,29 +646,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (showSuggestions) {
if (keyMatchers[Command.NAVIGATION_UP](key)) {
navigateUp();
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
navigateDown();
return;
return true;
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(-1);
return;
return true;
}
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(activeSuggestionIndex);
return;
return true;
}
}
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
sc.handleAutocomplete(activeSuggestionIndex);
resetState();
setActive(false);
return;
return true;
}
}
@ -660,7 +680,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleSubmitAndClear(textToSubmit);
resetState();
setActive(false);
return;
return true;
}
// Prevent up/down from falling through to regular history navigation
@ -668,14 +688,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
keyMatchers[Command.NAVIGATION_UP](key) ||
keyMatchers[Command.NAVIGATION_DOWN](key)
) {
return;
return true;
}
}
// If the command is a perfect match, pressing enter should execute it.
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
handleSubmitAndClear(buffer.text);
return;
return true;
}
if (completion.showSuggestions) {
@ -683,12 +703,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
return true;
}
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
completion.navigateDown();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
return true;
}
}
@ -703,7 +723,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setExpandedSuggestionIndex(-1); // Reset expansion after selection
}
}
return;
return true;
}
}
@ -711,28 +731,28 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (isAttachmentMode && attachments.length > 0) {
if (key.name === 'left') {
setSelectedAttachmentIndex((i) => Math.max(0, i - 1));
return;
return true;
}
if (key.name === 'right') {
setSelectedAttachmentIndex((i) =>
Math.min(attachments.length - 1, i + 1),
);
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
// Exit attachment mode and return to input
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
return;
return true;
}
if (key.name === 'backspace' || key.name === 'delete') {
handleAttachmentDelete(selectedAttachmentIndex);
return;
return true;
}
if (key.name === 'return' || key.name === 'escape') {
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
return;
return true;
}
// For other keys, exit attachment mode and let input handle them
setIsAttachmentMode(false);
@ -753,7 +773,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
) {
setIsAttachmentMode(true);
setSelectedAttachmentIndex(attachments.length - 1);
return;
return true;
}
if (!shellModeActive) {
@ -761,16 +781,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setCommandSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
return true;
}
if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
return;
return true;
}
if (keyMatchers[Command.HISTORY_DOWN](key)) {
inputHistory.navigateDown();
return;
return true;
}
// Handle arrow-up/down for history on single-line or at edges
if (
@ -779,27 +799,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
) {
inputHistory.navigateUp();
return;
return true;
}
if (
keyMatchers[Command.NAVIGATION_DOWN](key) &&
(buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
) {
inputHistory.navigateDown();
return;
if (inputHistory.navigateDown()) {
return true;
}
if (hasAgents) {
setAgentTabBarFocused(true);
return true;
}
return true;
}
} else {
// Shell History Navigation
if (keyMatchers[Command.NAVIGATION_UP](key)) {
const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand);
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
const nextCommand = shellHistory.getNextCommand();
if (nextCommand !== null) buffer.setText(nextCommand);
return;
return true;
}
}
@ -810,7 +836,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// paste markers may not work reliably and Enter key events can leak from pasted text.
if (pasteWorkaround && recentPasteTime !== null) {
// Paste occurred recently, ignore this submit to prevent auto-execution
return;
return true;
}
const [row, col] = buffer.cursor;
@ -823,65 +849,21 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleSubmitAndClear(buffer.text);
}
}
return;
}
// Newline insertion
if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return;
}
// Ctrl+A (Home) / Ctrl+E (End)
if (keyMatchers[Command.HOME](key)) {
buffer.move('home');
return;
}
if (keyMatchers[Command.END](key)) {
buffer.move('end');
return;
}
// Ctrl+C (Clear input)
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
}
return;
}
// Kill line commands
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
buffer.killLineRight();
return;
}
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
buffer.killLineLeft();
return;
}
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
buffer.deleteWordLeft();
return;
}
// External editor
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
buffer.openInExternalEditor();
return;
return true;
}
// Ctrl+V for clipboard image paste
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
handleClipboardImage();
return;
return true;
}
// Handle backspace with placeholder-aware deletion
if (
key.name === 'backspace' ||
key.sequence === '\x7f' ||
(key.ctrl && key.name === 'h')
pendingPastes.size > 0 &&
(key.name === 'backspace' ||
key.sequence === '\x7f' ||
(key.ctrl && key.name === 'h'))
) {
const text = buffer.text;
const [row, col] = buffer.cursor;
@ -894,7 +876,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
offset += col;
// Check if we're at the end of any placeholder
let placeholderDeleted = false;
for (const placeholder of pendingPastes.keys()) {
const placeholderStart = offset - placeholder.length;
if (
@ -913,20 +894,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (parsed) {
freePlaceholderId(parsed.charCount, parsed.id);
}
placeholderDeleted = true;
break;
return true;
}
}
if (!placeholderDeleted) {
// Normal backspace behavior
buffer.backspace();
}
return;
// No placeholder matched — fall through to BaseTextInput's default backspace
}
// Fall back to the text buffer's default input handling for all other keys
buffer.handleInput(key);
// Ctrl+C with completion active — also reset completion state
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
resetCompletionState();
}
// Fall through to BaseTextInput's default CLEAR_INPUT handler
}
// All remaining keys (readline shortcuts, text input) handled by BaseTextInput
return false;
},
[
focus,
@ -964,15 +947,89 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
pendingPastes,
parsePlaceholder,
freePlaceholderId,
agentTabBarFocused,
hasAgents,
setAgentTabBarFocused,
],
);
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
const renderLineWithHighlighting = useCallback(
(opts: RenderLineOptions): React.ReactNode => {
const {
lineText,
isOnCursorLine,
cursorCol: cursorVisualColAbsolute,
showCursor: showCursorOpt,
absoluteVisualIndex,
buffer: buf,
} = opts;
const mapEntry = buf.visualToLogicalMap[absoluteVisualIndex];
const [logicalLineIdx, logicalStartCol] = mapEntry;
const logicalLine = buf.lines[logicalLineIdx] || '';
const tokens = parseInputForHighlighting(logicalLine, logicalLineIdx);
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
const visualStart = logicalStartCol;
const visualEnd = logicalStartCol + cpLen(lineText);
const segments = buildSegmentsForVisualSlice(
tokens,
visualStart,
visualEnd,
);
const renderedLine: React.ReactNode[] = [];
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
if (isOnCursorLine) {
const segStart = charCount;
const segEnd = segStart + segLen;
if (
cursorVisualColAbsolute >= segStart &&
cursorVisualColAbsolute < segEnd
) {
const charToHighlight = cpSlice(
seg.text,
cursorVisualColAbsolute - segStart,
cursorVisualColAbsolute - segStart + 1,
);
const highlighted = showCursorOpt
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(seg.text, 0, cursorVisualColAbsolute - segStart) +
highlighted +
cpSlice(seg.text, cursorVisualColAbsolute - segStart + 1);
}
charCount = segEnd;
}
const color =
seg.type === 'command' || seg.type === 'file'
? theme.text.accent
: theme.text.primary;
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
if (isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText)) {
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
</Text>,
);
}
return <Text>{renderedLine}</Text>;
},
[],
);
const getActiveCompletion = () => {
if (commandSearchActive) return commandSearchCompletion;
@ -1009,10 +1066,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
const borderColor =
isShellFocused && !isEmbeddedShellFocused
isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused
? (statusColor ?? theme.border.focused)
: theme.border.default;
const prefixNode = (
<Text
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text color={theme.text.link} aria-label={SCREEN_READER_USER_PREFIX}>
(r:){' '}
</Text>
) : (
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'>'
)}{' '}
</Text>
);
return (
<>
{attachments.length > 0 && (
@ -1032,142 +1112,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
))}
</Box>
)}
<Box
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
<BaseTextInput
buffer={buffer}
onSubmit={handleSubmitAndClear}
onKeypress={handleInput}
showCursor={showCursor}
placeholder={placeholder}
prefix={prefixNode}
borderColor={borderColor}
>
<Text
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text
color={theme.text.link}
aria-label={SCREEN_READER_USER_PREFIX}
>
(r:){' '}
</Text>
) : (
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'>'
)}{' '}
</Text>
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, visualIdxInRenderedSet) => {
const absoluteVisualIdx =
scrollVisualRow + visualIdxInRenderedSet;
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
const isOnCursorLine =
focus && visualIdxInRenderedSet === cursorVisualRow;
const renderedLine: React.ReactNode[] = [];
const [logicalLineIdx, logicalStartCol] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || '';
const tokens = parseInputForHighlighting(
logicalLine,
logicalLineIdx,
);
const visualStart = logicalStartCol;
const visualEnd = logicalStartCol + cpLen(lineText);
const segments = buildSegmentsForVisualSlice(
tokens,
visualStart,
visualEnd,
);
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
if (isOnCursorLine) {
const relativeVisualColForHighlight = cursorVisualColAbsolute;
const segStart = charCount;
const segEnd = segStart + segLen;
if (
relativeVisualColForHighlight >= segStart &&
relativeVisualColForHighlight < segEnd
) {
const charToHighlight = cpSlice(
seg.text,
relativeVisualColForHighlight - segStart,
relativeVisualColForHighlight - segStart + 1,
);
const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(
seg.text,
0,
relativeVisualColForHighlight - segStart,
) +
highlighted +
cpSlice(
seg.text,
relativeVisualColForHighlight - segStart + 1,
);
}
charCount = segEnd;
}
const color =
seg.type === 'command' || seg.type === 'file'
? theme.text.accent
: theme.text.primary;
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
</Text>,
);
}
return (
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text>{renderedLine}</Text>
</Box>
);
})
)}
</Box>
</Box>
isActive={!isEmbeddedShellFocused}
renderLine={renderLineWithHighlighting}
/>
{shouldShowSuggestions && (
<Box marginLeft={2} marginRight={2}>
<SuggestionsDisplay

View file

@ -72,7 +72,8 @@ describe('<LoadingIndicator />', () => {
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('5s');
expect(output).toContain('esc to cancel');
});
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', () => {
@ -88,7 +89,7 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 10s');
expect(output).not.toContain('10s');
});
it('should display the currentLoadingPhrase correctly', () => {
@ -112,7 +113,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 1m)');
expect(lastFrame()).toContain('(1m · esc to cancel)');
});
it('should display the elapsedTime correctly in human-readable format', () => {
@ -124,7 +125,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
expect(lastFrame()).toContain('(2m 5s · esc to cancel)');
});
it('should render rightContent when provided', () => {
@ -155,7 +156,7 @@ describe('<LoadingIndicator />', () => {
let output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Now Responding');
expect(output).toContain('(esc to cancel, 2s)');
expect(output).toContain('(2s · esc to cancel)');
// Transition to WaitingForConfirmation
rerender(
@ -170,7 +171,7 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('⠏');
expect(output).toContain('Please Confirm');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 15s');
expect(output).not.toContain('15s');
// Transition back to Idle
rerender(
@ -262,7 +263,7 @@ describe('<LoadingIndicator />', () => {
// Check for single line output
expect(output?.includes('\n')).toBe(false);
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('(5s · esc to cancel)');
expect(output).toContain('Right');
});
@ -284,8 +285,8 @@ describe('<LoadingIndicator />', () => {
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Loading...');
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[0]).not.toContain('5s');
expect(lines[1]).toContain('5s');
expect(lines[2]).toContain('Right');
}
});
@ -308,4 +309,70 @@ describe('<LoadingIndicator />', () => {
expect(lastFrame()?.includes('\n')).toBe(true);
});
});
describe('token display', () => {
it('should display output tokens inline with arrow notation', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={847} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('↓ 847 tokens');
expect(output).not.toContain('↑');
expect(output).toContain('5s');
expect(output).toContain('esc to cancel');
});
it('should not display tokens when output tokens is 0', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={0} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
});
it('should not display tokens when props are undefined', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
});
it('should hide tokens in narrow terminal', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={500} />,
StreamingState.Responding,
79,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
expect(output).toContain('esc to cancel');
});
it('should show tokens in wide terminal with inline format', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={5400} />,
StreamingState.Responding,
80,
);
const output = lastFrame();
expect(output).toContain('↓ 5.4k tokens');
});
it('should format tokens inline with time and cancel', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={5400} />,
StreamingState.Responding,
120,
);
const output = lastFrame();
expect(output).toContain('(5s · ↓ 5.4k tokens · esc to cancel)');
});
});
});

View file

@ -11,7 +11,7 @@ import { theme } from '../semantic-colors.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js';
import { formatDuration, formatTokenCount } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { t } from '../../i18n/index.js';
@ -21,6 +21,7 @@ interface LoadingIndicatorProps {
elapsedTime: number;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
candidatesTokens?: number;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
@ -28,6 +29,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
elapsedTime,
rightContent,
thought,
candidatesTokens,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
@ -39,18 +41,26 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const primaryText = thought?.subject || currentLoadingPhrase;
const outputTokens = candidatesTokens ?? 0;
const showTokens = !isNarrow && outputTokens > 0;
const timeStr =
elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000);
const tokenStr = showTokens
? ` · ↓ ${formatTokenCount(outputTokens)} tokens`
: '';
const cancelAndTimerContent =
streamingState !== StreamingState.WaitingForConfirmation
? t('(esc to cancel, {{time}})', {
time:
elapsedTime < 60
? `${elapsedTime}s`
: formatDuration(elapsedTime * 1000),
? t('({{time}}{{tokens}} · esc to cancel)', {
time: timeStr,
tokens: tokenStr,
})
: null;
return (
<Box paddingLeft={0} flexDirection="column">
<Box paddingLeft={2} flexDirection="column">
{/* Main loading line */}
<Box
width="100%"

View file

@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<LoadingIndicator /> > should truncate long primary text instead of wrapping 1`] = `
"MockResponding This is an extremely long loading phrase that should be truncated in t (esc to
Spinner cancel, 5s)"
" MockResponding This is an extremely long loading phrase that should be truncated in (5s · esc to
Spinner cancel)"
`;

View file

@ -0,0 +1,272 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview AgentChatView displays a single in-process agent's conversation.
*
* Renders the agent's message history using HistoryItemDisplay the same
* component used by the main agent view. AgentMessage[] is converted to
* HistoryItem[] by agentMessagesToHistoryItems() so all 27 HistoryItem types
* are available without duplicating rendering logic.
*
* Layout:
* - Static area: finalized messages (efficient Ink <Static>)
* - Live area: tool groups still executing / awaiting confirmation
* - Status line: spinner while the agent is running
*
* Model text output is shown only after each round completes (no live
* streaming), which avoids per-chunk re-renders and keeps the display simple.
*/
import { Box, Text, Static } from 'ink';
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import {
AgentStatus,
AgentEventType,
getGitBranch,
type AgentStatusChangeEvent,
} from '@qwen-code/qwen-code-core';
import {
useAgentViewState,
useAgentViewActions,
} from '../../contexts/AgentViewContext.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { HistoryItemDisplay } from '../HistoryItemDisplay.js';
import { ToolCallStatus } from '../../types.js';
import { theme } from '../../semantic-colors.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
import { AgentHeader } from './AgentHeader.js';
// ─── Main Component ─────────────────────────────────────────
interface AgentChatViewProps {
agentId: string;
}
export const AgentChatView = ({ agentId }: AgentChatViewProps) => {
const { agents } = useAgentViewState();
const { setAgentShellFocused } = useAgentViewActions();
const uiState = useUIState();
const { historyRemountKey, availableTerminalHeight, constrainHeight } =
uiState;
const { columns: terminalWidth } = useTerminalSize();
const agent = agents.get(agentId);
const contentWidth = terminalWidth - 4;
// Force re-render on message updates and status changes.
// STREAM_TEXT is deliberately excluded — model text is shown only after
// each round completes (via committed messages), avoiding per-chunk re-renders.
const [, setRenderTick] = useState(0);
const tickRef = useRef(0);
const forceRender = useCallback(() => {
tickRef.current += 1;
setRenderTick(tickRef.current);
}, []);
useEffect(() => {
if (!agent) return;
const emitter = agent.interactiveAgent.getEventEmitter();
if (!emitter) return;
const onStatusChange = (_event: AgentStatusChangeEvent) => forceRender();
const onToolCall = () => forceRender();
const onToolResult = () => forceRender();
const onRoundEnd = () => forceRender();
const onApproval = () => forceRender();
const onOutputUpdate = () => forceRender();
emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange);
emitter.on(AgentEventType.TOOL_CALL, onToolCall);
emitter.on(AgentEventType.TOOL_RESULT, onToolResult);
emitter.on(AgentEventType.ROUND_END, onRoundEnd);
emitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
emitter.on(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
return () => {
emitter.off(AgentEventType.STATUS_CHANGE, onStatusChange);
emitter.off(AgentEventType.TOOL_CALL, onToolCall);
emitter.off(AgentEventType.TOOL_RESULT, onToolResult);
emitter.off(AgentEventType.ROUND_END, onRoundEnd);
emitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
emitter.off(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
};
}, [agent, forceRender]);
const interactiveAgent = agent?.interactiveAgent;
const messages = interactiveAgent?.getMessages() ?? [];
const pendingApprovals = interactiveAgent?.getPendingApprovals();
const liveOutputs = interactiveAgent?.getLiveOutputs();
const shellPids = interactiveAgent?.getShellPids();
const status = interactiveAgent?.getStatus();
const isRunning =
status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING;
// Derive the active PTY PID: first shell PID among currently-executing tools.
// Resets naturally to undefined when the tool finishes (shellPids cleared).
const activePtyId =
shellPids && shellPids.size > 0
? shellPids.values().next().value
: undefined;
// Track whether the user has toggled input focus into the embedded shell.
// Mirrors the main agent's embeddedShellFocused in AppContainer.
const [embeddedShellFocused, setEmbeddedShellFocusedLocal] = useState(false);
// Sync to AgentViewContext so AgentTabBar can suppress arrow-key navigation
// when an agent's embedded shell is focused.
useEffect(() => {
setAgentShellFocused(embeddedShellFocused);
return () => setAgentShellFocused(false);
}, [embeddedShellFocused, setAgentShellFocused]);
// Reset focus when the shell exits (activePtyId disappears).
useEffect(() => {
if (!activePtyId) setEmbeddedShellFocusedLocal(false);
}, [activePtyId]);
// Ctrl+F: toggle shell input focus when a PTY is active.
useKeypress(
(key) => {
if (key.ctrl && key.name === 'f') {
if (activePtyId || embeddedShellFocused) {
setEmbeddedShellFocusedLocal((prev) => !prev);
}
}
},
{ isActive: true },
);
// Convert AgentMessage[] → HistoryItem[] via adapter.
// tickRef.current in deps ensures we rebuild when events fire even if
// messages.length and pendingApprovals.size haven't changed (e.g. a
// tool result updates an existing entry in place).
const allItems = useMemo(
() =>
agentMessagesToHistoryItems(
messages,
pendingApprovals ?? new Map(),
liveOutputs,
shellPids,
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
agentId,
messages.length,
pendingApprovals?.size,
liveOutputs?.size,
shellPids?.size,
tickRef.current,
],
);
// Split into committed (Static) and pending (live area).
// Any tool_group with an Executing or Confirming tool — plus everything
// after it — stays in the live area so confirmation dialogs remain
// interactive (Ink's <Static> cannot receive input).
const splitIndex = useMemo(() => {
for (let idx = allItems.length - 1; idx >= 0; idx--) {
const item = allItems[idx]!;
if (
item.type === 'tool_group' &&
item.tools.some(
(t) =>
t.status === ToolCallStatus.Executing ||
t.status === ToolCallStatus.Confirming,
)
) {
return idx;
}
}
return allItems.length; // all committed
}, [allItems]);
const committedItems = allItems.slice(0, splitIndex);
const pendingItems = allItems.slice(splitIndex);
const core = interactiveAgent?.getCore();
const agentWorkingDir = core?.runtimeContext.getTargetDir() ?? '';
// Cache the branch — it won't change during the agent's lifetime and
// getGitBranch uses synchronous execSync which blocks the render loop.
const agentGitBranch = useMemo(
() => (agentWorkingDir ? getGitBranch(agentWorkingDir) : ''),
// eslint-disable-next-line react-hooks/exhaustive-deps
[agentId],
);
if (!agent || !interactiveAgent || !core) {
return (
<Box marginX={2}>
<Text color={theme.status.error}>
Agent &quot;{agentId}&quot; not found.
</Text>
</Box>
);
}
const agentModelId = core.modelConfig.model ?? '';
return (
<Box flexDirection="column">
{/* Committed message history.
key includes historyRemountKey: when refreshStatic() clears the
terminal it bumps the key, forcing Static to remount and re-emit
all items on the cleared screen. */}
<Static
key={`agent-${agentId}-${historyRemountKey}`}
items={[
<AgentHeader
key="agent-header"
modelId={agentModelId}
modelName={agent.modelName}
workingDirectory={agentWorkingDir}
gitBranch={agentGitBranch}
/>,
...committedItems.map((item) => (
<HistoryItemDisplay
key={item.id}
item={item}
isPending={false}
terminalWidth={terminalWidth}
mainAreaWidth={contentWidth}
/>
)),
]}
>
{(item) => item}
</Static>
{/* Live area tool groups awaiting confirmation or still executing.
Must remain outside Static so confirmation dialogs are interactive.
Pass PTY state so ShellInputPrompt is reachable via Ctrl+F. */}
{pendingItems.map((item) => (
<HistoryItemDisplay
key={item.id}
item={item}
isPending={true}
terminalWidth={terminalWidth}
mainAreaWidth={contentWidth}
availableTerminalHeight={
constrainHeight ? availableTerminalHeight : undefined
}
isFocused={true}
activeShellPtyId={activePtyId ?? null}
embeddedShellFocused={embeddedShellFocused}
/>
))}
{/* Spinner */}
{isRunning && (
<Box marginX={2} marginTop={1}>
<GeminiRespondingSpinner />
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,308 @@
/**
* @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>
);
};

View file

@ -0,0 +1,66 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Lightweight footer for agent tabs showing approval mode
* and context usage. Mirrors the main Footer layout but without
* main-agent-specific concerns (vim mode, shell mode, exit prompts, etc.).
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { AutoAcceptIndicator } from '../AutoAcceptIndicator.js';
import { ContextUsageDisplay } from '../ContextUsageDisplay.js';
import { theme } from '../../semantic-colors.js';
interface AgentFooterProps {
approvalMode: ApprovalMode | undefined;
promptTokenCount: number;
contextWindowSize: number | undefined;
terminalWidth: number;
}
export const AgentFooter: React.FC<AgentFooterProps> = ({
approvalMode,
promptTokenCount,
contextWindowSize,
terminalWidth,
}) => {
const showApproval =
approvalMode !== undefined && approvalMode !== ApprovalMode.DEFAULT;
const showContext = promptTokenCount > 0 && contextWindowSize !== undefined;
if (!showApproval && !showContext) {
return null;
}
return (
<Box
justifyContent="space-between"
width="100%"
flexDirection="row"
alignItems="center"
>
<Box marginLeft={2}>
{showApproval ? (
<AutoAcceptIndicator approvalMode={approvalMode} />
) : null}
</Box>
<Box marginRight={2}>
{showContext && (
<Text color={theme.text.accent}>
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
terminalWidth={terminalWidth}
contextWindowSize={contextWindowSize!}
/>
</Text>
)}
</Box>
</Box>
);
};

View file

@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Compact header for agent tabs, visually distinct from the
* main view's boxed logo header. Shows model, working directory, and git
* branch in a bordered info panel.
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
interface AgentHeaderProps {
modelId: string;
modelName?: string;
workingDirectory: string;
gitBranch?: string;
}
export const AgentHeader: React.FC<AgentHeaderProps> = ({
modelId,
modelName,
workingDirectory,
gitBranch,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const maxPathLen = Math.max(20, terminalWidth - 12);
const displayPath = shortenPath(tildeifyPath(workingDirectory), maxPathLen);
const modelText =
modelName && modelName !== modelId ? `${modelId} (${modelName})` : modelId;
return (
<Box
flexDirection="column"
marginX={2}
marginTop={1}
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
>
<Text>
<Text color={theme.text.secondary}>{'Model: '}</Text>
<Text color={theme.text.primary}>{modelText}</Text>
</Text>
<Text>
<Text color={theme.text.secondary}>{'Path: '}</Text>
<Text color={theme.text.primary}>{displayPath}</Text>
</Text>
{gitBranch && (
<Text>
<Text color={theme.text.secondary}>{'Branch: '}</Text>
<Text color={theme.text.primary}>{gitBranch}</Text>
</Text>
)}
</Box>
);
};

View file

@ -0,0 +1,167 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview AgentTabBar horizontal tab strip for in-process agent views.
*
* Rendered at the top of the terminal whenever in-process agents are registered.
*
* On the main tab, Left/Right switch tabs when the input buffer is empty.
* On agent tabs, the tab bar uses an exclusive-focus model:
* - Down arrow at the input's bottom edge focuses the tab bar
* - Left/Right switch tabs only when the tab bar is focused
* - Up arrow or typing returns focus to the input
*
* Tab indicators: running, idle/completed, failed, cancelled
*/
import { Box, Text } from 'ink';
import { useState, useEffect, useCallback } from 'react';
import { AgentStatus, AgentEventType } from '@qwen-code/qwen-code-core';
import {
useAgentViewState,
useAgentViewActions,
type RegisteredAgent,
} from '../../contexts/AgentViewContext.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { theme } from '../../semantic-colors.js';
// ─── Status Indicators ──────────────────────────────────────
function statusIndicator(agent: RegisteredAgent): {
symbol: string;
color: string;
} {
const status = agent.interactiveAgent.getStatus();
switch (status) {
case AgentStatus.RUNNING:
case AgentStatus.INITIALIZING:
return { symbol: '\u25CF', color: theme.status.warning }; // ● running
case AgentStatus.IDLE:
return { symbol: '\u25CF', color: theme.status.success }; // ● idle (ready)
case AgentStatus.COMPLETED:
return { symbol: '\u2713', color: theme.status.success }; // ✓ completed
case AgentStatus.FAILED:
return { symbol: '\u2717', color: theme.status.error }; // ✗ failed
case AgentStatus.CANCELLED:
return { symbol: '\u25CB', color: theme.text.secondary }; // ○ cancelled
default:
return { symbol: '\u25CB', color: theme.text.secondary }; // ○ fallback
}
}
// ─── Component ──────────────────────────────────────────────
export const AgentTabBar: React.FC = () => {
const { activeView, agents, agentShellFocused, agentTabBarFocused } =
useAgentViewState();
const { switchToNext, switchToPrevious, setAgentTabBarFocused } =
useAgentViewActions();
const { embeddedShellFocused } = useUIState();
useKeypress(
(key) => {
if (embeddedShellFocused || agentShellFocused) return;
if (!agentTabBarFocused) return;
if (key.name === 'left') {
switchToPrevious();
} else if (key.name === 'right') {
switchToNext();
} else if (key.name === 'up') {
setAgentTabBarFocused(false);
} else if (
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
// Printable character → return focus to input (key falls through
// to BaseTextInput's useKeypress and gets typed normally)
setAgentTabBarFocused(false);
}
},
{ isActive: true },
);
// Subscribe to STATUS_CHANGE events from all agents so the tab bar
// re-renders when an agent's status transitions (e.g. RUNNING → COMPLETED).
// Without this, status indicators would be stale until the next unrelated render.
const [, setTick] = useState(0);
const forceRender = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => {
const cleanups: Array<() => void> = [];
for (const [, agent] of agents) {
const emitter = agent.interactiveAgent.getEventEmitter();
if (emitter) {
emitter.on(AgentEventType.STATUS_CHANGE, forceRender);
cleanups.push(() =>
emitter.off(AgentEventType.STATUS_CHANGE, forceRender),
);
}
}
return () => cleanups.forEach((fn) => fn());
}, [agents, forceRender]);
const isFocused = agentTabBarFocused;
// Navigation hint varies by context
const hint = isFocused ? '\u2190/\u2192 switch \u2191 input' : '\u2193 tabs';
return (
<Box flexDirection="row" paddingX={1}>
{/* Main tab */}
<Box marginRight={1}>
<Text
bold={activeView === 'main'}
dimColor={!isFocused}
backgroundColor={
activeView === 'main' ? theme.border.default : undefined
}
color={
activeView === 'main' ? theme.text.primary : theme.text.secondary
}
>
{' Main '}
</Text>
</Box>
{/* Separator */}
<Text dimColor={!isFocused} color={theme.border.default}>
{'\u2502'}
</Text>
{/* Agent tabs */}
{[...agents.entries()].map(([agentId, agent]) => {
const isActive = activeView === agentId;
const { symbol, color: indicatorColor } = statusIndicator(agent);
return (
<Box key={agentId} marginLeft={1}>
<Text
bold={isActive}
dimColor={!isFocused}
backgroundColor={isActive ? theme.border.default : undefined}
color={isActive ? undefined : agent.color || theme.text.secondary}
>
{` ${agent.modelId} `}
</Text>
<Text dimColor={!isFocused} color={indicatorColor}>
{` ${symbol}`}
</Text>
</Box>
);
})}
{/* Navigation hint */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>{hint}</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,510 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
import type {
AgentMessage,
ToolCallConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import { ToolCallStatus } from '../../types.js';
// ─── Helpers ────────────────────────────────────────────────
function msg(
role: AgentMessage['role'],
content: string,
extra?: Partial<AgentMessage>,
): AgentMessage {
return { role, content, timestamp: 0, ...extra };
}
const noApprovals = new Map<string, ToolCallConfirmationDetails>();
function toolCallMsg(
callId: string,
toolName: string,
opts?: { description?: string; renderOutputAsMarkdown?: boolean },
): AgentMessage {
return msg('tool_call', `Tool call: ${toolName}`, {
metadata: {
callId,
toolName,
description: opts?.description ?? '',
renderOutputAsMarkdown: opts?.renderOutputAsMarkdown,
},
});
}
function toolResultMsg(
callId: string,
toolName: string,
opts?: {
success?: boolean;
resultDisplay?: string;
outputFile?: string;
},
): AgentMessage {
return msg('tool_result', `Tool ${toolName}`, {
metadata: {
callId,
toolName,
success: opts?.success ?? true,
resultDisplay: opts?.resultDisplay,
outputFile: opts?.outputFile,
},
});
}
// ─── Role mapping ────────────────────────────────────────────
describe('agentMessagesToHistoryItems — role mapping', () => {
it('maps user message', () => {
const items = agentMessagesToHistoryItems(
[msg('user', 'hello')],
noApprovals,
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ type: 'user', text: 'hello' });
});
it('maps plain assistant message', () => {
const items = agentMessagesToHistoryItems(
[msg('assistant', 'response')],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'gemini', text: 'response' });
});
it('maps thought assistant message', () => {
const items = agentMessagesToHistoryItems(
[msg('assistant', 'thinking...', { thought: true })],
noApprovals,
);
expect(items[0]).toMatchObject({
type: 'gemini_thought',
text: 'thinking...',
});
});
it('maps assistant message with error metadata', () => {
const items = agentMessagesToHistoryItems(
[msg('assistant', 'oops', { metadata: { error: true } })],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'error', text: 'oops' });
});
it('maps info message with no level → type info', () => {
const items = agentMessagesToHistoryItems(
[msg('info', 'note')],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'info', text: 'note' });
});
it.each([
['warning', 'warning'],
['success', 'success'],
['error', 'error'],
] as const)('maps info message with level=%s', (level, expectedType) => {
const items = agentMessagesToHistoryItems(
[msg('info', 'text', { metadata: { level } })],
noApprovals,
);
expect(items[0]).toMatchObject({ type: expectedType });
});
it('maps unknown info level → type info', () => {
const items = agentMessagesToHistoryItems(
[msg('info', 'x', { metadata: { level: 'verbose' } })],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'info' });
});
it('skips unknown roles without crashing', () => {
const items = agentMessagesToHistoryItems(
[
msg('user', 'before'),
// force an unknown role
{ role: 'unknown' as AgentMessage['role'], content: 'x', timestamp: 0 },
msg('user', 'after'),
],
noApprovals,
);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ type: 'user', text: 'before' });
expect(items[1]).toMatchObject({ type: 'user', text: 'after' });
});
});
// ─── Tool grouping ───────────────────────────────────────────
describe('agentMessagesToHistoryItems — tool grouping', () => {
it('merges a tool_call + tool_result pair into one tool_group', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'read_file'), toolResultMsg('c1', 'read_file')],
noApprovals,
);
expect(items).toHaveLength(1);
expect(items[0]!.type).toBe('tool_group');
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools).toHaveLength(1);
expect(group.tools[0]!.name).toBe('read_file');
});
it('merges multiple parallel tool calls into one tool_group', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'read_file'),
toolCallMsg('c2', 'write_file'),
toolResultMsg('c1', 'read_file'),
toolResultMsg('c2', 'write_file'),
],
noApprovals,
);
expect(items).toHaveLength(1);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools).toHaveLength(2);
expect(group.tools[0]!.name).toBe('read_file');
expect(group.tools[1]!.name).toBe('write_file');
});
it('preserves tool call order by first appearance', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c2', 'second'),
toolCallMsg('c1', 'first'),
toolResultMsg('c1', 'first'),
toolResultMsg('c2', 'second'),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.name).toBe('second');
expect(group.tools[1]!.name).toBe('first');
});
it('breaks tool groups at non-tool messages', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'tool_a'),
toolResultMsg('c1', 'tool_a'),
msg('assistant', 'between'),
toolCallMsg('c2', 'tool_b'),
toolResultMsg('c2', 'tool_b'),
],
noApprovals,
);
expect(items).toHaveLength(3);
expect(items[0]!.type).toBe('tool_group');
expect(items[1]!.type).toBe('gemini');
expect(items[2]!.type).toBe('tool_group');
});
it('handles tool_result arriving without a prior tool_call gracefully', () => {
const items = agentMessagesToHistoryItems(
[
toolResultMsg('c1', 'orphan', {
success: true,
resultDisplay: 'output',
}),
],
noApprovals,
);
expect(items).toHaveLength(1);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.callId).toBe('c1');
expect(group.tools[0]!.status).toBe(ToolCallStatus.Success);
});
});
// ─── Tool status ─────────────────────────────────────────────
describe('agentMessagesToHistoryItems — tool status', () => {
it('Executing: tool_call with no result yet', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Executing);
});
it('Success: tool_result with success=true', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'read'),
toolResultMsg('c1', 'read', { success: true }),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Success);
});
it('Error: tool_result with success=false', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'write'),
toolResultMsg('c1', 'write', { success: false }),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Error);
});
it('Confirming: tool_call present in pendingApprovals', () => {
const fakeApproval = {} as ToolCallConfirmationDetails;
const approvals = new Map([['c1', fakeApproval]]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
approvals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming);
expect(group.tools[0]!.confirmationDetails).toBe(fakeApproval);
});
it('Confirming takes priority over Executing', () => {
// pending approval AND no result yet → Confirming, not Executing
const approvals = new Map([['c1', {} as ToolCallConfirmationDetails]]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
approvals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming);
});
});
// ─── Tool metadata ───────────────────────────────────────────
describe('agentMessagesToHistoryItems — tool metadata', () => {
it('forwards resultDisplay from tool_result', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'read'),
toolResultMsg('c1', 'read', {
success: true,
resultDisplay: 'file contents',
}),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBe('file contents');
});
it('forwards renderOutputAsMarkdown from tool_call', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'web_fetch', { renderOutputAsMarkdown: true }),
toolResultMsg('c1', 'web_fetch', { success: true }),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.renderOutputAsMarkdown).toBe(true);
});
it('forwards description from tool_call', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'read', { description: 'reading src/index.ts' })],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.description).toBe('reading src/index.ts');
});
});
// ─── liveOutputs overlay ─────────────────────────────────────
describe('agentMessagesToHistoryItems — liveOutputs', () => {
it('uses liveOutput as resultDisplay for Executing tools', () => {
const liveOutputs = new Map([['c1', 'live stdout so far']]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
liveOutputs,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBe('live stdout so far');
});
it('ignores liveOutput for completed tools', () => {
const liveOutputs = new Map([['c1', 'stale live output']]);
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'shell'),
toolResultMsg('c1', 'shell', {
success: true,
resultDisplay: 'final output',
}),
],
noApprovals,
liveOutputs,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBe('final output');
});
it('falls back to entry resultDisplay when no liveOutput for callId', () => {
const liveOutputs = new Map([['other-id', 'unrelated']]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
liveOutputs,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBeUndefined();
});
});
// ─── shellPids overlay ───────────────────────────────────────
describe('agentMessagesToHistoryItems — shellPids', () => {
it('sets ptyId for Executing tools with a known PID', () => {
const shellPids = new Map([['c1', 12345]]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
undefined,
shellPids,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.ptyId).toBe(12345);
});
it('does not set ptyId for completed tools', () => {
const shellPids = new Map([['c1', 12345]]);
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'shell'),
toolResultMsg('c1', 'shell', { success: true }),
],
noApprovals,
undefined,
shellPids,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.ptyId).toBeUndefined();
});
it('does not set ptyId when shellPids is not provided', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.ptyId).toBeUndefined();
});
});
// ─── ID stability ────────────────────────────────────────────
describe('agentMessagesToHistoryItems — ID stability', () => {
it('assigns monotonically increasing IDs', () => {
const items = agentMessagesToHistoryItems(
[
msg('user', 'u1'),
msg('assistant', 'a1'),
msg('info', 'i1'),
toolCallMsg('c1', 'tool'),
toolResultMsg('c1', 'tool'),
],
noApprovals,
);
const ids = items.map((i) => i.id);
expect(ids).toEqual([0, 1, 2, 3]);
});
it('tool_group consumes one ID regardless of how many calls it contains', () => {
const items = agentMessagesToHistoryItems(
[
msg('user', 'go'),
toolCallMsg('c1', 'tool_a'),
toolCallMsg('c2', 'tool_b'),
toolResultMsg('c1', 'tool_a'),
toolResultMsg('c2', 'tool_b'),
msg('assistant', 'done'),
],
noApprovals,
);
// user=0, tool_group=1, assistant=2
expect(items.map((i) => i.id)).toEqual([0, 1, 2]);
});
it('IDs from a prefix of messages are stable when more messages are appended', () => {
const base: AgentMessage[] = [msg('user', 'u'), msg('assistant', 'a')];
const before = agentMessagesToHistoryItems(base, noApprovals);
const after = agentMessagesToHistoryItems(
[...base, msg('info', 'i')],
noApprovals,
);
expect(after[0]!.id).toBe(before[0]!.id);
expect(after[1]!.id).toBe(before[1]!.id);
expect(after[2]!.id).toBe(2);
});
});

View file

@ -0,0 +1,194 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview agentHistoryAdapter converts AgentMessage[] to HistoryItem[].
*
* This adapter bridges the sub-agent data model (AgentMessage[] from
* AgentInteractive) to the shared rendering model (HistoryItem[] consumed by
* HistoryItemDisplay). It lives in the CLI package so that packages/core types
* are never coupled to CLI rendering types.
*
* ID stability: AgentMessage[] is append-only, so the resulting HistoryItem[]
* only ever grows. Index-based IDs are therefore stable Ink's <Static>
* requires items never shift or be removed, which this guarantees.
*/
import type {
AgentMessage,
ToolCallConfirmationDetails,
ToolResultDisplay,
} from '@qwen-code/qwen-code-core';
import type { HistoryItem, IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
/**
* Convert AgentMessage[] + pendingApprovals into HistoryItem[].
*
* Consecutive tool_call / tool_result messages are merged into a single
* tool_group HistoryItem. pendingApprovals overlays confirmation state so
* ToolGroupMessage can render confirmation dialogs.
*
* liveOutputs (optional) provides real-time display data for executing tools.
* shellPids (optional) provides PTY PIDs for interactive shell tools so
* HistoryItemDisplay can render ShellInputPrompt on the active shell.
*/
export function agentMessagesToHistoryItems(
messages: readonly AgentMessage[],
pendingApprovals: ReadonlyMap<string, ToolCallConfirmationDetails>,
liveOutputs?: ReadonlyMap<string, ToolResultDisplay>,
shellPids?: ReadonlyMap<string, number>,
): HistoryItem[] {
const items: HistoryItem[] = [];
let nextId = 0;
let i = 0;
while (i < messages.length) {
const msg = messages[i]!;
// ── user ──────────────────────────────────────────────────
if (msg.role === 'user') {
items.push({ type: 'user', text: msg.content, id: nextId++ });
i++;
// ── assistant ─────────────────────────────────────────────
} else if (msg.role === 'assistant') {
if (msg.metadata?.['error']) {
items.push({ type: 'error', text: msg.content, id: nextId++ });
} else if (msg.thought) {
items.push({ type: 'gemini_thought', text: msg.content, id: nextId++ });
} else {
items.push({ type: 'gemini', text: msg.content, id: nextId++ });
}
i++;
// ── info / warning / success / error ──────────────────────
} else if (msg.role === 'info') {
const level = msg.metadata?.['level'] as string | undefined;
const type =
level === 'warning' || level === 'success' || level === 'error'
? level
: 'info';
items.push({ type, text: msg.content, id: nextId++ });
i++;
// ── tool_call / tool_result → tool_group ──────────────────
} else if (msg.role === 'tool_call' || msg.role === 'tool_result') {
const groupId = nextId++;
const callMap = new Map<
string,
{
callId: string;
name: string;
description: string;
resultDisplay: ToolResultDisplay | string | undefined;
outputFile: string | undefined;
renderOutputAsMarkdown: boolean | undefined;
success: boolean | undefined;
}
>();
const callOrder: string[] = [];
while (
i < messages.length &&
(messages[i]!.role === 'tool_call' ||
messages[i]!.role === 'tool_result')
) {
const m = messages[i]!;
const callId = (m.metadata?.['callId'] as string) ?? `unknown-${i}`;
if (m.role === 'tool_call') {
if (!callMap.has(callId)) callOrder.push(callId);
callMap.set(callId, {
callId,
name: (m.metadata?.['toolName'] as string) ?? 'unknown',
description: (m.metadata?.['description'] as string) ?? '',
resultDisplay: undefined,
outputFile: undefined,
renderOutputAsMarkdown: m.metadata?.['renderOutputAsMarkdown'] as
| boolean
| undefined,
success: undefined,
});
} else {
// tool_result — attach to existing call entry
const entry = callMap.get(callId);
const resultDisplay = m.metadata?.['resultDisplay'] as
| ToolResultDisplay
| string
| undefined;
const outputFile = m.metadata?.['outputFile'] as string | undefined;
const success = m.metadata?.['success'] as boolean;
if (entry) {
entry.success = success;
entry.resultDisplay = resultDisplay;
entry.outputFile = outputFile;
} else {
// Result arrived without a prior tool_call message (shouldn't
// normally happen, but handle gracefully)
callOrder.push(callId);
callMap.set(callId, {
callId,
name: (m.metadata?.['toolName'] as string) ?? 'unknown',
description: '',
resultDisplay,
outputFile,
renderOutputAsMarkdown: undefined,
success,
});
}
}
i++;
}
const tools: IndividualToolCallDisplay[] = callOrder.map((callId) => {
const entry = callMap.get(callId)!;
const approval = pendingApprovals.get(callId);
let status: ToolCallStatus;
if (approval) {
status = ToolCallStatus.Confirming;
} else if (entry.success === undefined) {
status = ToolCallStatus.Executing;
} else if (entry.success) {
status = ToolCallStatus.Success;
} else {
status = ToolCallStatus.Error;
}
// For executing tools, use live output if available (Gap 4)
const resultDisplay =
status === ToolCallStatus.Executing && liveOutputs?.has(callId)
? liveOutputs.get(callId)
: entry.resultDisplay;
return {
callId: entry.callId,
name: entry.name,
description: entry.description,
resultDisplay,
outputFile: entry.outputFile,
renderOutputAsMarkdown: entry.renderOutputAsMarkdown,
status,
confirmationDetails: approval,
ptyId:
status === ToolCallStatus.Executing
? shellPids?.get(callId)
: undefined,
};
});
items.push({ type: 'tool_group', tools, id: groupId });
} else {
// Skip unknown roles
i++;
}
}
return items;
}

View file

@ -0,0 +1,12 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export { AgentTabBar } from './AgentTabBar.js';
export { AgentChatView } from './AgentChatView.js';
export { AgentHeader } from './AgentHeader.js';
export { AgentComposer } from './AgentComposer.js';
export { AgentFooter } from './AgentFooter.js';
export { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';

View file

@ -0,0 +1,290 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { formatDuration } from '../../utils/formatters.js';
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
import type { ArenaAgentCardData } from '../../types.js';
// ─── Helpers ────────────────────────────────────────────────
// ─── Agent Complete Card ────────────────────────────────────
interface ArenaAgentCardProps {
agent: ArenaAgentCardData;
width?: number;
}
export const ArenaAgentCard: React.FC<ArenaAgentCardProps> = ({
agent,
width,
}) => {
const { icon, text, color } = getArenaStatusLabel(agent.status);
const duration = formatDuration(agent.durationMs);
const tokens = agent.totalTokens.toLocaleString();
const inTokens = agent.inputTokens.toLocaleString();
const outTokens = agent.outputTokens.toLocaleString();
return (
<Box flexDirection="column" width={width}>
{/* Line 1: Status icon + text + label + duration */}
<Box>
<Text color={color}>
{icon} {agent.label} · {text} · {duration}
</Text>
</Box>
{/* Line 2: Tokens */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
Tokens: {tokens} (in {inTokens}, out {outTokens})
</Text>
</Box>
{/* Line 3: Tool Calls with colored success/error counts */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
Tool Calls: {agent.toolCalls}
{agent.failedToolCalls > 0 && (
<>
{' '}
(
<Text color={theme.status.success}>
{agent.successfulToolCalls}
</Text>
<Text color={theme.text.secondary}> </Text>
<Text color={theme.status.error}> {agent.failedToolCalls}</Text>)
</>
)}
</Text>
</Box>
{/* Error line (if terminated with error) */}
{agent.error && (
<Box marginLeft={2}>
<Text color={theme.status.error}>{agent.error}</Text>
</Box>
)}
</Box>
);
};
// ─── Session Complete Card ──────────────────────────────────
interface ArenaSessionCardProps {
sessionStatus: string;
task: string;
totalDurationMs: number;
agents: ArenaAgentCardData[];
width?: number;
}
/**
* Pad or truncate a string to a fixed visual width.
*/
function pad(
str: string,
len: number,
align: 'left' | 'right' = 'left',
): string {
if (str.length >= len) return str.slice(0, len);
const padding = ' '.repeat(len - str.length);
return align === 'right' ? padding + str : str + padding;
}
/**
* Truncate a string to a maximum length, adding ellipsis if truncated.
*/
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 1) + '…';
}
/**
* Calculate diff stats from a unified diff string.
* Returns the stats string and individual counts for colored rendering.
*/
function getDiffStats(diff: string | undefined): {
text: string;
additions: number;
deletions: number;
} {
if (!diff) return { text: '', additions: 0, deletions: 0 };
const lines = diff.split('\n');
let additions = 0;
let deletions = 0;
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
additions++;
} else if (line.startsWith('-') && !line.startsWith('---')) {
deletions++;
}
}
return { text: `+${additions}/-${deletions}`, additions, deletions };
}
const MAX_MODEL_NAME_LENGTH = 35;
export const ArenaSessionCard: React.FC<ArenaSessionCardProps> = ({
sessionStatus,
task,
agents,
width,
}) => {
// Truncate task for display
const maxTaskLen = 60;
const displayTask =
task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task;
// Column widths for the agent table (unified with Arena Results)
const colStatus = 14;
const colTime = 8;
const colTokens = 10;
const colChanges = 10;
const titleLabel =
sessionStatus === 'idle'
? 'Agents Status · Idle'
: sessionStatus === 'completed'
? 'Arena Complete'
: sessionStatus === 'cancelled'
? 'Arena Cancelled'
: 'Arena Failed';
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingX={2}
paddingY={1}
width={width}
>
{/* Title - neutral color (not green) */}
<Box>
<Text bold color={theme.text.primary}>
{titleLabel}
</Text>
</Box>
<Box height={1} />
{/* Task */}
<Box>
<Text>
<Text color={theme.text.secondary}>Task: </Text>
<Text color={theme.text.primary}>&quot;{displayTask}&quot;</Text>
</Text>
</Box>
<Box height={1} />
{/* Table header - unified columns: Agent, Status, Time, Tokens, Changes */}
<Box>
<Box flexGrow={1}>
<Text bold color={theme.text.secondary}>
Agent
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Status
</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Time
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Tokens
</Text>
</Box>
<Box width={colChanges} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Changes
</Text>
</Box>
</Box>
{/* Table separator */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat((width ?? 60) - 8)}
</Text>
</Box>
{/* Agent rows */}
{agents.map((agent) => {
const { text: statusText, color } = getArenaStatusLabel(agent.status);
const diffStats = getDiffStats(agent.diff);
return (
<Box key={agent.label}>
<Box flexGrow={1}>
<Text color={theme.text.primary}>
{truncate(agent.label, MAX_MODEL_NAME_LENGTH)}
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text color={color}>{statusText}</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(formatDuration(agent.durationMs), colTime - 1, 'right')}
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(
agent.totalTokens.toLocaleString(),
colTokens - 1,
'right',
)}
</Text>
</Box>
<Box width={colChanges} justifyContent="flex-end">
{diffStats.additions > 0 || diffStats.deletions > 0 ? (
<Text>
<Text color={theme.status.success}>
+{diffStats.additions}
</Text>
<Text color={theme.text.secondary}>/</Text>
<Text color={theme.status.error}>-{diffStats.deletions}</Text>
</Text>
) : (
<Text color={theme.text.secondary}>-</Text>
)}
</Box>
</Box>
);
})}
<Box height={1} />
{/* Hint */}
{sessionStatus === 'idle' && (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
Switch to an agent tab to continue, or{' '}
<Text color={theme.text.accent}>/arena select</Text> to pick a
winner.
</Text>
</Box>
)}
{sessionStatus === 'completed' && (
<Box>
<Text color={theme.text.secondary}>
Run <Text color={theme.text.accent}>/arena select</Text> to pick a
winner.
</Text>
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,260 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import {
type ArenaManager,
isSuccessStatus,
type Config,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { MessageType, type HistoryItemWithoutId } from '../../types.js';
import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
import { formatDuration } from '../../utils/formatters.js';
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js';
import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js';
interface ArenaSelectDialogProps {
manager: ArenaManager;
config: Config;
addItem: UseHistoryManagerReturn['addItem'];
closeArenaDialog: () => void;
}
export function ArenaSelectDialog({
manager,
config,
addItem,
closeArenaDialog,
}: ArenaSelectDialogProps): React.JSX.Element {
const pushMessage = useCallback(
(result: { messageType: 'info' | 'error'; content: string }) => {
const item: HistoryItemWithoutId = {
type:
result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR,
text: result.content,
};
addItem(item, Date.now());
try {
const chatRecorder = config.getChatRecordingService();
chatRecorder?.recordSlashCommand({
phase: 'result',
rawCommand: '/arena select',
outputHistoryItems: [{ ...item } as Record<string, unknown>],
});
} catch {
// Best-effort recording
}
},
[addItem, config],
);
const onSelect = useCallback(
async (agentId: string) => {
closeArenaDialog();
const mgr = config.getArenaManager();
if (!mgr) {
pushMessage({
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
});
return;
}
const agent =
mgr.getAgentState(agentId) ??
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({
messageType: 'error',
content: `Failed to apply changes from ${label}: ${result.error}`,
});
return;
}
try {
await config.cleanupArenaRuntime(true);
} catch (err) {
pushMessage({
messageType: 'error',
content: `Warning: failed to clean up arena resources: ${err instanceof Error ? err.message : String(err)}`,
});
}
pushMessage({
messageType: 'info',
content: `Applied changes from ${label} to workspace. Arena session complete.`,
});
},
[closeArenaDialog, config, pushMessage],
);
const onDiscard = useCallback(async () => {
closeArenaDialog();
const mgr = config.getArenaManager();
if (!mgr) {
pushMessage({
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
});
return;
}
try {
pushMessage({
messageType: 'info',
content: 'Discarding Arena results and cleaning up…',
});
await config.cleanupArenaRuntime(true);
pushMessage({
messageType: 'info',
content: 'Arena results discarded. All worktrees cleaned up.',
});
} catch (err) {
pushMessage({
messageType: 'error',
content: `Failed to clean up arena worktrees: ${err instanceof Error ? err.message : String(err)}`,
});
}
}, [closeArenaDialog, config, pushMessage]);
const result = manager.getResult();
const agents = manager.getAgentStates();
const items: Array<DescriptiveRadioSelectItem<string>> = useMemo(
() =>
agents.map((agent) => {
const label = agent.model.modelId;
const statusInfo = getArenaStatusLabel(agent.status);
const duration = formatDuration(agent.stats.durationMs);
const tokens = agent.stats.totalTokens.toLocaleString();
// Build diff summary from cached result if available
let diffAdditions = 0;
let diffDeletions = 0;
if (isSuccessStatus(agent.status) && result) {
const agentResult = result.agents.find(
(a) => a.agentId === agent.agentId,
);
if (agentResult?.diff) {
const lines = agentResult.diff.split('\n');
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
diffAdditions++;
} else if (line.startsWith('-') && !line.startsWith('---')) {
diffDeletions++;
}
}
}
}
// Title: full model name (not truncated)
const title = <Text>{label}</Text>;
// Description: status, time, tokens, changes (unified with Arena Complete columns)
const description = (
<Text>
<Text color={statusInfo.color}>{statusInfo.text}</Text>
<Text color={theme.text.secondary}> · </Text>
<Text color={theme.text.secondary}>{duration}</Text>
<Text color={theme.text.secondary}> · </Text>
<Text color={theme.text.secondary}>{tokens} tokens</Text>
{(diffAdditions > 0 || diffDeletions > 0) && (
<>
<Text color={theme.text.secondary}> · </Text>
<Text color={theme.status.success}>+{diffAdditions}</Text>
<Text color={theme.text.secondary}>/</Text>
<Text color={theme.status.error}>-{diffDeletions}</Text>
<Text color={theme.text.secondary}> lines</Text>
</>
)}
</Text>
);
return {
key: agent.agentId,
value: agent.agentId,
title,
description,
disabled: !isSuccessStatus(agent.status),
};
}),
[agents, result],
);
useKeypress(
(key) => {
if (key.name === 'escape') {
closeArenaDialog();
}
if (key.name === 'd' && !key.ctrl && !key.meta) {
onDiscard();
}
},
{ isActive: true },
);
const task = result?.task || '';
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
{/* Neutral title color (not green) */}
<Text bold color={theme.text.primary}>
Arena Results
</Text>
<Box marginTop={1} flexDirection="column">
<Text>
<Text color={theme.text.secondary}>Task: </Text>
<Text
color={theme.text.primary}
>{`"${task.length > 60 ? task.slice(0, 59) + '…' : task}"`}</Text>
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Select a winner to apply changes:
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<DescriptiveRadioButtonSelect
items={items}
initialIndex={items.findIndex((item) => !item.disabled)}
onSelect={(agentId: string) => {
onSelect(agentId);
}}
isFocused={true}
showNumbers={false}
/>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Enter to select, d to discard all, Esc to cancel
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,161 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import Link from 'ink-link';
import { AuthType } from '@qwen-code/qwen-code-core';
import { useConfig } from '../../contexts/ConfigContext.js';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { MultiSelect } from '../shared/MultiSelect.js';
import { t } from '../../../i18n/index.js';
interface ArenaStartDialogProps {
onClose: () => void;
onConfirm: (selectedModels: string[]) => void;
}
const MODEL_PROVIDERS_DOCUMENTATION_URL =
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders';
export function ArenaStartDialog({
onClose,
onConfirm,
}: ArenaStartDialogProps): React.JSX.Element {
const config = useConfig();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const modelItems = useMemo(() => {
const allModels = config.getAllConfiguredModels();
const selectableModels = allModels.filter((model) => !model.isRuntimeModel);
return selectableModels.map((model) => {
const token = `${model.authType}:${model.id}`;
const isQwenOauth = model.authType === AuthType.QWEN_OAUTH;
return {
key: token,
value: token,
label: `[${model.authType}] ${model.label}`,
disabled: isQwenOauth,
};
});
}, [config]);
const hasDisabledQwenOauth = modelItems.some((item) => item.disabled);
const selectableModelCount = modelItems.filter(
(item) => !item.disabled,
).length;
const needsMoreModels = selectableModelCount < 2;
const shouldShowMoreModelsHint =
selectableModelCount >= 2 && selectableModelCount < 3;
useKeypress(
(key) => {
if (key.name === 'escape') {
onClose();
}
},
{ isActive: true },
);
const handleConfirm = (values: string[]) => {
if (values.length < 2) {
setErrorMessage(
t('Please select at least 2 models to start an Arena session.'),
);
return;
}
setErrorMessage(null);
onConfirm(values);
};
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{t('Select Models')}</Text>
{modelItems.length === 0 ? (
<Box marginTop={1} flexDirection="column">
<Text color={theme.status.warning}>
{t('No models available. Please configure models first.')}
</Text>
</Box>
) : (
<Box marginTop={1}>
<MultiSelect
items={modelItems}
initialIndex={0}
onConfirm={handleConfirm}
showNumbers
showScrollArrows
maxItemsToShow={10}
/>
</Box>
)}
{errorMessage && (
<Box marginTop={1}>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
)}
{(hasDisabledQwenOauth || needsMoreModels) && (
<Box marginTop={1} flexDirection="column">
{hasDisabledQwenOauth && (
<Text color={theme.status.warning}>
{t('Note: qwen-oauth models are not supported in Arena.')}
</Text>
)}
{needsMoreModels && (
<>
<Text color={theme.status.warning}>
{t('Arena requires at least 2 models. To add more:')}
</Text>
<Text color={theme.status.warning}>
{t(
' - Run /auth to set up a Coding Plan (includes multiple models)',
)}
</Text>
<Text color={theme.status.warning}>
{t(' - Or configure modelProviders in settings.json')}
</Text>
</>
)}
</Box>
)}
{shouldShowMoreModelsHint && (
<>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Configure more models with the modelProviders guide:')}
</Text>
</Box>
<Box marginTop={0}>
<Link url={MODEL_PROVIDERS_DOCUMENTATION_URL} fallback={false}>
<Text color={theme.text.secondary} underline>
{MODEL_PROVIDERS_DOCUMENTATION_URL}
</Text>
</Link>
</Box>
</>
)}
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
{t('Space to toggle, Enter to confirm, Esc to cancel')}
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,288 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
type ArenaManager,
type ArenaAgentState,
type InProcessBackend,
type AgentStatsSummary,
isSettledStatus,
ArenaSessionStatus,
DISPLAY_MODE,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { formatDuration } from '../../utils/formatters.js';
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
const STATUS_REFRESH_INTERVAL_MS = 2000;
const IN_PROCESS_REFRESH_INTERVAL_MS = 1000;
interface ArenaStatusDialogProps {
manager: ArenaManager;
closeArenaDialog: () => void;
width?: number;
}
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 1) + '…';
}
function pad(
str: string,
len: number,
align: 'left' | 'right' = 'left',
): string {
if (str.length >= len) return str.slice(0, len);
const padding = ' '.repeat(len - str.length);
return align === 'right' ? padding + str : str + padding;
}
function getElapsedMs(agent: ArenaAgentState): number {
if (isSettledStatus(agent.status)) {
return agent.stats.durationMs;
}
return Date.now() - agent.startedAt;
}
function getSessionStatusLabel(status: ArenaSessionStatus): {
text: string;
color: string;
} {
switch (status) {
case ArenaSessionStatus.RUNNING:
return { text: 'Running', color: theme.status.success };
case ArenaSessionStatus.INITIALIZING:
return { text: 'Initializing', color: theme.status.warning };
case ArenaSessionStatus.IDLE:
return { text: 'Idle', color: theme.status.success };
case ArenaSessionStatus.COMPLETED:
return { text: 'Completed', color: theme.status.success };
case ArenaSessionStatus.CANCELLED:
return { text: 'Cancelled', color: theme.status.warning };
case ArenaSessionStatus.FAILED:
return { text: 'Failed', color: theme.status.error };
default:
return { text: String(status), color: theme.text.secondary };
}
}
const MAX_MODEL_NAME_LENGTH = 35;
export function ArenaStatusDialog({
manager,
closeArenaDialog,
width,
}: ArenaStatusDialogProps): React.JSX.Element {
const [tick, setTick] = useState(0);
// Detect in-process backend for live stats reading
const backend = manager.getBackend();
const isInProcess = backend?.type === DISPLAY_MODE.IN_PROCESS;
const inProcessBackend = isInProcess ? (backend as InProcessBackend) : null;
useEffect(() => {
const interval = isInProcess
? IN_PROCESS_REFRESH_INTERVAL_MS
: STATUS_REFRESH_INTERVAL_MS;
const timer = setInterval(() => {
setTick((prev) => prev + 1);
}, interval);
return () => clearInterval(timer);
}, [isInProcess]);
// Force re-read on every tick
void tick;
const sessionStatus = manager.getSessionStatus();
const sessionLabel = getSessionStatusLabel(sessionStatus);
const agents = manager.getAgentStates();
const task = manager.getTask() ?? '';
// For in-process mode, read live stats directly from AgentInteractive
const liveStats = useMemo(() => {
if (!inProcessBackend) return null;
const statsMap = new Map<string, AgentStatsSummary>();
for (const agent of agents) {
const interactive = inProcessBackend.getAgent(agent.agentId);
if (interactive) {
statsMap.set(agent.agentId, interactive.getStats());
}
}
return statsMap;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inProcessBackend, agents, tick]);
const maxTaskLen = 60;
const displayTask =
task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task;
const colStatus = 14;
const colTime = 8;
const colTokens = 10;
const colRounds = 8;
const colTools = 8;
useKeypress(
(key) => {
if (key.name === 'escape' || key.name === 'q' || key.name === 'return') {
closeArenaDialog();
}
},
{ isActive: true },
);
// Inner content width: total width minus border (2) and paddingX (2*2)
const innerWidth = (width ?? 80) - 6;
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingX={2}
paddingY={1}
width="100%"
>
{/* Title */}
<Box>
<Text bold color={theme.text.primary}>
Arena Status
</Text>
<Text color={theme.text.secondary}> · </Text>
<Text color={sessionLabel.color}>{sessionLabel.text}</Text>
</Box>
<Box height={1} />
{/* Task */}
<Box>
<Text>
<Text color={theme.text.secondary}>Task: </Text>
<Text color={theme.text.primary}>&quot;{displayTask}&quot;</Text>
</Text>
</Box>
<Box height={1} />
{/* Table header */}
<Box>
<Box flexGrow={1}>
<Text bold color={theme.text.secondary}>
Agent
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Status
</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Time
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Tokens
</Text>
</Box>
<Box width={colRounds} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Rounds
</Text>
</Box>
<Box width={colTools} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Tools
</Text>
</Box>
</Box>
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(innerWidth)}</Text>
</Box>
{/* Agent rows */}
{agents.map((agent) => {
const label = agent.model.modelId;
const { text: statusText, color } = getArenaStatusLabel(agent.status);
const elapsed = getElapsedMs(agent);
// Use live stats from AgentInteractive when in-process, otherwise
// fall back to the cached ArenaAgentState.stats (file-polled).
const live = liveStats?.get(agent.agentId);
const totalTokens = live?.totalTokens ?? agent.stats.totalTokens;
const rounds = live?.rounds ?? agent.stats.rounds;
const toolCalls = live?.totalToolCalls ?? agent.stats.toolCalls;
const successfulToolCalls =
live?.successfulToolCalls ?? agent.stats.successfulToolCalls;
const failedToolCalls =
live?.failedToolCalls ?? agent.stats.failedToolCalls;
return (
<Box key={agent.agentId} flexDirection="column">
<Box>
<Box flexGrow={1}>
<Text color={theme.text.primary}>
{truncate(label, MAX_MODEL_NAME_LENGTH)}
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text color={color}>{statusText}</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(formatDuration(elapsed), colTime - 1, 'right')}
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(totalTokens.toLocaleString(), colTokens - 1, 'right')}
</Text>
</Box>
<Box width={colRounds} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(String(rounds), colRounds - 1, 'right')}
</Text>
</Box>
<Box width={colTools} justifyContent="flex-end">
{failedToolCalls > 0 ? (
<Text>
<Text color={theme.status.success}>
{successfulToolCalls}
</Text>
<Text color={theme.text.secondary}>/</Text>
<Text color={theme.status.error}>{failedToolCalls}</Text>
</Text>
) : (
<Text
color={
toolCalls > 0 ? theme.status.success : theme.text.primary
}
>
{pad(String(toolCalls), colTools - 1, 'right')}
</Text>
)}
</Box>
</Box>
</Box>
);
})}
{agents.length === 0 && (
<Box>
<Text color={theme.text.secondary}>No agents registered yet.</Text>
</Box>
)}
</Box>
);
}

View file

@ -0,0 +1,213 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
ArenaSessionStatus,
createDebugLogger,
type Config,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { MessageType, type HistoryItemWithoutId } from '../../types.js';
import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js';
import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js';
const debugLogger = createDebugLogger('ARENA_STOP_DIALOG');
type StopAction = 'cleanup' | 'preserve';
interface ArenaStopDialogProps {
config: Config;
addItem: UseHistoryManagerReturn['addItem'];
closeArenaDialog: () => void;
}
export function ArenaStopDialog({
config,
addItem,
closeArenaDialog,
}: ArenaStopDialogProps): React.JSX.Element {
const [isProcessing, setIsProcessing] = useState(false);
const pushMessage = useCallback(
(result: { messageType: 'info' | 'error'; content: string }) => {
const item: HistoryItemWithoutId = {
type:
result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR,
text: result.content,
};
addItem(item, Date.now());
try {
const chatRecorder = config.getChatRecordingService();
chatRecorder?.recordSlashCommand({
phase: 'result',
rawCommand: '/arena stop',
outputHistoryItems: [{ ...item } as Record<string, unknown>],
});
} catch {
// Best-effort recording
}
},
[addItem, config],
);
const onStop = useCallback(
async (action: StopAction) => {
if (isProcessing) return;
setIsProcessing(true);
closeArenaDialog();
const mgr = config.getArenaManager();
if (!mgr) {
pushMessage({
messageType: 'error',
content: 'No running Arena session found.',
});
return;
}
try {
const sessionStatus = mgr.getSessionStatus();
if (
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();
} else {
await mgr.cleanup();
}
config.setArenaManager(null);
if (action === 'preserve') {
pushMessage({
messageType: 'info',
content:
'Arena session stopped. Worktrees and session files were preserved. ' +
'Use /arena select --discard to manually clean up later.',
});
} else {
pushMessage({
messageType: 'info',
content:
'Arena session stopped. All Arena resources (including Git worktrees) were cleaned up.',
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
debugLogger.error('Failed to stop Arena session:', error);
pushMessage({
messageType: 'error',
content: `Failed to stop Arena session: ${message}`,
});
}
},
[isProcessing, closeArenaDialog, config, pushMessage],
);
const configPreserve =
config.getAgentsSettings().arena?.preserveArtifacts ?? false;
const items: Array<DescriptiveRadioSelectItem<StopAction>> = useMemo(
() => [
{
key: 'cleanup',
value: 'cleanup' as StopAction,
title: <Text>Stop and clean up</Text>,
description: (
<Text color={theme.text.secondary}>
Remove all worktrees and session files
</Text>
),
},
{
key: 'preserve',
value: 'preserve' as StopAction,
title: <Text>Stop and preserve artifacts</Text>,
description: (
<Text color={theme.text.secondary}>
Keep worktrees and session files for later inspection
</Text>
),
},
],
[],
);
const defaultIndex = configPreserve ? 1 : 0;
useKeypress(
(key) => {
if (key.name === 'escape') {
closeArenaDialog();
}
},
{ isActive: !isProcessing },
);
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={theme.text.primary}>
Stop Arena Session
</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Choose what to do with Arena artifacts:
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<DescriptiveRadioButtonSelect
items={items}
initialIndex={defaultIndex}
onSelect={(action: StopAction) => {
onStop(action);
}}
isFocused={!isProcessing}
showNumbers={false}
/>
</Box>
{configPreserve && (
<Box marginTop={1}>
<Text color={theme.text.secondary} dimColor>
Default: preserve (agents.arena.preserveArtifacts is enabled)
</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Enter to confirm, Esc to cancel
</Text>
</Box>
</Box>
);
}

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

@ -66,7 +66,11 @@ export function DescriptiveRadioButtonSelect<T>({
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" key={item.key}>
<Text color={titleColor}>{item.title}</Text>
<Text color={theme.text.secondary}>{item.description}</Text>
{typeof item.description === 'string' ? (
<Text color={theme.text.secondary}>{item.description}</Text>
) : (
item.description
)}
</Box>
)}
/>

View file

@ -0,0 +1,193 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useSelectionList } from '../../hooks/useSelectionList.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
export interface MultiSelectItem<T> extends SelectionListItem<T> {
label: string;
}
export interface MultiSelectProps<T> {
items: Array<MultiSelectItem<T>>;
initialIndex?: number;
initialSelectedKeys?: string[];
onConfirm: (selectedValues: T[]) => void;
onChange?: (selectedValues: T[]) => void;
onHighlight?: (value: T) => void;
isFocused?: boolean;
showNumbers?: boolean;
showScrollArrows?: boolean;
maxItemsToShow?: number;
}
const EMPTY_SELECTED_KEYS: string[] = [];
function getSelectedValues<T>(
items: Array<MultiSelectItem<T>>,
selectedKeys: Set<string>,
): T[] {
return items
.filter((item) => selectedKeys.has(item.key))
.map((item) => item.value);
}
export function MultiSelect<T>({
items,
initialIndex = 0,
initialSelectedKeys = EMPTY_SELECTED_KEYS,
onConfirm,
onChange,
onHighlight,
isFocused = true,
showNumbers = true,
showScrollArrows = false,
maxItemsToShow = 10,
}: MultiSelectProps<T>): React.JSX.Element {
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(
() => new Set(initialSelectedKeys),
);
const [scrollOffset, setScrollOffset] = useState(0);
useEffect(() => {
setSelectedKeys((prev) => {
const next = new Set(initialSelectedKeys);
if (
prev.size === next.size &&
Array.from(next).every((key) => prev.has(key))
) {
return prev;
}
return next;
});
}, [initialSelectedKeys]);
const { activeIndex } = useSelectionList({
items,
initialIndex,
isFocused,
// Disable numeric quick-select in useSelectionList — in a multi-select
// context, onSelect triggers onConfirm (submit), so numeric keys would
// accidentally submit the dialog instead of toggling checkboxes.
// Numbers are still rendered visually via the showNumbers prop below.
showNumbers: false,
onHighlight,
onSelect: () => {
onConfirm(getSelectedValues(items, selectedKeys));
},
});
const toggleSelectionAtIndex = useCallback(
(index: number) => {
const item = items[index];
if (!item || item.disabled) {
return;
}
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(item.key)) {
next.delete(item.key);
} else {
next.add(item.key);
}
return next;
});
},
[items],
);
useEffect(() => {
onChange?.(getSelectedValues(items, selectedKeys));
}, [items, selectedKeys, onChange]);
useKeypress(
(key) => {
if (key.name === 'space' || key.sequence === ' ') {
toggleSelectionAtIndex(activeIndex);
}
},
{ isActive: isFocused },
);
useEffect(() => {
const newScrollOffset = Math.max(
0,
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
);
if (activeIndex < scrollOffset) {
setScrollOffset(activeIndex);
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newScrollOffset);
}
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
const visibleItems = useMemo(
() => items.slice(scrollOffset, scrollOffset + maxItemsToShow),
[items, scrollOffset, maxItemsToShow],
);
const numberColumnWidth = String(items.length).length;
const hasMoreAbove = scrollOffset > 0;
const hasMoreBelow = scrollOffset + maxItemsToShow < items.length;
const moreAboveCount = scrollOffset;
const moreBelowCount = Math.max(
0,
items.length - (scrollOffset + maxItemsToShow),
);
return (
<Box flexDirection="column">
{showScrollArrows && hasMoreAbove && (
<Text color={theme.text.secondary}> {moreAboveCount} more above</Text>
)}
{visibleItems.map((item, index) => {
const itemIndex = scrollOffset + index;
const isActive = activeIndex === itemIndex;
const isChecked = selectedKeys.has(item.key);
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,
)}.`;
const checkboxText = item.disabled ? '[x]' : isChecked ? '[✓]' : '[ ]';
let textColor = theme.text.primary;
if (item.disabled) {
textColor = theme.text.secondary;
} else if (isActive) {
textColor = theme.status.success;
} else if (isChecked) {
textColor = theme.text.accent;
}
return (
<Box key={item.key} alignItems="flex-start">
<Box minWidth={4} flexShrink={0}>
<Text color={textColor}>{checkboxText}</Text>
</Box>
{showNumbers && (
<Box marginRight={1} minWidth={itemNumberText.length}>
<Text color={textColor}>{itemNumberText}</Text>
</Box>
)}
<Box flexGrow={1}>
<Text color={textColor}>{item.label}</Text>
</Box>
</Box>
);
})}
{showScrollArrows && hasMoreBelow && (
<Text color={theme.text.secondary}> {moreBelowCount} more below</Text>
)}
</Box>
);
}

View file

@ -1907,8 +1907,8 @@ export function useTextBuffer({
else if (key.ctrl && key.name === 'b') move('left');
else if (key.name === 'right' && !key.meta && !key.ctrl) move('right');
else if (key.ctrl && key.name === 'f') move('right');
else if (key.name === 'up') move('up');
else if (key.name === 'down') move('down');
else if (key.name === 'up' && !key.shift) move('up');
else if (key.name === 'down' && !key.shift) move('down');
else if ((key.ctrl || key.meta) && key.name === 'left') move('wordLeft');
else if (key.meta && key.name === 'b') move('wordLeft');
else if ((key.ctrl || key.meta) && key.name === 'right')

View file

@ -8,7 +8,7 @@ import React, { useMemo } from 'react';
import { Box, Text } from 'ink';
import type {
TaskResultDisplay,
SubagentStatsSummary,
AgentStatsSummary,
Config,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../../semantic-colors.js';
@ -467,7 +467,7 @@ const ExecutionSummaryDetails: React.FC<{
* Tool usage statistics component
*/
const ToolUsageStats: React.FC<{
executionSummary?: SubagentStatsSummary;
executionSummary?: AgentStatsSummary;
}> = ({ executionSummary }) => {
if (!executionSummary) {
return (

View file

@ -0,0 +1,424 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import type {
ContextCategoryBreakdown,
ContextToolDetail,
ContextMemoryDetail,
ContextSkillDetail,
} from '../../types.js';
import { t } from '../../../i18n/index.js';
// Progress bar characters
const FILLED = '\u2588'; // █ - filled block
const BUFFER = '\u2592'; // ▒ - medium shade (autocompact buffer)
const EMPTY = '\u2591'; // ░ - light shade (free space)
const CONTENT_WIDTH = 56;
interface ContextUsageProps {
modelName: string;
totalTokens: number;
contextWindowSize: number;
breakdown: ContextCategoryBreakdown;
builtinTools: ContextToolDetail[];
mcpTools: ContextToolDetail[];
memoryFiles: ContextMemoryDetail[];
skills: ContextSkillDetail[];
/** True when totalTokens is estimated (no API call yet) */
isEstimated?: boolean;
/** When true, show per-item detail breakdowns. Default: false (compact). */
showDetails?: boolean;
}
/**
* Truncate a string to maxLen, appending '…' if truncated.
*/
function truncateName(name: string, maxLen: number): string {
if (name.length <= maxLen) return name;
return name.slice(0, maxLen - 1) + '\u2026';
}
/**
* Format token count for display (e.g. 1234 -> "1.2k", 123456 -> "123.5k")
*/
function formatTokens(tokens: number): string {
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return `${tokens}`;
}
/**
* Render a three-segment progress bar: used | autocompact buffer | free space.
*/
const ProgressBar: React.FC<{
usedPercentage: number;
bufferPercentage: number;
width: number;
}> = ({ usedPercentage, bufferPercentage, width }) => {
const usedCount = Math.round((Math.min(usedPercentage, 100) / 100) * width);
const bufferCount = Math.round(
(Math.min(bufferPercentage, 100 - usedPercentage) / 100) * width,
);
const freeCount = Math.max(0, width - usedCount - bufferCount);
const usedStr = FILLED.repeat(Math.max(0, usedCount));
const freeStr = EMPTY.repeat(Math.max(0, freeCount));
const bufferStr = BUFFER.repeat(Math.max(0, bufferCount));
// Used color: accent by default, warning/error at high usage.
let usedColor = theme.text.accent;
if (usedPercentage > 80) {
usedColor = theme.status.error;
} else if (usedPercentage > 60) {
usedColor = theme.status.warning;
}
return (
<Text>
<Text color={usedColor}>{usedStr}</Text>
<Text color={theme.text.secondary}>{freeStr}</Text>
<Text color={theme.status.warning}>{bufferStr}</Text>
</Text>
);
};
/**
* A row showing a category with its token count and percentage.
*/
const CategoryRow: React.FC<{
symbol: string;
label: string;
tokens: number;
contextWindowSize: number;
symbolColor?: string;
}> = ({ symbol, label, tokens, contextWindowSize, symbolColor }) => {
const percentage = ((tokens / contextWindowSize) * 100).toFixed(1);
const tokenStr = `${formatTokens(tokens)} ${t('tokens')} (${percentage}%)`;
return (
<Box width={CONTENT_WIDTH}>
<Box width={2}>
<Text color={symbolColor || theme.text.secondary}>{symbol}</Text>
</Box>
<Box width={24}>
<Text color={theme.text.primary}>{label}</Text>
</Box>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.text.secondary}>{tokenStr}</Text>
</Box>
</Box>
);
};
/**
* A detail row for individual items (MCP tools, memory files, skills).
*/
const DETAIL_NAME_MAX_LEN = 30;
const DetailRow: React.FC<{
name: string;
tokens: number;
}> = ({ name, tokens }) => {
const tokenStr =
tokens > 0 ? `${formatTokens(tokens)} ${t('tokens')}` : `0 ${t('tokens')}`;
return (
<Box width={CONTENT_WIDTH} paddingLeft={2}>
<Text color={theme.text.secondary}>{'\u2514'} </Text>
<Box width={32}>
<Text color={theme.text.link}>
{truncateName(name, DETAIL_NAME_MAX_LEN)}
</Text>
</Box>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.text.secondary}>{tokenStr}</Text>
</Box>
</Box>
);
};
export const ContextUsage: React.FC<ContextUsageProps> = ({
modelName,
totalTokens,
contextWindowSize,
breakdown,
builtinTools,
mcpTools,
memoryFiles,
skills,
isEstimated,
showDetails = false,
}) => {
const percentage =
contextWindowSize > 0 ? (totalTokens / contextWindowSize) * 100 : 0;
// Sort detail items by token count (descending) for better readability
const sortedBuiltinTools = [...builtinTools].sort(
(a, b) => b.tokens - a.tokens,
);
const sortedMcpTools = [...mcpTools].sort((a, b) => b.tokens - a.tokens);
const sortedMemoryFiles = [...memoryFiles].sort(
(a, b) => b.tokens - a.tokens,
);
// Sort skills: loaded first, then by total token cost descending
const sortedSkills = [...skills].sort((a, b) => {
if (a.loaded !== b.loaded) return a.loaded ? -1 : 1;
const aTotal = a.tokens + (a.bodyTokens ?? 0);
const bTotal = b.tokens + (b.bodyTokens ?? 0);
return bTotal - aTotal;
});
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingX={2}
>
{/* Title */}
<Text bold color={theme.text.accent}>
{t('Context Usage')}
</Text>
<Box height={1} />
{isEstimated ? (
<>
{/* No API data yet — show hint instead of progress bar */}
<Box marginBottom={1}>
<Text color={theme.status.warning} italic>
{t('No API response yet. Send a message to see actual usage.')}
</Text>
</Box>
{/* Estimated overhead categories */}
<Text bold color={theme.text.primary}>
{t('Estimated pre-conversation overhead')}
</Text>
<Text color={theme.text.secondary}>
{t('Model')}: {modelName}
{' '}
{t('Context window')}: {formatTokens(contextWindowSize)}{' '}
{t('tokens')}
</Text>
<Box height={1} />
</>
) : (
<>
{/* Model name + context window info */}
<Box width={CONTENT_WIDTH} marginBottom={1}>
<Text color={theme.text.secondary}>
{t('Model')}: {modelName}
</Text>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.text.secondary}>
{t('Context window')}: {formatTokens(contextWindowSize)}{' '}
{t('tokens')}
</Text>
</Box>
</Box>
{/* Progress bar — three segments: used | free | buffer */}
<Box width={CONTENT_WIDTH}>
<ProgressBar
usedPercentage={Math.min(percentage, 100)}
bufferPercentage={
contextWindowSize > 0
? (breakdown.autocompactBuffer / contextWindowSize) * 100
: 0
}
width={CONTENT_WIDTH}
/>
</Box>
<Box height={1} />
{/* Legend — same layout as CategoryRow for alignment */}
<CategoryRow
symbol={FILLED}
label={t('Used')}
tokens={totalTokens}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
<CategoryRow
symbol={EMPTY}
label={t('Free')}
tokens={breakdown.freeSpace}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.secondary}
/>
<CategoryRow
symbol={BUFFER}
label={t('Autocompact buffer')}
tokens={breakdown.autocompactBuffer}
contextWindowSize={contextWindowSize}
symbolColor={theme.status.warning}
/>
<Box height={1} />
{/* Breakdown header */}
<Text bold color={theme.text.primary}>
{t('Usage by category')}
</Text>
</>
)}
<CategoryRow
symbol={FILLED}
label={t('System prompt')}
tokens={breakdown.systemPrompt}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
<CategoryRow
symbol={FILLED}
label={t('Built-in tools')}
tokens={breakdown.builtinTools}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
{breakdown.mcpTools > 0 && (
<CategoryRow
symbol={FILLED}
label={t('MCP tools')}
tokens={breakdown.mcpTools}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
)}
<CategoryRow
symbol={FILLED}
label={t('Memory files')}
tokens={breakdown.memoryFiles}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
<CategoryRow
symbol={FILLED}
label={t('Skills')}
tokens={breakdown.skills}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
{/* Only show Messages when we have real API data */}
{!isEstimated && (
<CategoryRow
symbol={FILLED}
label={t('Messages')}
tokens={breakdown.messages}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
)}
{showDetails ? (
<>
{/* Built-in tools detail */}
{sortedBuiltinTools.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('Built-in tools')}
</Text>
{sortedBuiltinTools.map((tool) => (
<DetailRow
key={tool.name}
name={tool.name}
tokens={tool.tokens}
/>
))}
</Box>
)}
{/* MCP Tools detail */}
{sortedMcpTools.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('MCP tools')}
</Text>
{sortedMcpTools.map((tool) => (
<DetailRow
key={tool.name}
name={tool.name}
tokens={tool.tokens}
/>
))}
</Box>
)}
{/* Memory files detail */}
{sortedMemoryFiles.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('Memory files')}
</Text>
{sortedMemoryFiles.map((file) => (
<DetailRow
key={file.path}
name={file.path}
tokens={file.tokens}
/>
))}
</Box>
)}
{/* Skills detail */}
{sortedSkills.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('Skills')}
</Text>
{sortedSkills.map((skill) => (
<Box key={skill.name} flexDirection="column">
<Box width={CONTENT_WIDTH} paddingLeft={2}>
<Text color={theme.text.secondary}>{'\u2514'} </Text>
<Box width={32}>
<Text color={theme.text.link}>
{truncateName(skill.name, DETAIL_NAME_MAX_LEN)}
</Text>
{skill.loaded && (
<Text color={theme.status.success}> {t('active')}</Text>
)}
</Box>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.text.secondary}>
{formatTokens(skill.tokens)} {t('tokens')}
</Text>
</Box>
</Box>
{skill.loaded &&
skill.bodyTokens != null &&
skill.bodyTokens > 0 && (
<Box width={CONTENT_WIDTH} paddingLeft={4}>
<Text color={theme.text.secondary}>{' \u2514'} </Text>
<Box width={30}>
<Text color={theme.text.secondary} italic>
{t('body loaded')}
</Text>
</Box>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.status.success}>
+{formatTokens(skill.bodyTokens)} {t('tokens')}
</Text>
</Box>
</Box>
)}
</Box>
))}
</Box>
)}
</>
) : (
<Box marginTop={1}>
<Text color={theme.text.secondary} italic>
{t('Run /context detail for per-item breakdown.')}
</Text>
</Box>
)}
</Box>
);
};