/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useCallback, useEffect, useId, useRef, useState } from 'react'; import { Box, Text, ResizeObserver, type DOMElement } from 'ink'; import { theme } from '../../semantic-colors.js'; import { useOverflowActions } from '../../contexts/OverflowContext.js'; import { isNarrowWidth } from '../../utils/isNarrowWidth.js'; import { Command } from '../../key/keyBindings.js'; import { formatCommand } from '../../key/keybindingUtils.js'; /** * 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 interface MaxSizedBoxProps { children?: React.ReactNode; maxWidth?: number; maxHeight?: number; 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`. */ export const MaxSizedBox: React.FC = ({ children, maxWidth, maxHeight, overflowDirection = 'top', additionalHiddenLinesCount = 0, }) => { const id = useId(); const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {}; const observerRef = useRef(null); const [contentHeight, setContentHeight] = useState(0); const onRefChange = useCallback( (node: DOMElement | null) => { if (observerRef.current) { observerRef.current.disconnect(); observerRef.current = null; } if (node && maxHeight !== undefined) { const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) { setContentHeight(Math.round(entry.contentRect.height)); } }); observer.observe(node); observerRef.current = observer; } }, [maxHeight], ); const effectiveMaxHeight = maxHeight !== undefined ? Math.max(Math.round(maxHeight), MINIMUM_MAX_HEIGHT) : undefined; const isOverflowing = (effectiveMaxHeight !== undefined && contentHeight > effectiveMaxHeight) || additionalHiddenLinesCount > 0; // If we're overflowing, we need to hide at least 1 line for the message. const visibleContentHeight = isOverflowing && effectiveMaxHeight !== undefined ? effectiveMaxHeight - 1 : effectiveMaxHeight; const hiddenLinesCount = visibleContentHeight !== undefined ? Math.max(0, contentHeight - visibleContentHeight) : 0; const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount; const isNarrow = maxWidth !== undefined && isNarrowWidth(maxWidth); const showMoreKey = formatCommand(Command.SHOW_MORE_LINES); useEffect(() => { if (totalHiddenLines > 0) { addOverflowingId?.(id); } else { removeOverflowingId?.(id); } }, [id, totalHiddenLines, addOverflowingId, removeOverflowingId]); useEffect( () => () => { removeOverflowingId?.(id); }, [id, removeOverflowingId], ); if (effectiveMaxHeight === undefined) { return ( {children} ); } const offset = hiddenLinesCount > 0 && overflowDirection === 'top' ? -hiddenLinesCount : 0; return ( {totalHiddenLines > 0 && overflowDirection === 'top' && ( {isNarrow ? `... ${totalHiddenLines} hidden (${showMoreKey}) ...` : `... first ${totalHiddenLines} line${totalHiddenLines === 1 ? '' : 's'} hidden (${showMoreKey} to show) ...`} )} {children} {totalHiddenLines > 0 && overflowDirection === 'bottom' && ( {isNarrow ? `... ${totalHiddenLines} hidden (${showMoreKey}) ...` : `... last ${totalHiddenLines} line${totalHiddenLines === 1 ? '' : 's'} hidden (${showMoreKey} to show) ...`} )} ); };