Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
640 changes: 640 additions & 0 deletions integration-tests/terminal-capture/subagent-flicker-regression.ts

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ describe('AppContainer State Management', () => {
const mockedUseTextBuffer = useTextBuffer as Mock;
const mockedUseLogger = useLogger as Mock;
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
const mockedUseTerminalSize = useTerminalSize as Mock;

beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -276,6 +277,7 @@ describe('AppContainer State Management', () => {
elapsedTime: '0.0s',
currentLoadingPhrase: '',
});
mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 24 });

// Mock Config
mockConfig = makeFakeConfig();
Expand Down Expand Up @@ -452,6 +454,34 @@ describe('AppContainer State Management', () => {
expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearTerminal);
});

it('does not clear the terminal just because width changed', () => {
vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 24 });
const { rerender } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
mockStdout.write.mockClear();

mockedUseTerminalSize.mockReturnValue({ columns: 100, rows: 24 });
rerender(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);

expect(mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
});

it('handleClearScreen avoids a second clearTerminal write', () => {
const clearSpy = vi.spyOn(console, 'clear').mockImplementation(() => {});

Expand Down
17 changes: 0 additions & 17 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1643,8 +1643,6 @@ export const AppContainer = (props: AppContainerProps) => {
pager: settings.merged.tools?.shell?.pager,
showColor: settings.merged.tools?.shell?.showColor,
});
const isInitialMount = useRef(true);

useEffect(() => {
if (activePtyId) {
ShellExecutionService.resizePty(
Expand All @@ -1662,21 +1660,6 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [ideNeedsRestart]);

useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}

const handler = setTimeout(() => {
refreshStatic();
}, 300);

return () => {
clearTimeout(handler);
};
}, [terminalWidth, refreshStatic]);

useEffect(() => {
const unsubscribe = ideContextStore.subscribe(setIdeContextState);
setIdeContextState(ideContextStore.get());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { act } from 'react';
import { render } from 'ink-testing-library';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AgentResultDisplay } from '@qwen-code/qwen-code-core';
import { makeFakeConfig } from '@qwen-code/qwen-code-core';
import { AgentExecutionDisplay } from './AgentExecutionDisplay.js';

let keypressHandler:
| ((key: { ctrl?: boolean; name?: string }) => void)
| undefined;

vi.mock('../../../hooks/useKeypress.js', () => ({
// The mock honours { isActive } so historical/completed displays don't
// capture the keypress handler — same scoping the production hook does.
useKeypress: vi.fn(
(
handler: (key: { ctrl?: boolean; name?: string }) => void,
options?: { isActive?: boolean },
) => {
keypressHandler = options?.isActive === false ? undefined : handler;
},
),
}));

function makeRunningData(toolCount: number): AgentResultDisplay {
return {
type: 'task_execution',
subagentName: 'reviewer',
subagentColor: 'blue',
status: 'running',
taskDescription: 'Review large output stability',
taskPrompt: `${'very-long-task-prompt '.repeat(20)}\nsecond\nthird`,
toolCalls: Array.from({ length: toolCount }, (_, index) => ({
callId: `call-${index}`,
name: `tool-${index}`,
status: 'success',
description: `description-${index} ${'wide '.repeat(20)}`,
resultDisplay: `result-${index} ${'payload '.repeat(20)}`,
})),
};
}

function makeCompletedData(toolCount: number): AgentResultDisplay {
return {
...makeRunningData(toolCount),
status: 'completed',
executionSummary: {
rounds: 3,
totalDurationMs: 12_345,
totalToolCalls: toolCount,
successfulToolCalls: toolCount,
failedToolCalls: 0,
successRate: 100,
inputTokens: 100,
outputTokens: 200,
thoughtTokens: 0,
cachedTokens: 0,
totalTokens: 4_321,
toolUsage: [],
},
};
}

function visualRowCount(frame: string): number {
if (!frame) return 0;
return frame.split('\n').length;
}

describe('<AgentExecutionDisplay />', () => {
beforeEach(() => {
keypressHandler = undefined;
});

it('bounds expanded detail by the assigned visual height', () => {
const { lastFrame } = render(
<AgentExecutionDisplay
data={makeRunningData(8)}
availableHeight={8}
childWidth={40}
config={makeFakeConfig()}
/>,
);

act(() => {
keypressHandler?.({ ctrl: true, name: 'e' });
});

const frame = lastFrame() ?? '';
expect(frame).toContain('Showing the first 1 visual lines');
expect(frame).toContain('Showing the last 1 of 8 tools');
expect(frame).toContain('tool-7');
expect(frame).not.toContain('tool-0');
});

it('keeps the rendered running frame within availableHeight', () => {
const availableHeight = 26;
const { lastFrame } = render(
<AgentExecutionDisplay
data={makeRunningData(8)}
availableHeight={availableHeight}
childWidth={80}
config={makeFakeConfig()}
/>,
);

act(() => {
keypressHandler?.({ ctrl: true, name: 'e' });
});

expect(visualRowCount(lastFrame() ?? '')).toBeLessThanOrEqual(
availableHeight,
);
});

it('survives the running → completed transition while expanded', () => {
// Real path: subagent is running, the user expands it (ctrl+e) so
// displayMode becomes 'default', then the same instance rerenders with
// completed data. The completed-state budget must still hold the
// expanded layout inside availableHeight, and ctrl+e must become a
// no-op on the completed instance so it doesn't drag historical
// displays through mode toggles.
const availableHeight = 30;
const { lastFrame, rerender } = render(
<AgentExecutionDisplay
data={makeRunningData(8)}
availableHeight={availableHeight}
childWidth={80}
config={makeFakeConfig()}
/>,
);

// Expand the running display.
act(() => {
keypressHandler?.({ ctrl: true, name: 'e' });
});
const expandedRunningFrame = lastFrame() ?? '';
expect(expandedRunningFrame).toContain('Task Detail:');
expect(expandedRunningFrame).toContain('Tools:');

// Re-render the same component instance with completed data, preserving
// displayMode. Without an overhead-aware completed budget the
// ExecutionSummary + ToolUsage blocks would push the frame past
// availableHeight here.
rerender(
<AgentExecutionDisplay
data={makeCompletedData(8)}
availableHeight={availableHeight}
childWidth={80}
config={makeFakeConfig()}
/>,
);

const completedFrame = lastFrame() ?? '';
expect(visualRowCount(completedFrame)).toBeLessThanOrEqual(
availableHeight,
);

// useKeypress is now `{ isActive: false }`; ctrl+e on the completed
// instance must not toggle anything. The mock unsets keypressHandler
// when isActive is false, so the call below is a no-op and the frame
// is identical.
const before = lastFrame() ?? '';
act(() => {
keypressHandler?.({ ctrl: true, name: 'e' });
});
expect(lastFrame() ?? '').toBe(before);
});
});
Loading
Loading