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
209 changes: 209 additions & 0 deletions packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<ToolCallStatus, string> = {
Expand All @@ -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 (
<Text>
MockSubagent[{callId}]: focused={String(isFocused)}
</Text>
);
}

return (
<Text>
MockTool[{callId}]: {statusSymbol} {name} - {description} ({emphasis})
Expand Down Expand Up @@ -258,6 +275,198 @@ describe('<ToolGroupMessage />', () => {
});
Comment thread
yiliang114 marked this conversation as resolved.
});

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(
<ToolGroupMessage
{...baseProps}
toolCalls={[
createToolCall({
callId: 'agent-1',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: createRunningSubagentDisplay('reviewer'),
}),
]}
/>,
);

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(
<ToolGroupMessage
{...baseProps}
isFocused={false}
toolCalls={[
createToolCall({
callId: 'agent-1',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: createRunningSubagentDisplay('reviewer'),
}),
]}
/>,
);

expect(lastFrame()).toContain('MockSubagent[agent-1]: focused=false');
});

it('gives focus to only the first running subagent when multiple are running', () => {
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={[
createToolCall({
callId: 'agent-1',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: createRunningSubagentDisplay('first'),
}),
createToolCall({
callId: 'agent-2',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: createRunningSubagentDisplay('second'),
}),
]}
/>,
);

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(
<ToolGroupMessage
{...baseProps}
toolCalls={[
createToolCall({
callId: 'agent-running',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: createRunningSubagentDisplay('runner'),
}),
createToolCall({
callId: 'agent-pending',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: pendingDisplay,
}),
]}
/>,
);

// 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(
<ToolGroupMessage
{...baseProps}
toolCalls={[
createToolCall({
callId: 'tool-confirm',
name: 'write_file',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Write file?',
prompt: 'Allow write?',
onConfirm: vi.fn(),
},
}),
createToolCall({
callId: 'agent-running',
name: 'agent',
status: ToolCallStatus.Executing,
resultDisplay: createRunningSubagentDisplay('runner'),
}),
]}
/>,
);

// 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(
<ToolGroupMessage
{...baseProps}
toolCalls={[
createToolCall({
callId: 'agent-done',
name: 'agent',
status: ToolCallStatus.Success,
resultDisplay: createCompletedSubagentDisplay('finished'),
}),
]}
/>,
);

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 })];
Expand Down
33 changes: 30 additions & 3 deletions packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -128,6 +140,18 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
}

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(
Comment thread
yiliang114 marked this conversation as resolved.
() =>
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
Expand Down Expand Up @@ -256,12 +280,15 @@ 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.
// 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;
Comment thread
yiliang114 marked this conversation as resolved.
// Show the waiting indicator only when this subagent genuinely has a
// pending confirmation AND another subagent holds the focus lock.
const isWaitingForOtherApproval =
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/ui/components/messages/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading