Fix flicker issues by ensuring all actively changing content fits in the viewport (#1217)

This commit is contained in:
Jacob Richman
2025-06-19 20:17:23 +00:00
committed by GitHub
parent 10a83a6395
commit b0bc7c3d99
22 changed files with 1353 additions and 248 deletions

View File

@@ -0,0 +1,303 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { MaxSizedBox } from './MaxSizedBox.js';
import { Box, Text } from 'ink';
import { describe, it, expect } from 'vitest';
describe('<MaxSizedBox />', () => {
it('renders children without truncation when they fit', () => {
const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={10}>
<Box>
<Text>Hello, World!</Text>
</Box>
</MaxSizedBox>,
);
expect(lastFrame()).equals('Hello, World!');
});
it('hides lines when content exceeds maxHeight', () => {
const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={2}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>,
);
expect(lastFrame()).equals(`... first 2 lines hidden ...
Line 3`);
});
it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', () => {
const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>,
);
expect(lastFrame()).equals(`Line 1
... last 2 lines hidden ...`);
});
it('wraps text that exceeds maxWidth', () => {
const { lastFrame } = render(
<MaxSizedBox maxWidth={10} maxHeight={5}>
<Box>
<Text wrap="wrap">This is a long line of text</Text>
</Box>
</MaxSizedBox>,
);
expect(lastFrame()).equals(`This is a
long line
of text`);
});
it('handles mixed wrapping and non-wrapping segments', () => {
const multilineText = `This part will wrap around.
And has a line break.
Leading spaces preserved.`;
const { lastFrame } = render(
<MaxSizedBox maxWidth={20} maxHeight={20}>
<Box>
<Text>Example</Text>
</Box>
<Box>
<Text>No Wrap: </Text>
<Text wrap="wrap">{multilineText}</Text>
</Box>
<Box>
<Text>Longer No Wrap: </Text>
<Text wrap="wrap">This part will wrap around.</Text>
</Box>
</MaxSizedBox>,
);
expect(lastFrame()).equals(
`Example
No Wrap: This part
will wrap
around.
And has a
line break.
Leading
spaces
preserved.
Longer No Wrap: This
part
will
wrap
arou
nd.`,
);
});
it('handles words longer than maxWidth by splitting them', () => {
const { lastFrame } = render(
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
<Text wrap="wrap">Supercalifragilisticexpialidocious</Text>
</Box>
</MaxSizedBox>,
);
expect(lastFrame()).equals(`... …
istic
expia
lidoc
ious`);
});
it('does not truncate when maxHeight is undefined', () => {
const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={undefined}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
</MaxSizedBox>,
);
expect(lastFrame()).equals(`Line 1
Line 2`);
});
it('shows plural "lines" when more than one line is hidden', () => {
const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={2}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>,
);
expect(lastFrame()).equals(`... first 2 lines hidden ...
Line 3`);
});
it('shows plural "lines" when more than one line is hidden and overflowDirection is bottom', () => {
const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>,
);
expect(lastFrame()).equals(`Line 1
... last 2 lines hidden ...`);
});
it('renders an empty box for empty children', () => {
const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>,
);
// Expect an empty string or a box with nothing in it.
// Ink renders an empty box as an empty string.
expect(lastFrame()).equals('');
});
it('wraps text with multi-byte unicode characters correctly', () => {
const { lastFrame } = render(
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
<Text wrap="wrap"></Text>
</Box>
</MaxSizedBox>,
);
// "你好" has a visual width of 4. "世界" has a visual width of 4.
// With maxWidth=5, it should wrap after the second character.
expect(lastFrame()).equals(`你好
世界`);
});
it('wraps text with multi-byte emoji characters correctly', () => {
const { lastFrame } = render(
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
<Text wrap="wrap">🐶🐶🐶🐶🐶</Text>
</Box>
</MaxSizedBox>,
);
// Each "🐶" has a visual width of 2.
// With maxWidth=5, it should wrap every 2 emojis.
expect(lastFrame()).equals(`🐶🐶
🐶🐶
🐶`);
});
it('accounts for additionalHiddenLinesCount', () => {
const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>,
);
// 1 line is hidden by overflow, 5 are additionally hidden.
expect(lastFrame()).equals(`... first 7 lines hidden ...
Line 3`);
});
it('handles React.Fragment as a child', () => {
const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={10}>
<>
<Box>
<Text>Line 1 from Fragment</Text>
</Box>
<Box>
<Text>Line 2 from Fragment</Text>
</Box>
</>
<Box>
<Text>Line 3 direct child</Text>
</Box>
</MaxSizedBox>,
);
expect(lastFrame()).equals(`Line 1 from Fragment
Line 2 from Fragment
Line 3 direct child`);
});
it('clips a long single text child from the top', () => {
const THIRTY_LINES = Array.from(
{ length: 30 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={10}>
<Box>
<Text>{THIRTY_LINES}</Text>
</Box>
</MaxSizedBox>,
);
const expected = [
'... first 21 lines hidden ...',
...Array.from({ length: 9 }, (_, i) => `Line ${22 + i}`),
].join('\n');
expect(lastFrame()).equals(expected);
});
it('clips a long single text child from the bottom', () => {
const THIRTY_LINES = Array.from(
{ length: 30 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
<Box>
<Text>{THIRTY_LINES}</Text>
</Box>
</MaxSizedBox>,
);
const expected = [
...Array.from({ length: 9 }, (_, i) => `Line ${i + 1}`),
'... last 21 lines hidden ...',
].join('\n');
expect(lastFrame()).equals(expected);
});
});

