diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 4b70e3c6f1..c44b96ed2b 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import { Box } from 'ink'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; @@ -16,6 +16,19 @@ import { theme } from '../../semantic-colors.js'; import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js'; import { useConfig } from '../../contexts/ConfigContext.js'; import { useVerboseMode } from '../../contexts/VerboseModeContext.js'; +import type { AgentResultDisplay } from '@qwen-code/qwen-code-core'; + +function isAgentWithPendingConfirmation( + rd: IndividualToolCallDisplay['resultDisplay'], +): rd is AgentResultDisplay { + return ( + typeof rd === 'object' && + rd !== null && + 'type' in rd && + (rd as AgentResultDisplay).type === 'task_execution' && + (rd as AgentResultDisplay).pendingConfirmation !== undefined + ); +} interface ToolGroupMessageProps { groupId: number; @@ -60,6 +73,32 @@ export const ToolGroupMessage: React.FC = ({ [toolCalls], ); + // Determine which subagent tools currently have a pending confirmation. + // Must be called unconditionally (Rules of Hooks) — before any early return. + const subagentsAwaitingApproval = useMemo( + () => + toolCalls.filter((tc) => + isAgentWithPendingConfirmation(tc.resultDisplay), + ), + [toolCalls], + ); + + // "First-come, first-served" focus lock: once a subagent's confirmation + // appears, it keeps keyboard focus until the user resolves it. Only then + // does focus move to the next pending subagent. This prevents the jarring + // experience of focus jumping away while the user is mid-selection. + const focusedSubagentRef = useRef(null); + + const stillPending = subagentsAwaitingApproval.some( + (tc) => tc.callId === focusedSubagentRef.current, + ); + if (!stillPending) { + // Release stale lock and promote the next pending subagent (if any). + focusedSubagentRef.current = subagentsAwaitingApproval[0]?.callId ?? null; + } + + const focusedSubagentCallId = focusedSubagentRef.current; + // Compact mode: entire group → single line summary // Force-expand when: user must interact (Confirming), tool errored, // shell is focused, or user-initiated @@ -133,6 +172,19 @@ 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. + const isSubagentFocused = + isFocused && + !toolAwaitingApproval && + focusedSubagentCallId === tool.callId; + // Show the waiting indicator only when this subagent genuinely has a + // pending confirmation AND another subagent holds the focus lock. + const isWaitingForOtherApproval = + isAgentWithPendingConfirmation(tool.resultDisplay) && + focusedSubagentCallId !== null && + focusedSubagentCallId !== tool.callId; return ( @@ -155,6 +207,8 @@ export const ToolGroupMessage: React.FC = ({ tool.status === ToolCallStatus.Confirming || tool.status === ToolCallStatus.Error } + isFocused={isSubagentFocused} + isWaitingForOtherApproval={isWaitingForOtherApproval} /> {tool.status === ToolCallStatus.Confirming && diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index eeb2e91a89..21a0ec2ba4 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -173,12 +173,23 @@ const SubagentExecutionRenderer: React.FC<{ availableHeight?: number; childWidth: number; config: Config; -}> = ({ data, availableHeight, childWidth, config }) => ( + isFocused?: boolean; + isWaitingForOtherApproval?: boolean; +}> = ({ + data, + availableHeight, + childWidth, + config, + isFocused, + isWaitingForOtherApproval, +}) => ( ); @@ -249,6 +260,10 @@ export interface ToolMessageProps extends IndividualToolCallDisplay { embeddedShellFocused?: boolean; config?: Config; forceShowResult?: boolean; + /** Whether this tool's subagent confirmation prompt should respond to keyboard input. */ + isFocused?: boolean; + /** Whether another subagent's approval currently holds the focus lock, blocking this one. */ + isWaitingForOtherApproval?: boolean; } export const ToolMessage: React.FC = ({ @@ -265,6 +280,8 @@ export const ToolMessage: React.FC = ({ ptyId, config, forceShowResult, + isFocused, + isWaitingForOtherApproval, }) => { const settings = useSettings(); const isThisShellFocused = @@ -370,6 +387,8 @@ export const ToolMessage: React.FC = ({ availableHeight={availableHeight} childWidth={innerWidth} config={config} + isFocused={isFocused} + isWaitingForOtherApproval={isWaitingForOtherApproval} /> )} {effectiveDisplayRenderer.type === 'diff' && ( diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx index c55befbd1f..337696ef84 100644 --- a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx +++ b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx @@ -24,6 +24,10 @@ export interface AgentExecutionDisplayProps { availableHeight?: number; childWidth: number; config: Config; + /** Whether this display's confirmation prompt should respond to keyboard input. */ + isFocused?: boolean; + /** Whether another subagent's approval currently holds the focus lock, blocking this one. */ + isWaitingForOtherApproval?: boolean; } const getStatusColor = ( @@ -78,6 +82,8 @@ export const AgentExecutionDisplay: React.FC = ({ availableHeight, childWidth, config, + isFocused = true, + isWaitingForOtherApproval = false, }) => { const [displayMode, setDisplayMode] = React.useState('compact'); @@ -168,9 +174,16 @@ export const AgentExecutionDisplay: React.FC = ({ {/* Inline approval prompt when awaiting confirmation */} {data.pendingConfirmation && ( + {isWaitingForOtherApproval && ( + + + ⏳ Waiting for other approval... + + + )} = ({ {/* Inline approval prompt when awaiting confirmation */} {data.pendingConfirmation && ( + {isWaitingForOtherApproval && ( + + + ⏳ Waiting for other approval... + + + )}