UNPKG

buroventures-harald-code

Version:

Harald Code - AI-powered coding assistant CLI

451 lines 19.4 kB
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