fix(cli): add TUI flicker foundation fixes (#3591)

* fix(cli): reduce main screen flicker

* fix(cli): pre-slice large tool text output

* fix(cli): slice tool output by visual height

* fix(core): preserve shell transcript across narrow wraps

* fix(core): suppress soft-wrap-only shell rerenders

* fix(core): compare default shell output by logical wraps

* fix(cli): gate synchronized terminal output

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
This commit is contained in:
ChiGao 2026-04-25 10:13:34 +08:00 committed by GitHub
parent 007a109db8
commit 54465b0c02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1473 additions and 200 deletions

View file

@ -547,6 +547,76 @@ describe('<ToolMessage />', () => {
expect(output).toContain('line 30');
});
it('pre-slices large non-shell string output before MaxSizedBox layout', () => {
const longString = Array.from(
{ length: 5000 },
(_, i) => `line ${i + 1}`,
).join('\n');
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="some-other-tool"
resultDisplay={longString}
status={ToolCallStatus.Success}
availableTerminalHeight={12}
/>,
StreamingState.Idle,
);
const output = lastFrame()!;
expect(output).toContain('... first 4995 lines hidden ...');
expect(output).not.toContain('line 4995');
expect(output).toContain('line 4996');
expect(output).toContain('line 4997');
expect(output).toContain('line 4998');
expect(output).toContain('line 4999');
expect(output).toContain('line 5000');
});
it('pre-slices single-line output by visual width before MaxSizedBox layout', () => {
const longSingleLine = Array.from({ length: 1000 }, (_, i) =>
String(i % 10),
).join('');
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="some-other-tool"
contentWidth={20}
resultDisplay={longSingleLine}
status={ToolCallStatus.Success}
availableTerminalHeight={12}
/>,
StreamingState.Idle,
);
const output = lastFrame()!;
expect(output).toMatch(/\.\.\. first \d+ lin/);
expect(output).not.toContain(longSingleLine);
expect(output).toContain(longSingleLine.slice(-10));
});
it('does not pre-slice string output that exactly fits available height', () => {
const exactFitString = Array.from(
{ length: 6 },
(_, i) => `line ${i + 1}`,
).join('\n');
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="some-other-tool"
resultDisplay={exactFitString}
status={ToolCallStatus.Success}
availableTerminalHeight={12}
/>,
StreamingState.Idle,
);
const output = lastFrame()!;
expect(output).not.toContain('lines hidden');
expect(output).toContain('line 1');
expect(output).toContain('line 6');
});
it.each([
['negative', -1],
['fractional', 1.5],

View file

@ -12,7 +12,7 @@ import { DiffRenderer } from './DiffRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText, ShellStatsBar } from '../AnsiOutput.js';
import type { ShellStatsBarProps } from '../AnsiOutput.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
import { TodoDisplay } from '../TodoDisplay.js';
import type {
TodoResultDisplay,
@ -31,6 +31,7 @@ import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../../config/settings.js';
import { useCompactMode } from '../../contexts/CompactModeContext.js';
import { getCachedStringWidth, toCodePoints } from '../../utils/textUtils.js';
import {
ToolStatusIndicator,
@ -48,6 +49,65 @@ const DEFAULT_SHELL_OUTPUT_MAX_LINES = 5;
const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000;
export type TextEmphasis = 'high' | 'medium' | 'low';
function sliceTextForMaxHeight(
text: string,
maxHeight: number | undefined,
maxWidth: number,
): { text: string; hiddenLinesCount: number } {
if (maxHeight === undefined) {
return { text, hiddenLinesCount: 0 };
}
const targetMaxHeight = Math.max(Math.round(maxHeight), MINIMUM_MAX_HEIGHT);
const visibleContentHeight = targetMaxHeight - 1;
const visualWidth = Math.max(1, Math.floor(maxWidth));
const visibleLines: string[] = [];
let visualLineCount = 0;
let currentLine = '';
let currentLineWidth = 0;
const appendVisibleLine = (line: string) => {
visualLineCount += 1;
visibleLines.push(line);
if (visibleLines.length > visibleContentHeight) {
visibleLines.shift();
}
};
const flushCurrentLine = () => {
appendVisibleLine(currentLine);
currentLine = '';
currentLineWidth = 0;
};
for (const char of toCodePoints(text)) {
if (char === '\n') {
flushCurrentLine();
continue;
}
const charWidth = Math.max(getCachedStringWidth(char), 1);
if (currentLineWidth > 0 && currentLineWidth + charWidth > visualWidth) {
flushCurrentLine();
}
currentLine += char;
currentLineWidth += charWidth;
}
flushCurrentLine();
if (visualLineCount <= targetMaxHeight) {
return { text, hiddenLinesCount: 0 };
}
const hiddenLinesCount = visualLineCount - visibleContentHeight;
return {
text: visibleLines.join('\n'),
hiddenLinesCount,
};
}
type DisplayRendererResult =
| { type: 'none' }
| { type: 'todo'; data: TodoResultDisplay }
@ -234,11 +294,21 @@ const StringResultRenderer: React.FC<{
);
}
const sliced = sliceTextForMaxHeight(
displayData,
availableHeight,
childWidth,
);
return (
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
<MaxSizedBox
maxHeight={availableHeight}
maxWidth={childWidth}
additionalHiddenLinesCount={sliced.hiddenLinesCount}
>
<Box>
<Text wrap="wrap" color={theme.text.primary}>
{displayData}
{sliced.text}
</Text>
</Box>
</MaxSizedBox>