fix(cli): restore SubAgent shortcut focus (#3771)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

This commit is contained in:
易良 2026-04-30 23:02:01 +08:00 committed by GitHub
parent 4cd9f0cbe4
commit 8b6b0d64f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 247 additions and 5 deletions

View file

@ -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<ToolCallStatus, string> = {
@ -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})
@ -258,6 +275,198 @@ describe('<ToolGroupMessage />', () => {
});
});
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 })];

View file

@ -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<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(
() =>
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<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;
// Show the waiting indicator only when this subagent genuinely has a
// pending confirmation AND another subagent holds the focus lock.
const isWaitingForOtherApproval =

View file

@ -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;

View file

@ -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;