View File

@@ -0,0 +1,511 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { Fragment } from 'react';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { Colors } from '../../colors.js';
import { toCodePoints } from '../../utils/textUtils.js';
const enableDebugLog = true;
function debugReportError(message: string, element: React.ReactNode) {
if (!enableDebugLog) return;
if (!React.isValidElement(element)) {
console.error(
message,
`Invalid element: '${String(element)}' typeof=${typeof element}`,
);
return;
}
let sourceMessage = '<Unknown file>';
try {
const elementWithSource = element as {
_source?: { fileName?: string; lineNumber?: number };
};
const fileName = elementWithSource._source?.fileName;
const lineNumber = elementWithSource._source?.lineNumber;
sourceMessage = fileName ? `${fileName}:${lineNumber}` : '<Unknown file>';
} catch (error) {
console.error('Error while trying to get file name:', error);
}
console.error(message, `${String(element.type)}. Source: ${sourceMessage}`);
}
interface MaxSizedBoxProps {
children?: React.ReactNode;
maxWidth?: number;
maxHeight: number | undefined;
overflowDirection?: 'top' | 'bottom';
additionalHiddenLinesCount?: number;
}
/**
* A React component that constrains the size of its children and provides
* content-aware truncation when the content exceeds the specified `maxHeight`.
*
* `MaxSizedBox` requires a specific structure for its children to correctly
* measure and render the content:
*
* 1. **Direct children must be `<Box>` elements.** Each `<Box>` represents a
* single row of content.
* 2. **Row `<Box>` elements must contain only `<Text>` elements.** These
* `<Text>` elements can be nested and there are no restrictions to Text
* element styling other than that non-wrapping text elements must be
* before wrapping text elements.
*
* **Constraints:**
* - **Box Properties:** Custom properties on the child `<Box>` elements are
* ignored. In debug mode, runtime checks will report errors for any
* unsupported properties.
* - **Text Wrapping:** Within a single row, `<Text>` elements with no wrapping
* (e.g., headers, labels) must appear before any `<Text>` elements that wrap.
* - **Element Types:** Runtime checks will warn if unsupported element types
* are used as children.
*
* @example
* <MaxSizedBox maxWidth={80} maxHeight={10}>
* <Box>
* <Text>This is the first line.</Text>
* </Box>
* <Box>
* <Text color="cyan" wrap="truncate">Non-wrapping Header: </Text>
* <Text>This is the rest of the line which will wrap if it's too long.</Text>
* </Box>
* <Box>
* <Text>
* Line 3 with <Text color="yellow">nested styled text</Text> inside of it.
* </Text>
* </Box>
* </MaxSizedBox>
*/
export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
children,
maxWidth,
maxHeight,
overflowDirection = 'top',
additionalHiddenLinesCount = 0,
}) => {
// When maxHeight is not set, we render the content normally rather
// than using our custom layout logic. This should slightly improve
// performance for the case where there is no height limit and is
// a useful debugging tool to ensure that our layouts are consist
// with the expected layout when there is no height limit.
// In the future we might choose to still apply our layout logic
// even in this case particularlly if there are cases where we
// intentionally diverse how certain layouts are rendered.
if (maxHeight === undefined) {
return (
<Box width={maxWidth} height={maxHeight} flexDirection="column">
{children}
</Box>
);
}
if (maxWidth === undefined) {
throw new Error('maxWidth must be defined when maxHeight is set.');
}
const laidOutStyledText: StyledText[][] = [];
function visitRows(element: React.ReactNode) {
if (!React.isValidElement(element)) {
return;
}
if (element.type === Fragment) {
React.Children.forEach(element.props.children, visitRows);
return;
}
if (element.type === Box) {
layoutInkElementAsStyledText(element, maxWidth!, laidOutStyledText);
return;
}
debugReportError('MaxSizedBox children must be <Box> elements', element);
}
React.Children.forEach(children, visitRows);
const contentWillOverflow =
(laidOutStyledText.length > maxHeight && maxHeight > 0) ||
additionalHiddenLinesCount > 0;
const visibleContentHeight = contentWillOverflow ? maxHeight - 1 : maxHeight;
const hiddenLinesCount = Math.max(
0,
laidOutStyledText.length - visibleContentHeight,
);
const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
const visibleStyledText =
hiddenLinesCount > 0
? overflowDirection === 'top'
? laidOutStyledText.slice(
laidOutStyledText.length - visibleContentHeight,
)
: laidOutStyledText.slice(0, visibleContentHeight)
: laidOutStyledText;
const visibleLines = visibleStyledText.map((line, index) => (
<Box key={index}>
{line.length > 0 ? (
line.map((segment, segIndex) => (
<Text key={segIndex} {...segment.props}>
{segment.text}
</Text>
))
) : (
<Text> </Text>
)}
</Box>
));
return (
<Box flexDirection="column" width={maxWidth} flexShrink={0}>
{totalHiddenLines > 0 && overflowDirection === 'top' && (
<Text color={Colors.Gray} wrap="truncate">
... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
</Text>
)}
{visibleLines}
{totalHiddenLines > 0 && overflowDirection === 'bottom' && (
<Text color={Colors.Gray} wrap="truncate">
... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
</Text>
)}
</Box>
);
};
// Define a type for styled text segments
interface StyledText {
text: string;
props: Record<string, unknown>;
}
/**
* Single row of content within the MaxSizedBox.
*
* A row can contain segments that are not wrapped, followed by segments that
* are. This is a minimal implementation that only supports the functionality
* needed today.
*/
interface Row {
noWrapSegments: StyledText[];
segments: StyledText[];
}
/**
* Flattens the child elements of MaxSizedBox into an array of `Row` objects.
*
* This function expects a specific child structure to function correctly:
* 1. The top-level child of `MaxSizedBox` should be a single `<Box>`. This
* outer box is primarily for structure and is not directly rendered.
* 2. Inside the outer `<Box>`, there should be one or more children. Each of
* these children must be a `<Box>` that represents a row.
* 3. Inside each "row" `<Box>`, the children must be `<Text>` components.
*
* The structure should look like this:
* <MaxSizedBox>
* <Box> // Row 1
* <Text>...</Text>
* <Text>...</Text>
* </Box>
* <Box> // Row 2
* <Text>...</Text>
* </Box>
* </MaxSizedBox>
*
* It is an error for a <Text> child without wrapping to appear after a
* <Text> child with wrapping within the same row Box.
*
* @param element The React node to flatten.
* @returns An array of `Row` objects.
*/
function visitBoxRow(element: React.ReactNode): Row {
if (!React.isValidElement(element) || element.type !== Box) {
debugReportError(
`All children of MaxSizedBox must be <Box> elements`,
element,
);
return {
noWrapSegments: [{ text: '<ERROR>', props: {} }],
segments: [],
};
}
if (enableDebugLog) {
const boxProps = element.props;
// Ensure the Box has no props other than the default ones and key.
let maxExpectedProps = 4;
if (boxProps.children !== undefined) {
// Allow the key prop, which is automatically added by React.
maxExpectedProps += 1;
}
if (boxProps.flexDirection !== 'row') {
debugReportError(
'MaxSizedBox children must have flexDirection="row".',
element,
);
}
if (Object.keys(boxProps).length > maxExpectedProps) {
debugReportError(
`Boxes inside MaxSizedBox must not have additional props. ${Object.keys(
boxProps,
).join(', ')}`,
element,
);
}
}
const row: Row = {
noWrapSegments: [],
segments: [],
};
let hasSeenWrapped = false;
function visitRowChild(
element: React.ReactNode,
parentProps: Record<string, unknown> | undefined,
) {
if (element === null) {
return;
}
if (typeof element === 'string' || typeof element === 'number') {
const text = String(element);
// Ignore empty strings as they don't need to be rendered.
if (!text) {
return;
}
const segment: StyledText = { text, props: parentProps ?? {} };
// Check the 'wrap' property from the merged props to decide the segment type.
if (parentProps === undefined || parentProps.wrap === 'wrap') {
hasSeenWrapped = true;
row.segments.push(segment);
} else {
if (!hasSeenWrapped) {
row.noWrapSegments.push(segment);
} else {
// put in in the wrapped segment as the row is already stuck in wrapped mode.
row.segments.push(segment);
debugReportError(
'Text elements without wrapping cannot appear after elements with wrapping in the same row.',
element,
);
}
}
return;
}
if (!React.isValidElement(element)) {
debugReportError('Invalid element.', element);
return;
}
if (element.type === Fragment) {
const fragmentChildren = element.props.children;
React.Children.forEach(fragmentChildren, (child) =>
visitRowChild(child, parentProps),
);
return;
}
if (element.type !== Text) {
debugReportError(
'Children of a row Box must be <Text> elements.',
element,
);
return;
}
// Merge props from parent <Text> elements. Child props take precedence.
const { children, ...currentProps } = element.props;
const mergedProps =
parentProps === undefined
? currentProps
: { ...parentProps, ...currentProps };
React.Children.forEach(children, (child) =>
visitRowChild(child, mergedProps),
);
}
React.Children.forEach(element.props.children, (child) =>
visitRowChild(child, undefined),
);
return row;
}
function layoutInkElementAsStyledText(
element: React.ReactElement,
maxWidth: number,
output: StyledText[][],
) {
const row = visitBoxRow(element);
if (row.segments.length === 0 && row.noWrapSegments.length === 0) {
// Return a single empty line if there are no segments to display
output.push([]);
return;
}
const lines: StyledText[][] = [];
const nonWrappingContent: StyledText[] = [];
let noWrappingWidth = 0;
// First, lay out the non-wrapping segments
row.noWrapSegments.forEach((segment) => {
nonWrappingContent.push(segment);
noWrappingWidth += stringWidth(segment.text);
});
if (row.segments.length === 0) {
// This is a bit of a special case when there are no segments that allow
// wrapping. It would be ideal to unify.
const lines: StyledText[][] = [];
let currentLine: StyledText[] = [];
nonWrappingContent.forEach((segment) => {
const textLines = segment.text.split('\n');
textLines.forEach((text, index) => {
if (index > 0) {
lines.push(currentLine);
currentLine = [];
}
if (text) {
currentLine.push({ text, props: segment.props });
}
});
});
if (
currentLine.length > 0 ||
(nonWrappingContent.length > 0 &&
nonWrappingContent[nonWrappingContent.length - 1].text.endsWith('\n'))
) {
lines.push(currentLine);
}
output.push(...lines);
return;
}
const availableWidth = maxWidth - noWrappingWidth;
if (availableWidth < 1) {
// No room to render the wrapping segments. TODO(jacob314): consider an alternative fallback strategy.
output.push(nonWrappingContent);
return;
}
// Now, lay out the wrapping segments
let wrappingPart: StyledText[] = [];
let wrappingPartWidth = 0;
function addWrappingPartToLines() {
if (lines.length === 0) {
lines.push([...nonWrappingContent, ...wrappingPart]);
} else {
if (noWrappingWidth > 0) {
lines.push([
...[{ text: ' '.repeat(noWrappingWidth), props: {} }],
...wrappingPart,
]);
} else {
lines.push(wrappingPart);
}
}
wrappingPart = [];
wrappingPartWidth = 0;
}
function addToWrappingPart(text: string, props: Record<string, unknown>) {
if (
wrappingPart.length > 0 &&
wrappingPart[wrappingPart.length - 1].props === props
) {
wrappingPart[wrappingPart.length - 1].text += text;
} else {
wrappingPart.push({ text, props });
}
}
row.segments.forEach((segment) => {
const linesFromSegment = segment.text.split('\n');
linesFromSegment.forEach((lineText, lineIndex) => {
if (lineIndex > 0) {
addWrappingPartToLines();
}
const words = lineText.split(/(\s+)/); // Split by whitespace
words.forEach((word) => {
if (!word) return;
const wordWidth = stringWidth(word);
if (
wrappingPartWidth + wordWidth > availableWidth &&
wrappingPartWidth > 0
) {
addWrappingPartToLines();
if (/^\s+$/.test(word)) {
return;
}
}
if (wordWidth > availableWidth) {
// Word is too long, needs to be split across lines
const wordAsCodePoints = toCodePoints(word);
let remainingWordAsCodePoints = wordAsCodePoints;
while (remainingWordAsCodePoints.length > 0) {
let splitIndex = 0;
let currentSplitWidth = 0;
for (const char of remainingWordAsCodePoints) {
const charWidth = stringWidth(char);
if (
wrappingPartWidth + currentSplitWidth + charWidth >
availableWidth
) {
break;
}
currentSplitWidth += charWidth;
splitIndex++;
}
if (splitIndex > 0) {
const part = remainingWordAsCodePoints
.slice(0, splitIndex)
.join('');
addToWrappingPart(part, segment.props);
wrappingPartWidth += stringWidth(part);
remainingWordAsCodePoints =
remainingWordAsCodePoints.slice(splitIndex);
}
if (remainingWordAsCodePoints.length > 0) {
addWrappingPartToLines();
}
}
} else {
addToWrappingPart(word, segment.props);
wrappingPartWidth += wordWidth;
}
});
});
// Split omits a trailing newline, so we need to handle it here
if (segment.text.endsWith('\n')) {
addWrappingPartToLines();
}
});
if (wrappingPart.length > 0) {
addWrappingPartToLines();
}
output.push(...lines);
}

