Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -60,6 +73,32 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
[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<string | null>(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
Expand Down Expand Up @@ -133,6 +172,19 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
>
{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 (
<Box key={tool.callId} flexDirection="column" minHeight={1}>
<Box flexDirection="row" alignItems="center">
Expand All @@ -155,6 +207,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
tool.status === ToolCallStatus.Confirming ||
tool.status === ToolCallStatus.Error
}
isFocused={isSubagentFocused}
isWaitingForOtherApproval={isWaitingForOtherApproval}
/>
</Box>
{tool.status === ToolCallStatus.Confirming &&
Expand Down
21 changes: 20 additions & 1 deletion packages/cli/src/ui/components/messages/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => (
<AgentExecutionDisplay
data={data}
availableHeight={availableHeight}
childWidth={childWidth}
config={config}
isFocused={isFocused}
isWaitingForOtherApproval={isWaitingForOtherApproval}
/>
);

Expand Down Expand Up @@ -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<ToolMessageProps> = ({
Expand All @@ -265,6 +280,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
ptyId,
config,
forceShowResult,
isFocused,
isWaitingForOtherApproval,
}) => {
const settings = useSettings();
const isThisShellFocused =
Expand Down Expand Up @@ -370,6 +387,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
availableHeight={availableHeight}
childWidth={innerWidth}
config={config}
isFocused={isFocused}
isWaitingForOtherApproval={isWaitingForOtherApproval}
/>
)}
{effectiveDisplayRenderer.type === 'diff' && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -78,6 +82,8 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
availableHeight,
childWidth,
config,
isFocused = true,
isWaitingForOtherApproval = false,
}) => {
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('compact');

Expand Down Expand Up @@ -168,9 +174,16 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
{/* Inline approval prompt when awaiting confirmation */}
{data.pendingConfirmation && (
<Box flexDirection="column" marginTop={1} paddingLeft={1}>
{isWaitingForOtherApproval && (
<Box marginBottom={0}>
<Text color={theme.text.secondary} dimColor>
⏳ Waiting for other approval...
</Text>
</Box>
)}
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
isFocused={true}
isFocused={isFocused}
availableTerminalHeight={availableHeight}
contentWidth={childWidth - 4}
compactMode={true}
Expand Down Expand Up @@ -237,10 +250,17 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
{/* Inline approval prompt when awaiting confirmation */}
{data.pendingConfirmation && (
<Box flexDirection="column">
{isWaitingForOtherApproval && (
<Box marginBottom={0}>
<Text color={theme.text.secondary} dimColor>
⏳ Waiting for other approval...
</Text>
</Box>
)}
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
config={config}
isFocused={true}
isFocused={isFocused}
availableTerminalHeight={availableHeight}
contentWidth={childWidth - 4}
compactMode={true}
Expand Down
Loading