diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx index 50951b4f..92147d3c 100644 --- a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx +++ b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx @@ -248,6 +248,89 @@ Line 3`); 🐶`); }); + it('falls back to an ellipsis when width is extremely small', () => { + const { lastFrame } = render( + + + + No + wrap + + + , + ); + + expect(lastFrame()).equals('N…'); + }); + + it('truncates long non-wrapping text with ellipsis', () => { + const { lastFrame } = render( + + + + ABCDE + wrap + + + , + ); + + expect(lastFrame()).equals('AB…'); + }); + + it('truncates non-wrapping text containing line breaks', () => { + const { lastFrame } = render( + + + + {'A\nBCDE'} + wrap + + + , + ); + + expect(lastFrame()).equals(`A\n…`); + }); + + it('truncates emoji characters correctly with ellipsis', () => { + const { lastFrame } = render( + + + + 🐶🐶🐶 + wrap + + + , + ); + + expect(lastFrame()).equals(`🐶…`); + }); + + it('shows ellipsis for multiple rows with long non-wrapping text', () => { + const { lastFrame } = render( + + + + AAA + first + + + BBB + second + + + CCC + third + + + , + ); + + expect(lastFrame()).equals(`AA…\nBB…\nCC…`); + }); + it('accounts for additionalHiddenLinesCount', () => { const { lastFrame } = render( diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx index eb5ef6b4..346472bf 100644 --- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx +++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx @@ -432,8 +432,85 @@ function layoutInkElementAsStyledText( const availableWidth = maxWidth - noWrappingWidth; if (availableWidth < 1) { - // No room to render the wrapping segments. TODO(jacob314): consider an alternative fallback strategy. - output.push(nonWrappingContent); + // No room to render the wrapping segments. Truncate the non-wrapping + // content and append an ellipsis so the line always fits within maxWidth. + + // Handle line breaks in non-wrapping content when truncating + const lines: StyledText[][] = []; + let currentLine: StyledText[] = []; + let currentLineWidth = 0; + + for (const segment of nonWrappingContent) { + const textLines = segment.text.split('\n'); + textLines.forEach((text, index) => { + if (index > 0) { + // New line encountered, finish current line and start new one + lines.push(currentLine); + currentLine = []; + currentLineWidth = 0; + } + + if (text) { + const textWidth = stringWidth(text); + + // When there's no room for wrapping content, be very conservative + // For lines after the first line break, show only ellipsis if the text would be truncated + if (index > 0 && textWidth > 0) { + // This is content after a line break - just show ellipsis to indicate truncation + currentLine.push({ text: '…', props: {} }); + currentLineWidth = stringWidth('…'); + } else { + // This is the first line or a continuation, try to fit what we can + const maxContentWidth = Math.max(0, maxWidth - stringWidth('…')); + + if (textWidth <= maxContentWidth && currentLineWidth === 0) { + // Text fits completely on this line + currentLine.push({ text, props: segment.props }); + currentLineWidth += textWidth; + } else { + // Text needs truncation + const codePoints = toCodePoints(text); + let truncatedWidth = currentLineWidth; + let sliceEndIndex = 0; + + for (const char of codePoints) { + const charWidth = stringWidth(char); + if (truncatedWidth + charWidth > maxContentWidth) { + break; + } + truncatedWidth += charWidth; + sliceEndIndex++; + } + + const slice = codePoints.slice(0, sliceEndIndex).join(''); + if (slice) { + currentLine.push({ text: slice, props: segment.props }); + } + currentLine.push({ text: '…', props: {} }); + currentLineWidth = truncatedWidth + stringWidth('…'); + } + } + } + }); + } + + // Add the last line if it has content or if the last segment ended with \n + if ( + currentLine.length > 0 || + (nonWrappingContent.length > 0 && + nonWrappingContent[nonWrappingContent.length - 1].text.endsWith('\n')) + ) { + lines.push(currentLine); + } + + // If we don't have any lines yet, add an ellipsis line + if (lines.length === 0) { + lines.push([{ text: '…', props: {} }]); + } + + for (const line of lines) { + output.push(line); + } return; }