buroventures-harald-code
Version:
Harald Code - AI-powered coding assistant CLI
451 lines • 19.4 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { Fragment, useEffect, useId } from 'react';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { Colors } from '../../colors.js';
import { toCodePoints } from '../../utils/textUtils.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
let enableDebugLog = false;
/**
* Minimum height for the MaxSizedBox component.
* This ensures there is room for at least one line of content as well as the
* message that content was truncated.
*/
export const MINIMUM_MAX_HEIGHT = 2;
export function setMaxSizedBoxDebugging(value) {
enableDebugLog = value;
}
function debugReportError(message, element) {
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;
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}`);
}
/**
* 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 = ({ children, maxWidth, maxHeight, overflowDirection = 'top', additionalHiddenLinesCount = 0, }) => {
const id = useId();
const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {};
const laidOutStyledText = [];
const targetMaxHeight = Math.max(Math.round(maxHeight ?? Number.MAX_SAFE_INTEGER), MINIMUM_MAX_HEIGHT);
if (maxWidth === undefined) {
throw new Error('maxWidth must be defined when maxHeight is set.');
}
function visitRows(element) {
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 = (targetMaxHeight !== undefined &&
laidOutStyledText.length > targetMaxHeight) ||
additionalHiddenLinesCount > 0;
const visibleContentHeight = contentWillOverflow && targetMaxHeight !== undefined
? targetMaxHeight - 1
: targetMaxHeight;
const hiddenLinesCount = visibleContentHeight !== undefined
? Math.max(0, laidOutStyledText.length - visibleContentHeight)
: 0;
const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
useEffect(() => {
if (totalHiddenLines > 0) {
addOverflowingId?.(id);
}
else {
removeOverflowingId?.(id);
}
return () => {
removeOverflowingId?.(id);
};
}, [id, totalHiddenLines, addOverflowingId, removeOverflowingId]);
const visibleStyledText = hiddenLinesCount > 0
? overflowDirection === 'top'
? laidOutStyledText.slice(hiddenLinesCount, laidOutStyledText.length)
: laidOutStyledText.slice(0, visibleContentHeight)
: laidOutStyledText;
const visibleLines = visibleStyledText.map((line, index) => (_jsx(Box, { children: line.length > 0 ? (line.map((segment, segIndex) => (_jsx(Text, { ...segment.props, children: segment.text }, segIndex)))) : (_jsx(Text, { children: " " })) }, index)));
return (_jsxs(Box, { flexDirection: "column", width: maxWidth, flexShrink: 0, children: [totalHiddenLines > 0 && overflowDirection === 'top' && (_jsxs(Text, { color: Colors.Gray, wrap: "truncate", children: ["... first ", totalHiddenLines, " line", totalHiddenLines === 1 ? '' : 's', ' ', "hidden ..."] })), visibleLines, totalHiddenLines > 0 && overflowDirection === 'bottom' && (_jsxs(Text, { color: Colors.Gray, wrap: "truncate", children: ["... last ", totalHiddenLines, " line", totalHiddenLines === 1 ? '' : 's', ' ', "hidden ..."] }))] }));
};
/**
* 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) {
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 !== undefined &&
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 = {
noWrapSegments: [],
segments: [],
};
let hasSeenWrapped = false;
function visitRowChild(element, parentProps) {
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 = { 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 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) {
React.Children.forEach(element.props.children, (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, maxWidth, output) {
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 = [];
const nonWrappingContent = [];
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 = [];
let currentLine = [];
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);
}
for (const line of lines) {
output.push(line);
}
return;
}
const availableWidth = maxWidth - noWrappingWidth;
if (availableWidth < 1) {
// 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 = [];
let currentLine = [];
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;
}
// Now, lay out the wrapping segments
let wrappingPart = [];
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, props) {
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();
}
for (const line of lines) {
output.push(line);
}
}
//# sourceMappingURL=MaxSizedBox.js.map