mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +00:00
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:
parent
d4cfb18f79
commit
5d07c495f1
27 changed files with 2086 additions and 314 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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] =
|
||||
|
|
|
|||
248
packages/cli/src/ui/components/agent-view/AgentChatView.tsx
Normal file
248
packages/cli/src/ui/components/agent-view/AgentChatView.tsx
Normal 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 "{agentId}" 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>
|
||||
);
|
||||
};
|
||||
137
packages/cli/src/ui/components/agent-view/AgentTabBar.tsx
Normal file
137
packages/cli/src/ui/components/agent-view/AgentTabBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
194
packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts
Normal file
194
packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts
Normal 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;
|
||||
}
|
||||
9
packages/cli/src/ui/components/agent-view/index.ts
Normal file
9
packages/cli/src/ui/components/agent-view/index.ts
Normal 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';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
@ -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');
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue