summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/shared
diff options
context:
space:
mode:
authorJacob Richman <[email protected]>2025-06-19 20:17:23 +0000
committerGitHub <[email protected]>2025-06-19 13:17:23 -0700
commitb0bc7c3d996d25c9fefdfbcba3ca19fa46ad199f (patch)
treec2d89d14b8dade1daf51f835969d9b0e79d4df30 /packages/cli/src/ui/components/shared
parent10a83a6395b70f21b01da99d0992c78d0354a8dd (diff)
Fix flicker issues by ensuring all actively changing content fits in the viewport (#1217)
Diffstat (limited to 'packages/cli/src/ui/components/shared')
-rw-r--r--packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx303
-rw-r--r--packages/cli/src/ui/components/shared/MaxSizedBox.tsx511
-rw-r--r--packages/cli/src/ui/components/shared/RadioButtonSelect.tsx21
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.ts23
4 files changed, 828 insertions, 30 deletions
diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
new file mode 100644
index 00000000..23ef98cd
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
@@ -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);
+ });
+});
diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
new file mode 100644
index 00000000..fe73c250
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
@@ -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);
+}
diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
index 5430a442..71077f1c 100644
--- a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
+++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
@@ -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;
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
index 6b025dd5..93364ebe 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -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;
}
-/*
- * -------------------------------------------------------------------------
- * Unicode‑aware helpers (work at the code‑point level rather than UTF‑16
- * code units so that surrogate‑pair emoji count as one "column".)
- * ---------------------------------------------------------------------- */
-
-export function toCodePoints(str: string): string[] {
- // [...str] or Array.from both iterate by UTF‑32 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 code‑point indices and re‑join.
- const arr = toCodePoints(str).slice(start, end);
- return arr.join('');
-}
-
/* -------------------------------------------------------------------------
* Debug helper – enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
* ---------------------------------------------------------------------- */