feat(cli): Add agent tab navigation and live tool output for in-process arena mode

Add AgentViewContext, AgentTabBar, and AgentChatView components for tab-based
agent switching. Add useArenaInProcess hook bridging ArenaManager events to
React state. Add agentHistoryAdapter converting AgentMessage[] to HistoryItem[].

Core support changes:
- Replace stream buffers with ROUND_TEXT events (complete round text)
- Add TOOL_OUTPUT_UPDATE events for live tool output streaming
- Add pendingApprovals/liveOutputs/shellPids state to AgentInteractive
- Fix missing ROUND_END emission for final text rounds

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-02-23 13:21:16 +08:00
parent d4cfb18f79
commit 5d07c495f1
27 changed files with 2086 additions and 314 deletions

View file

@ -20,10 +20,10 @@ import { AuthDialog } from '../auth/AuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { ArenaStartDialog } from './ArenaStartDialog.js';
import { ArenaSelectDialog } from './ArenaSelectDialog.js';
import { ArenaStopDialog } from './ArenaStopDialog.js';
import { ArenaStatusDialog } from './ArenaStatusDialog.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';

View file

@ -39,7 +39,7 @@ 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 { ArenaAgentCard, ArenaSessionCard } from './messages/ArenaCards.js';
import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
interface HistoryItemDisplayProps {
item: HistoryItem;

View file

@ -873,7 +873,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
],
);
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
useKeypress(handleInput, {
isActive: !isEmbeddedShellFocused,
});
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =

View file

@ -0,0 +1,248 @@
/**
* @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,
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';
// ─── 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
[
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);
if (!agent || !interactiveAgent) {
return (
<Box marginX={2}>
<Text color={theme.status.error}>
Agent &quot;{agentId}&quot; not found.
</Text>
</Box>
);
}
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={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,137 @@
/**
* @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.
* Left/Right arrow keys cycle through tabs when the input buffer is empty.
*
* 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.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 } = useAgentViewState();
const { switchToNext, switchToPrevious } = useAgentViewActions();
const { buffer, embeddedShellFocused } = useUIState();
// Left/Right arrow keys switch tabs when the input buffer is empty
// and no embedded shell (main or agent tab) has input focus.
useKeypress(
(key) => {
if (buffer.text !== '' || embeddedShellFocused || agentShellFocused)
return;
if (key.name === 'left') {
switchToPrevious();
} else if (key.name === 'right') {
switchToNext();
}
},
{ 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]);
return (
<Box flexDirection="row" paddingX={1}>
{/* Main tab */}
<Box marginRight={1}>
<Text
bold={activeView === 'main'}
backgroundColor={
activeView === 'main' ? theme.border.default : undefined
}
color={
activeView === 'main' ? theme.text.primary : theme.text.secondary
}
>
{' Main '}
</Text>
</Box>
{/* Separator */}
<Text 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}
backgroundColor={isActive ? theme.border.default : undefined}
color={isActive ? undefined : agent.color || theme.text.secondary}
>
{` ${agent.displayName} `}
</Text>
<Text color={indicatorColor}>{` ${symbol}`}</Text>
</Box>
);
})}
{/* Navigation hint */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>/</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,528 @@
/**
* @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 outputFile from tool_result', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'shell'),
toolResultMsg('c1', 'shell', {
success: true,
outputFile: '/tmp/output.txt',
}),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.outputFile).toBe('/tmp/output.txt');
});
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,9 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export { AgentTabBar } from './AgentTabBar.js';
export { AgentChatView } from './AgentChatView.js';
export { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';

View file

@ -12,14 +12,14 @@ import {
AgentStatus,
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';
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;

View file

@ -9,11 +9,11 @@ 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';
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;

View file

@ -5,20 +5,24 @@
*/
import type React from 'react';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
type ArenaManager,
type ArenaAgentState,
type InProcessBackend,
type AgentStatsSummary,
isTerminalStatus,
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';
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;
@ -77,12 +81,20 @@ export function ArenaStatusDialog({
}: 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);
}, STATUS_REFRESH_INTERVAL_MS);
}, interval);
return () => clearInterval(timer);
}, []);
}, [isInProcess]);
// Force re-read on every tick
void tick;
@ -92,6 +104,20 @@ export function ArenaStatusDialog({
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;
@ -130,6 +156,12 @@ export function ArenaStatusDialog({
</Text>
<Text color={theme.text.secondary}> · </Text>
<Text color={sessionLabel.color}>{sessionLabel.text}</Text>
{isInProcess && (
<>
<Text color={theme.text.secondary}> · </Text>
<Text color={theme.text.accent}>In-Process</Text>
</>
)}
</Box>
<Box height={1} />
@ -189,52 +221,73 @@ export function ArenaStatusDialog({
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}>
<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(
agent.stats.totalTokens.toLocaleString(),
colTokens - 1,
'right',
)}
</Text>
</Box>
<Box width={colRounds} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(String(agent.stats.rounds), colRounds - 1, 'right')}
</Text>
</Box>
<Box width={colTools} justifyContent="flex-end">
{agent.stats.failedToolCalls > 0 ? (
<Text>
<Text color={theme.status.success}>
{agent.stats.successfulToolCalls}
</Text>
<Text color={theme.text.secondary}>/</Text>
<Text color={theme.status.error}>
{agent.stats.failedToolCalls}
</Text>
</Text>
) : (
<Box key={agent.agentId} flexDirection="column">
<Box>
<Box flexGrow={1}>
<Text color={theme.text.primary}>
{pad(String(agent.stats.toolCalls), colTools - 1, 'right')}
{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={theme.text.primary}>
{pad(String(toolCalls), colTools - 1, 'right')}
</Text>
)}
</Box>
</Box>
{/* In-process mode: show extra detail row with cost + thought tokens */}
{live && (live.estimatedCost > 0 || live.thoughtTokens > 0) && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
{live.estimatedCost > 0 &&
`Cost: $${live.estimatedCost.toFixed(4)}`}
{live.estimatedCost > 0 && live.thoughtTokens > 0 && ' · '}
{live.thoughtTokens > 0 &&
`Thinking: ${live.thoughtTokens.toLocaleString()} tok`}
{live.cachedTokens > 0 &&
` · Cached: ${live.cachedTokens.toLocaleString()} tok`}
</Text>
</Box>
)}
</Box>
);
})}

View file

@ -12,12 +12,12 @@ import {
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';
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');