From 8b6b0d64f871f5372670a2dc71fb28d164c3d837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Thu, 30 Apr 2026 23:02:01 +0800 Subject: [PATCH] fix(cli): restore SubAgent shortcut focus (#3771) --- .../messages/ToolGroupMessage.test.tsx | 209 ++++++++++++++++++ .../components/messages/ToolGroupMessage.tsx | 33 ++- .../ui/components/messages/ToolMessage.tsx | 5 +- .../runtime/AgentExecutionDisplay.tsx | 5 +- 4 files changed, 247 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 51ca0bea1..9610f2771 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -12,6 +12,7 @@ import { ToolGroupMessage } from './ToolGroupMessage.js'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; import type { + AgentResultDisplay, Config, ToolCallConfirmationDetails, } from '@qwen-code/qwen-code-core'; @@ -26,12 +27,16 @@ vi.mock('./ToolMessage.js', () => ({ description, status, emphasis, + resultDisplay, + isFocused, }: { callId: string; name: string; description: string; status: ToolCallStatus; emphasis: string; + resultDisplay?: unknown; + isFocused?: boolean; }) { // Use the same constants as the real component const statusSymbolMap: Record = { @@ -43,6 +48,18 @@ vi.mock('./ToolMessage.js', () => ({ [ToolCallStatus.Error]: TOOL_STATUS.ERROR, }; const statusSymbol = statusSymbolMap[status] || '?'; + if ( + resultDisplay && + typeof resultDisplay === 'object' && + (resultDisplay as { type?: string }).type === 'task_execution' + ) { + return ( + + MockSubagent[{callId}]: focused={String(isFocused)} + + ); + } + return ( MockTool[{callId}]: {statusSymbol} {name} - {description} ({emphasis}) @@ -258,6 +275,198 @@ describe('', () => { }); }); + describe('SubAgent focus', () => { + // Helper to build a running SubAgent result display + const createRunningSubagentDisplay = ( + name: string, + ): AgentResultDisplay => ({ + type: 'task_execution', + subagentName: name, + taskDescription: `${name} task`, + taskPrompt: `Run ${name}`, + status: 'running', + toolCalls: [ + { + callId: `${name}-read-1`, + name: 'read_file', + status: 'success', + description: 'Read file', + }, + ], + }); + + // Helper to build a completed SubAgent result display + const createCompletedSubagentDisplay = ( + name: string, + ): AgentResultDisplay => ({ + type: 'task_execution', + subagentName: name, + taskDescription: `${name} task`, + taskPrompt: `Run ${name}`, + status: 'completed', + toolCalls: [ + { + callId: `${name}-read-1`, + name: 'read_file', + status: 'success', + description: 'Read file', + }, + ], + }); + + it('keeps a normal running subagent focused so Ctrl+E can expand it', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('MockSubagent[agent-1]: focused=true'); + }); + + it('does not focus a running subagent when the parent group is not focused', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('MockSubagent[agent-1]: focused=false'); + }); + + it('gives focus to only the first running subagent when multiple are running', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('MockSubagent[agent-1]: focused=true'); + expect(lastFrame()).toContain('MockSubagent[agent-2]: focused=false'); + }); + + it('pending confirmation wins over running fallback', () => { + const pendingDisplay: AgentResultDisplay = { + ...createRunningSubagentDisplay('pending-agent'), + pendingConfirmation: { + type: 'info', + title: 'Approve?', + prompt: 'Allow this action?', + onConfirm: vi.fn(), + }, + }; + + const { lastFrame } = renderWithProviders( + , + ); + + // The subagent with pending confirmation gets focus, not the first running one + expect(lastFrame()).toContain( + 'MockSubagent[agent-running]: focused=false', + ); + expect(lastFrame()).toContain( + 'MockSubagent[agent-pending]: focused=true', + ); + }); + + it('direct tool-level confirmation blocks all subagent shortcut focus', () => { + const { lastFrame } = renderWithProviders( + , + ); + + // Direct tool confirmation active → subagent gets no shortcut focus + expect(lastFrame()).toContain( + 'MockSubagent[agent-running]: focused=false', + ); + }); + + it('completed subagent does not receive focus', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('MockSubagent[agent-done]: focused=false'); + }); + }); + describe('Border Color Logic', () => { it('uses yellow border when tools are pending', () => { const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })]; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 71385c533..2520b19b0 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -30,6 +30,18 @@ function isAgentWithPendingConfirmation( ); } +function isRunningAgent( + rd: IndividualToolCallDisplay['resultDisplay'], +): rd is AgentResultDisplay { + return ( + typeof rd === 'object' && + rd !== null && + 'type' in rd && + (rd as AgentResultDisplay).type === 'task_execution' && + (rd as AgentResultDisplay).status === 'running' + ); +} + interface ToolGroupMessageProps { groupId: number; toolCalls: IndividualToolCallDisplay[]; @@ -128,6 +140,18 @@ export const ToolGroupMessage: React.FC = ({ } const focusedSubagentCallId = focusedSubagentRef.current; + // When no subagent has a pending confirmation, fall back to the *first* + // running subagent for Ctrl+E/Ctrl+F shortcut focus. "First" (array order) + // is the oldest — the one most likely to have accumulated tool calls and + // display the "+N more (ctrl+e to expand)" hint. + const runningSubagentCallId = useMemo( + () => + toolCalls.find((tc) => isRunningAgent(tc.resultDisplay))?.callId ?? null, + [toolCalls], + ); + // Pending confirmation takes strict priority over running fallback. + const keyboardFocusedSubagentCallId = + focusedSubagentCallId ?? runningSubagentCallId; // Compact mode: entire group → single line summary // Force-expand when: user must interact (Confirming or subagent pending @@ -256,12 +280,15 @@ export const ToolGroupMessage: React.FC = ({ {toolCalls.map((tool) => { const isConfirming = toolAwaitingApproval?.callId === tool.callId; // A subagent's inline confirmation should only receive keyboard focus - // when (1) there is no direct tool-level confirmation active, and - // (2) this tool currently holds the focus lock. + // when (1) there is no direct tool-level confirmation active, and (2) + // this tool currently holds the subagent keyboard focus. Pending + // confirmations keep the existing first-come focus lock; otherwise the + // first running subagent owns Ctrl+E/Ctrl+F so the compact hint remains + // actionable without making parallel subagents toggle in lock-step. const isSubagentFocused = isFocused && !toolAwaitingApproval && - focusedSubagentCallId === tool.callId; + keyboardFocusedSubagentCallId === tool.callId; // Show the waiting indicator only when this subagent genuinely has a // pending confirmation AND another subagent holds the focus lock. const isWaitingForOtherApproval = diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 417c00010..a7a78f1a9 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -342,7 +342,10 @@ export interface ToolMessageProps extends IndividualToolCallDisplay { embeddedShellFocused?: boolean; config?: Config; forceShowResult?: boolean; - /** Whether this tool's subagent confirmation prompt should respond to keyboard input. */ + /** + * Whether this subagent owns keyboard input for confirmations and + * Ctrl+E/Ctrl+F display shortcuts. + */ isFocused?: boolean; /** Whether another subagent's approval currently holds the focus lock, blocking this one. */ isWaitingForOtherApproval?: boolean; diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx index 72b175d21..a81ecc4cd 100644 --- a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx +++ b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx @@ -29,7 +29,10 @@ export interface AgentExecutionDisplayProps { availableHeight?: number; childWidth: number; config: Config; - /** Whether this display's confirmation prompt should respond to keyboard input. */ + /** + * Whether this subagent owns keyboard input for confirmations and + * Ctrl+E/Ctrl+F display shortcuts. + */ isFocused?: boolean; /** Whether another subagent's approval currently holds the focus lock, blocking this one. */ isWaitingForOtherApproval?: boolean;