Jacob314/overflow notification and one MaxSizedBox bug fix (#1288)

This commit is contained in:
Jacob Richman 2025-06-22 00:54:10 +00:00 committed by GitHub
parent e20171e7dd
commit 63f6a497cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 457 additions and 243 deletions

View File

@ -68,6 +68,8 @@ import * as fs from 'fs';
import { UpdateNotification } from './components/UpdateNotification.js'; import { UpdateNotification } from './components/UpdateNotification.js';
import { checkForUpdates } from './utils/updateCheck.js'; import { checkForUpdates } from './utils/updateCheck.js';
import ansiEscapes from 'ansi-escapes'; import ansiEscapes from 'ansi-escapes';
import { OverflowProvider } from './contexts/OverflowContext.js';
import { ShowMoreLines } from './components/ShowMoreLines.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000; const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@ -560,23 +562,27 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
> >
{(item) => item} {(item) => item}
</Static> </Static>
<Box ref={pendingHistoryItemRef}> <OverflowProvider>
{pendingHistoryItems.map((item, i) => ( <Box ref={pendingHistoryItemRef} flexDirection="column">
<HistoryItemDisplay {pendingHistoryItems.map((item, i) => (
key={i} <HistoryItemDisplay
availableTerminalHeight={ key={i}
constrainHeight ? availableTerminalHeight : undefined availableTerminalHeight={
} constrainHeight ? availableTerminalHeight : undefined
terminalWidth={mainAreaWidth} }
// TODO(taehykim): It seems like references to ids aren't necessary in terminalWidth={mainAreaWidth}
// HistoryItemDisplay. Refactor later. Use a fake id for now. // TODO(taehykim): It seems like references to ids aren't necessary in
item={{ ...item, id: 0 }} // HistoryItemDisplay. Refactor later. Use a fake id for now.
isPending={true} item={{ ...item, id: 0 }}
config={config} isPending={true}
isFocused={!isEditorDialogOpen} config={config}
/> isFocused={!isEditorDialogOpen}
))} />
</Box> ))}
<ShowMoreLines constrainHeight={constrainHeight} />
</Box>
</OverflowProvider>
{showHelp && <Help commands={slashCommands} />} {showHelp && <Help commands={slashCommands} />}
<Box flexDirection="column" ref={mainControlsRef}> <Box flexDirection="column" ref={mainControlsRef}>
@ -700,13 +706,16 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
</Box> </Box>
{showErrorDetails && ( {showErrorDetails && (
<DetailedMessagesDisplay <OverflowProvider>
messages={filteredConsoleMessages} <DetailedMessagesDisplay
maxHeight={ messages={filteredConsoleMessages}
constrainHeight ? debugConsoleMaxHeight : undefined maxHeight={
} constrainHeight ? debugConsoleMaxHeight : undefined
width={inputWidth} }
/> width={inputWidth}
/>
<ShowMoreLines constrainHeight={constrainHeight} />
</OverflowProvider>
)} )}
{isInputActive && ( {isInputActive && (

View File

@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useOverflowState } from '../contexts/OverflowContext.js';
import { Colors } from '../colors.js';
interface ShowMoreLinesProps {
constrainHeight: boolean;
}
export const ShowMoreLines = ({ constrainHeight }: ShowMoreLinesProps) => {
const overflowState = useOverflowState();
if (
overflowState === undefined ||
overflowState.overflowingIds.size === 0 ||
!constrainHeight
) {
return null;
}
return (
<Box>
<Text color={Colors.Gray} wrap="truncate">
Press Ctrl-S to show more lines
</Text>
</Box>
);
};

View File

@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { render } from 'ink-testing-library'; import { render } from 'ink-testing-library';
import { DiffRenderer } from './DiffRenderer.js'; import { DiffRenderer } from './DiffRenderer.js';
import * as CodeColorizer from '../../utils/CodeColorizer.js'; import * as CodeColorizer from '../../utils/CodeColorizer.js';
import { vi } from 'vitest'; import { vi } from 'vitest';
describe('<DiffRenderer />', () => { describe('<OverflowProvider><DiffRenderer /></OverflowProvider>', () => {
const mockColorizeCode = vi.spyOn(CodeColorizer, 'colorizeCode'); const mockColorizeCode = vi.spyOn(CodeColorizer, 'colorizeCode');
beforeEach(() => { beforeEach(() => {
@ -30,11 +31,13 @@ index 0000000..e69de29
+print("hello world") +print("hello world")
`; `;
render( render(
<DiffRenderer <OverflowProvider>
diffContent={newFileDiffContent} <DiffRenderer
filename="test.py" diffContent={newFileDiffContent}
terminalWidth={80} filename="test.py"
/>, terminalWidth={80}
/>
</OverflowProvider>,
); );
expect(mockColorizeCode).toHaveBeenCalledWith( expect(mockColorizeCode).toHaveBeenCalledWith(
'print("hello world")', 'print("hello world")',
@ -55,11 +58,13 @@ index 0000000..e69de29
+some content +some content
`; `;
render( render(
<DiffRenderer <OverflowProvider>
diffContent={newFileDiffContent} <DiffRenderer
filename="test.unknown" diffContent={newFileDiffContent}
terminalWidth={80} filename="test.unknown"
/>, terminalWidth={80}
/>
</OverflowProvider>,
); );
expect(mockColorizeCode).toHaveBeenCalledWith( expect(mockColorizeCode).toHaveBeenCalledWith(
'some content', 'some content',
@ -80,7 +85,9 @@ index 0000000..e69de29
+some text content +some text content
`; `;
render( render(
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />, <OverflowProvider>
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
</OverflowProvider>,
); );
expect(mockColorizeCode).toHaveBeenCalledWith( expect(mockColorizeCode).toHaveBeenCalledWith(
'some text content', 'some text content',
@ -101,11 +108,13 @@ index 0000001..0000002 100644
+new line +new line
`; `;
const { lastFrame } = render( const { lastFrame } = render(
<DiffRenderer <OverflowProvider>
diffContent={existingFileDiffContent} <DiffRenderer
filename="test.txt" diffContent={existingFileDiffContent}
terminalWidth={80} filename="test.txt"
/>, terminalWidth={80}
/>
</OverflowProvider>,
); );
// colorizeCode is used internally by the line-by-line rendering, not for the whole block // colorizeCode is used internally by the line-by-line rendering, not for the whole block
expect(mockColorizeCode).not.toHaveBeenCalledWith( expect(mockColorizeCode).not.toHaveBeenCalledWith(
@ -129,11 +138,13 @@ index 1234567..1234567 100644
+++ b/file.txt +++ b/file.txt
`; `;
const { lastFrame } = render( const { lastFrame } = render(
<DiffRenderer <OverflowProvider>
diffContent={noChangeDiff} <DiffRenderer
filename="file.txt" diffContent={noChangeDiff}
terminalWidth={80} filename="file.txt"
/>, terminalWidth={80}
/>
</OverflowProvider>,
); );
expect(lastFrame()).toContain('No changes detected'); expect(lastFrame()).toContain('No changes detected');
expect(mockColorizeCode).not.toHaveBeenCalled(); expect(mockColorizeCode).not.toHaveBeenCalled();
@ -141,7 +152,9 @@ index 1234567..1234567 100644
it('should handle empty diff content', () => { it('should handle empty diff content', () => {
const { lastFrame } = render( const { lastFrame } = render(
<DiffRenderer diffContent="" terminalWidth={80} />, <OverflowProvider>
<DiffRenderer diffContent="" terminalWidth={80} />
</OverflowProvider>,
); );
expect(lastFrame()).toContain('No diff content'); expect(lastFrame()).toContain('No diff content');
expect(mockColorizeCode).not.toHaveBeenCalled(); expect(mockColorizeCode).not.toHaveBeenCalled();
@ -162,11 +175,13 @@ index 123..456 100644
context line 11 context line 11
`; `;
const { lastFrame } = render( const { lastFrame } = render(
<DiffRenderer <OverflowProvider>
diffContent={diffWithGap} <DiffRenderer
filename="file.txt" diffContent={diffWithGap}
terminalWidth={80} filename="file.txt"
/>, terminalWidth={80}
/>
</OverflowProvider>,
); );
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('═'); // Check for the border character used in the gap expect(output).toContain('═'); // Check for the border character used in the gap
@ -197,11 +212,13 @@ index abc..def 100644
context line 15 context line 15
`; `;
const { lastFrame } = render( const { lastFrame } = render(
<DiffRenderer <OverflowProvider>
diffContent={diffWithSmallGap} <DiffRenderer
filename="file.txt" diffContent={diffWithSmallGap}
terminalWidth={80} filename="file.txt"
/>, terminalWidth={80}
/>
</OverflowProvider>,
); );
const output = lastFrame(); const output = lastFrame();
expect(output).not.toContain('═'); // Ensure no separator is rendered expect(output).not.toContain('═'); // Ensure no separator is rendered
@ -267,12 +284,14 @@ index 123..789 100644
'with terminalWidth $terminalWidth and height $height', 'with terminalWidth $terminalWidth and height $height',
({ terminalWidth, height, expected }) => { ({ terminalWidth, height, expected }) => {
const { lastFrame } = render( const { lastFrame } = render(
<DiffRenderer <OverflowProvider>
diffContent={diffWithMultipleHunks} <DiffRenderer
filename="multi.js" diffContent={diffWithMultipleHunks}
terminalWidth={terminalWidth} filename="multi.js"
availableTerminalHeight={height} terminalWidth={terminalWidth}
/>, availableTerminalHeight={height}
/>
</OverflowProvider>,
); );
const output = lastFrame(); const output = lastFrame();
expect(sanitizeOutput(output, terminalWidth)).toEqual(expected); expect(sanitizeOutput(output, terminalWidth)).toEqual(expected);
@ -297,11 +316,13 @@ fileDiff Index: file.txt
\\ No newline at end of file \\ No newline at end of file
`; `;
const { lastFrame } = render( const { lastFrame } = render(
<DiffRenderer <OverflowProvider>
diffContent={newFileDiff} <DiffRenderer
filename="TEST" diffContent={newFileDiff}
terminalWidth={80} filename="TEST"
/>, terminalWidth={80}
/>
</OverflowProvider>,
); );
const output = lastFrame(); const output = lastFrame();
@ -325,11 +346,13 @@ fileDiff Index: Dockerfile
\\ No newline at end of file \\ No newline at end of file
`; `;
const { lastFrame } = render( const { lastFrame } = render(
<DiffRenderer <OverflowProvider>
diffContent={newFileDiff} <DiffRenderer
filename="Dockerfile" diffContent={newFileDiff}
terminalWidth={80} filename="Dockerfile"
/>, terminalWidth={80}
/>
</OverflowProvider>,
); );
const output = lastFrame(); const output = lastFrame();
expect(output).toEqual(`1 FROM node:14 expect(output).toEqual(`1 FROM node:14

View File

@ -77,7 +77,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
marginLeft={1} marginLeft={1}
borderDimColor={hasPending} borderDimColor={hasPending}
borderColor={borderColor} borderColor={borderColor}
marginBottom={1}
> >
{toolCalls.map((tool) => { {toolCalls.map((tool) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId; const isConfirming = toolAwaitingApproval?.callId === tool.callId;

View File

@ -5,6 +5,7 @@
*/ */
import { render } from 'ink-testing-library'; import { render } from 'ink-testing-library';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { MaxSizedBox, setMaxSizedBoxDebugging } from './MaxSizedBox.js'; import { MaxSizedBox, setMaxSizedBoxDebugging } from './MaxSizedBox.js';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
@ -18,28 +19,32 @@ describe('<MaxSizedBox />', () => {
it('renders children without truncation when they fit', () => { it('renders children without truncation when they fit', () => {
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={10}> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={80} maxHeight={10}>
<Text>Hello, World!</Text> <Box>
</Box> <Text>Hello, World!</Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
expect(lastFrame()).equals('Hello, World!'); expect(lastFrame()).equals('Hello, World!');
}); });
it('hides lines when content exceeds maxHeight', () => { it('hides lines when content exceeds maxHeight', () => {
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={2}> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={80} maxHeight={2}>
<Text>Line 1</Text> <Box>
</Box> <Text>Line 1</Text>
<Box> </Box>
<Text>Line 2</Text> <Box>
</Box> <Text>Line 2</Text>
<Box> </Box>
<Text>Line 3</Text> <Box>
</Box> <Text>Line 3</Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
expect(lastFrame()).equals(`... first 2 lines hidden ... expect(lastFrame()).equals(`... first 2 lines hidden ...
Line 3`); Line 3`);
@ -47,17 +52,19 @@ Line 3`);
it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', () => { it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', () => {
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom"> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
<Text>Line 1</Text> <Box>
</Box> <Text>Line 1</Text>
<Box> </Box>
<Text>Line 2</Text> <Box>
</Box> <Text>Line 2</Text>
<Box> </Box>
<Text>Line 3</Text> <Box>
</Box> <Text>Line 3</Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
expect(lastFrame()).equals(`Line 1 expect(lastFrame()).equals(`Line 1
... last 2 lines hidden ...`); ... last 2 lines hidden ...`);
@ -65,11 +72,13 @@ Line 3`);
it('wraps text that exceeds maxWidth', () => { it('wraps text that exceeds maxWidth', () => {
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={10} maxHeight={5}> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={10} maxHeight={5}>
<Text wrap="wrap">This is a long line of text</Text> <Box>
</Box> <Text wrap="wrap">This is a long line of text</Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
expect(lastFrame()).equals(`This is a expect(lastFrame()).equals(`This is a
@ -82,19 +91,21 @@ of text`);
And has a line break. And has a line break.
Leading spaces preserved.`; Leading spaces preserved.`;
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={20} maxHeight={20}> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={20} maxHeight={20}>
<Text>Example</Text> <Box>
</Box> <Text>Example</Text>
<Box> </Box>
<Text>No Wrap: </Text> <Box>
<Text wrap="wrap">{multilineText}</Text> <Text>No Wrap: </Text>
</Box> <Text wrap="wrap">{multilineText}</Text>
<Box> </Box>
<Text>Longer No Wrap: </Text> <Box>
<Text wrap="wrap">This part will wrap around.</Text> <Text>Longer No Wrap: </Text>
</Box> <Text wrap="wrap">This part will wrap around.</Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
expect(lastFrame()).equals( expect(lastFrame()).equals(
@ -118,11 +129,13 @@ Longer No Wrap: This
it('handles words longer than maxWidth by splitting them', () => { it('handles words longer than maxWidth by splitting them', () => {
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={5} maxHeight={5}> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={5} maxHeight={5}>
<Text wrap="wrap">Supercalifragilisticexpialidocious</Text> <Box>
</Box> <Text wrap="wrap">Supercalifragilisticexpialidocious</Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
expect(lastFrame()).equals(`... … expect(lastFrame()).equals(`... …
@ -134,14 +147,16 @@ ious`);
it('does not truncate when maxHeight is undefined', () => { it('does not truncate when maxHeight is undefined', () => {
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={undefined}> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={80} maxHeight={undefined}>
<Text>Line 1</Text> <Box>
</Box> <Text>Line 1</Text>
<Box> </Box>
<Text>Line 2</Text> <Box>
</Box> <Text>Line 2</Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
expect(lastFrame()).equals(`Line 1 expect(lastFrame()).equals(`Line 1
Line 2`); Line 2`);
@ -149,17 +164,19 @@ Line 2`);
it('shows plural "lines" when more than one line is hidden', () => { it('shows plural "lines" when more than one line is hidden', () => {
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={2}> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={80} maxHeight={2}>
<Text>Line 1</Text> <Box>
</Box> <Text>Line 1</Text>
<Box> </Box>
<Text>Line 2</Text> <Box>
</Box> <Text>Line 2</Text>
<Box> </Box>
<Text>Line 3</Text> <Box>
</Box> <Text>Line 3</Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
expect(lastFrame()).equals(`... first 2 lines hidden ... expect(lastFrame()).equals(`... first 2 lines hidden ...
Line 3`); Line 3`);
@ -167,17 +184,19 @@ Line 3`);
it('shows plural "lines" when more than one line is hidden and overflowDirection is bottom', () => { it('shows plural "lines" when more than one line is hidden and overflowDirection is bottom', () => {
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom"> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
<Text>Line 1</Text> <Box>
</Box> <Text>Line 1</Text>
<Box> </Box>
<Text>Line 2</Text> <Box>
</Box> <Text>Line 2</Text>
<Box> </Box>
<Text>Line 3</Text> <Box>
</Box> <Text>Line 3</Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
expect(lastFrame()).equals(`Line 1 expect(lastFrame()).equals(`Line 1
... last 2 lines hidden ...`); ... last 2 lines hidden ...`);
@ -185,7 +204,9 @@ Line 3`);
it('renders an empty box for empty children', () => { it('renders an empty box for empty children', () => {
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>, <OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>
</OverflowProvider>,
); );
// Expect an empty string or a box with nothing in it. // Expect an empty string or a box with nothing in it.
// Ink renders an empty box as an empty string. // Ink renders an empty box as an empty string.
@ -194,11 +215,13 @@ Line 3`);
it('wraps text with multi-byte unicode characters correctly', () => { it('wraps text with multi-byte unicode characters correctly', () => {
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={5} maxHeight={5}> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={5} maxHeight={5}>
<Text wrap="wrap"></Text> <Box>
</Box> <Text wrap="wrap"></Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
// "你好" has a visual width of 4. "世界" has a visual width of 4. // "你好" has a visual width of 4. "世界" has a visual width of 4.
@ -209,11 +232,13 @@ Line 3`);
it('wraps text with multi-byte emoji characters correctly', () => { it('wraps text with multi-byte emoji characters correctly', () => {
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={5} maxHeight={5}> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={5} maxHeight={5}>
<Text wrap="wrap">🐶🐶🐶🐶🐶</Text> <Box>
</Box> <Text wrap="wrap">🐶🐶🐶🐶🐶</Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
// Each "🐶" has a visual width of 2. // Each "🐶" has a visual width of 2.
@ -225,17 +250,19 @@ Line 3`);
it('accounts for additionalHiddenLinesCount', () => { it('accounts for additionalHiddenLinesCount', () => {
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
<Text>Line 1</Text> <Box>
</Box> <Text>Line 1</Text>
<Box> </Box>
<Text>Line 2</Text> <Box>
</Box> <Text>Line 2</Text>
<Box> </Box>
<Text>Line 3</Text> <Box>
</Box> <Text>Line 3</Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
// 1 line is hidden by overflow, 5 are additionally hidden. // 1 line is hidden by overflow, 5 are additionally hidden.
expect(lastFrame()).equals(`... first 7 lines hidden ... expect(lastFrame()).equals(`... first 7 lines hidden ...
@ -244,19 +271,21 @@ Line 3`);
it('handles React.Fragment as a child', () => { it('handles React.Fragment as a child', () => {
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={10}> <OverflowProvider>
<> <MaxSizedBox maxWidth={80} maxHeight={10}>
<>
<Box>
<Text>Line 1 from Fragment</Text>
</Box>
<Box>
<Text>Line 2 from Fragment</Text>
</Box>
</>
<Box> <Box>
<Text>Line 1 from Fragment</Text> <Text>Line 3 direct child</Text>
</Box> </Box>
<Box> </MaxSizedBox>
<Text>Line 2 from Fragment</Text> </OverflowProvider>,
</Box>
</>
<Box>
<Text>Line 3 direct child</Text>
</Box>
</MaxSizedBox>,
); );
expect(lastFrame()).equals(`Line 1 from Fragment expect(lastFrame()).equals(`Line 1 from Fragment
Line 2 from Fragment Line 2 from Fragment
@ -270,11 +299,13 @@ Line 3 direct child`);
).join('\n'); ).join('\n');
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={10}> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={80} maxHeight={10}>
<Text>{THIRTY_LINES}</Text> <Box>
</Box> <Text>{THIRTY_LINES}</Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
const expected = [ const expected = [
@ -292,11 +323,13 @@ Line 3 direct child`);
).join('\n'); ).join('\n');
const { lastFrame } = render( const { lastFrame } = render(
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom"> <OverflowProvider>
<Box> <MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
<Text>{THIRTY_LINES}</Text> <Box>
</Box> <Text>{THIRTY_LINES}</Text>
</MaxSizedBox>, </Box>
</MaxSizedBox>
</OverflowProvider>,
); );
const expected = [ const expected = [

View File

@ -4,14 +4,22 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React, { Fragment } from 'react'; import React, { Fragment, useEffect, useId } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import stringWidth from 'string-width'; import stringWidth from 'string-width';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
import { toCodePoints } from '../../utils/textUtils.js'; import { toCodePoints } from '../../utils/textUtils.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
let enableDebugLog = false; 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: boolean) { export function setMaxSizedBoxDebugging(value: boolean) {
enableDebugLog = value; enableDebugLog = value;
} }
@ -95,6 +103,10 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
overflowDirection = 'top', overflowDirection = 'top',
additionalHiddenLinesCount = 0, additionalHiddenLinesCount = 0,
}) => { }) => {
const id = useId();
const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {};
const laidOutStyledText: StyledText[][] = [];
// When maxHeight is not set, we render the content normally rather // When maxHeight is not set, we render the content normally rather
// than using our custom layout logic. This should slightly improve // than using our custom layout logic. This should slightly improve
// performance for the case where there is no height limit and is // performance for the case where there is no height limit and is
@ -103,6 +115,59 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
// In the future we might choose to still apply our layout logic // In the future we might choose to still apply our layout logic
// even in this case particularlly if there are cases where we // even in this case particularlly if there are cases where we
// intentionally diverse how certain layouts are rendered. // intentionally diverse how certain layouts are rendered.
let targetMaxHeight;
if (maxHeight !== undefined) {
targetMaxHeight = Math.max(Math.round(maxHeight), MINIMUM_MAX_HEIGHT);
if (maxWidth === undefined) {
throw new Error('maxWidth must be defined when maxHeight is set.');
}
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 =
(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]);
if (maxHeight === undefined) { if (maxHeight === undefined) {
return ( return (
<Box width={maxWidth} height={maxHeight} flexDirection="column"> <Box width={maxWidth} height={maxHeight} flexDirection="column">
@ -111,46 +176,10 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
); );
} }
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 = const visibleStyledText =
hiddenLinesCount > 0 hiddenLinesCount > 0
? overflowDirection === 'top' ? overflowDirection === 'top'
? laidOutStyledText.slice( ? laidOutStyledText.slice(hiddenLinesCount, laidOutStyledText.length)
laidOutStyledText.length - visibleContentHeight,
)
: laidOutStyledText.slice(0, visibleContentHeight) : laidOutStyledText.slice(0, visibleContentHeight)
: laidOutStyledText; : laidOutStyledText;

View File

@ -0,0 +1,87 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
} from 'react';
interface OverflowState {
overflowingIds: ReadonlySet<string>;
}
interface OverflowActions {
addOverflowingId: (id: string) => void;
removeOverflowingId: (id: string) => void;
}
const OverflowStateContext = createContext<OverflowState | undefined>(
undefined,
);
const OverflowActionsContext = createContext<OverflowActions | undefined>(
undefined,
);
export const useOverflowState = (): OverflowState | undefined =>
useContext(OverflowStateContext);
export const useOverflowActions = (): OverflowActions | undefined =>
useContext(OverflowActionsContext);
export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [overflowingIds, setOverflowingIds] = useState(new Set<string>());
const addOverflowingId = useCallback((id: string) => {
setOverflowingIds((prevIds) => {
if (prevIds.has(id)) {
return prevIds;
}
const newIds = new Set(prevIds);
newIds.add(id);
return newIds;
});
}, []);
const removeOverflowingId = useCallback((id: string) => {
setOverflowingIds((prevIds) => {
if (!prevIds.has(id)) {
return prevIds;
}
const newIds = new Set(prevIds);
newIds.delete(id);
return newIds;
});
}, []);
const stateValue = useMemo(
() => ({
overflowingIds,
}),
[overflowingIds],
);
const actionsValue = useMemo(
() => ({
addOverflowingId,
removeOverflowingId,
}),
[addOverflowingId, removeOverflowingId],
);
return (
<OverflowStateContext.Provider value={stateValue}>
<OverflowActionsContext.Provider value={actionsValue}>
{children}
</OverflowActionsContext.Provider>
</OverflowStateContext.Provider>
);
};

View File

@ -16,7 +16,10 @@ import type {
} from 'hast'; } from 'hast';
import { themeManager } from '../themes/theme-manager.js'; import { themeManager } from '../themes/theme-manager.js';
import { Theme } from '../themes/theme.js'; import { Theme } from '../themes/theme.js';
import { MaxSizedBox } from '../components/shared/MaxSizedBox.js'; import {
MaxSizedBox,
MINIMUM_MAX_HEIGHT,
} from '../components/shared/MaxSizedBox.js';
// Configure themeing and parsing utilities. // Configure themeing and parsing utilities.
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
@ -85,8 +88,6 @@ function renderHastNode(
return null; return null;
} }
const RESERVED_LINES_FOR_TRUNCATION_MESSAGE = 2;
/** /**
* Renders syntax-highlighted code for Ink applications using a selected theme. * Renders syntax-highlighted code for Ink applications using a selected theme.
* *
@ -111,11 +112,11 @@ export function colorizeCode(
let hiddenLinesCount = 0; let hiddenLinesCount = 0;
// Optimizaiton to avoid highlighting lines that cannot possibly be displayed. // Optimization to avoid highlighting lines that cannot possibly be displayed.
if (availableHeight && lines.length > availableHeight) { if (availableHeight !== undefined) {
const sliceIndex = availableHeight = Math.max(availableHeight, MINIMUM_MAX_HEIGHT);
lines.length - availableHeight + RESERVED_LINES_FOR_TRUNCATION_MESSAGE; if (lines.length > availableHeight) {
if (sliceIndex > 0) { const sliceIndex = lines.length - availableHeight;
hiddenLinesCount = sliceIndex; hiddenLinesCount = sliceIndex;
lines = lines.slice(sliceIndex); lines = lines.slice(sliceIndex);
} }