View File

@@ -5,7 +5,7 @@
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Text } from 'ink';
import SelectInput, {
type ItemProps as InkSelectItemProps,
type IndicatorProps as InkSelectIndicatorProps,
@@ -78,11 +78,12 @@ export function RadioButtonSelect<T>({
isSelected = false,
}: InkSelectIndicatorProps): React.JSX.Element {
return (
<Box marginRight={1}>
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
{isSelected ? '●' : '○'}
</Text>
</Box>
<Text
color={isSelected ? Colors.AccentGreen : Colors.Foreground}
wrap="truncate"
>
{isSelected ? '● ' : '○ '}
</Text>
);
}
@@ -113,14 +114,18 @@ export function RadioButtonSelect<T>({
itemWithThemeProps.themeTypeDisplay
) {
return (
<Text color={textColor}>
<Text color={textColor} wrap="truncate">
{itemWithThemeProps.themeNameDisplay}{' '}
<Text color={Colors.Gray}>{itemWithThemeProps.themeTypeDisplay}</Text>
</Text>
);
}
return <Text color={textColor}>{label}</Text>;
return (
<Text color={textColor} wrap="truncate">
{label}
</Text>
);
}
initialIndex = initialIndex ?? 0;

View File

@@ -12,6 +12,7 @@ import pathMod from 'path';
import { useState, useCallback, useEffect, useMemo } from 'react';
import stringWidth from 'string-width';
import { unescapePath } from '@gemini-cli/core';
import { toCodePoints, cpLen, cpSlice } from '../../utils/textUtils.js';
export type Direction =
| 'left'
@@ -69,28 +70,6 @@ function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
/*
* -------------------------------------------------------------------------
* Unicodeaware helpers (work at the codepoint level rather than UTF16
* code units so that surrogatepair emoji count as one "column".)
* ---------------------------------------------------------------------- */
export function toCodePoints(str: string): string[] {
// [...str] or Array.from both iterate by UTF32 code point, handling
// surrogate pairs correctly.
return Array.from(str);
}
export function cpLen(str: string): number {
return toCodePoints(str).length;
}
export function cpSlice(str: string, start: number, end?: number): string {
// Slice by codepoint indices and rejoin.
const arr = toCodePoints(str).slice(start, end);
return arr.join('');
}
/* -------------------------------------------------------------------------
* Debug helper enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
* ---------------------------------------------------------------------- */