diff options
| author | Jacob Richman <[email protected]> | 2025-06-19 20:17:23 +0000 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-06-19 13:17:23 -0700 |
| commit | b0bc7c3d996d25c9fefdfbcba3ca19fa46ad199f (patch) | |
| tree | c2d89d14b8dade1daf51f835969d9b0e79d4df30 /packages/cli/src/ui/components/shared | |
| parent | 10a83a6395b70f21b01da99d0992c78d0354a8dd (diff) | |
Fix flicker issues by ensuring all actively changing content fits in the viewport (#1217)
Diffstat (limited to 'packages/cli/src/ui/components/shared')
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 * ---------------------------------------------------------------------- */ |
