Jacob314/overflow notification and one MaxSizedBox bug fix (#1288)
This commit is contained in:
parent
e20171e7dd
commit
63f6a497cb
|
@ -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,7 +562,8 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
>
|
>
|
||||||
{(item) => item}
|
{(item) => item}
|
||||||
</Static>
|
</Static>
|
||||||
<Box ref={pendingHistoryItemRef}>
|
<OverflowProvider>
|
||||||
|
<Box ref={pendingHistoryItemRef} flexDirection="column">
|
||||||
{pendingHistoryItems.map((item, i) => (
|
{pendingHistoryItems.map((item, i) => (
|
||||||
<HistoryItemDisplay
|
<HistoryItemDisplay
|
||||||
key={i}
|
key={i}
|
||||||
|
@ -576,7 +579,10 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
isFocused={!isEditorDialogOpen}
|
isFocused={!isEditorDialogOpen}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||||
</Box>
|
</Box>
|
||||||
|
</OverflowProvider>
|
||||||
|
|
||||||
{showHelp && <Help commands={slashCommands} />}
|
{showHelp && <Help commands={slashCommands} />}
|
||||||
|
|
||||||
<Box flexDirection="column" ref={mainControlsRef}>
|
<Box flexDirection="column" ref={mainControlsRef}>
|
||||||
|
@ -700,6 +706,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{showErrorDetails && (
|
{showErrorDetails && (
|
||||||
|
<OverflowProvider>
|
||||||
<DetailedMessagesDisplay
|
<DetailedMessagesDisplay
|
||||||
messages={filteredConsoleMessages}
|
messages={filteredConsoleMessages}
|
||||||
maxHeight={
|
maxHeight={
|
||||||
|
@ -707,6 +714,8 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
}
|
}
|
||||||
width={inputWidth}
|
width={inputWidth}
|
||||||
/>
|
/>
|
||||||
|
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||||
|
</OverflowProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isInputActive && (
|
{isInputActive && (
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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(
|
||||||
|
<OverflowProvider>
|
||||||
<DiffRenderer
|
<DiffRenderer
|
||||||
diffContent={newFileDiffContent}
|
diffContent={newFileDiffContent}
|
||||||
filename="test.py"
|
filename="test.py"
|
||||||
terminalWidth={80}
|
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(
|
||||||
|
<OverflowProvider>
|
||||||
<DiffRenderer
|
<DiffRenderer
|
||||||
diffContent={newFileDiffContent}
|
diffContent={newFileDiffContent}
|
||||||
filename="test.unknown"
|
filename="test.unknown"
|
||||||
terminalWidth={80}
|
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(
|
||||||
|
<OverflowProvider>
|
||||||
<DiffRenderer
|
<DiffRenderer
|
||||||
diffContent={existingFileDiffContent}
|
diffContent={existingFileDiffContent}
|
||||||
filename="test.txt"
|
filename="test.txt"
|
||||||
terminalWidth={80}
|
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(
|
||||||
|
<OverflowProvider>
|
||||||
<DiffRenderer
|
<DiffRenderer
|
||||||
diffContent={noChangeDiff}
|
diffContent={noChangeDiff}
|
||||||
filename="file.txt"
|
filename="file.txt"
|
||||||
terminalWidth={80}
|
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(
|
||||||
|
<OverflowProvider>
|
||||||
<DiffRenderer
|
<DiffRenderer
|
||||||
diffContent={diffWithGap}
|
diffContent={diffWithGap}
|
||||||
filename="file.txt"
|
filename="file.txt"
|
||||||
terminalWidth={80}
|
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(
|
||||||
|
<OverflowProvider>
|
||||||
<DiffRenderer
|
<DiffRenderer
|
||||||
diffContent={diffWithSmallGap}
|
diffContent={diffWithSmallGap}
|
||||||
filename="file.txt"
|
filename="file.txt"
|
||||||
terminalWidth={80}
|
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(
|
||||||
|
<OverflowProvider>
|
||||||
<DiffRenderer
|
<DiffRenderer
|
||||||
diffContent={diffWithMultipleHunks}
|
diffContent={diffWithMultipleHunks}
|
||||||
filename="multi.js"
|
filename="multi.js"
|
||||||
terminalWidth={terminalWidth}
|
terminalWidth={terminalWidth}
|
||||||
availableTerminalHeight={height}
|
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(
|
||||||
|
<OverflowProvider>
|
||||||
<DiffRenderer
|
<DiffRenderer
|
||||||
diffContent={newFileDiff}
|
diffContent={newFileDiff}
|
||||||
filename="TEST"
|
filename="TEST"
|
||||||
terminalWidth={80}
|
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(
|
||||||
|
<OverflowProvider>
|
||||||
<DiffRenderer
|
<DiffRenderer
|
||||||
diffContent={newFileDiff}
|
diffContent={newFileDiff}
|
||||||
filename="Dockerfile"
|
filename="Dockerfile"
|
||||||
terminalWidth={80}
|
terminalWidth={80}
|
||||||
/>,
|
/>
|
||||||
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toEqual(`1 FROM node:14
|
expect(output).toEqual(`1 FROM node:14
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,17 +19,20 @@ describe('<MaxSizedBox />', () => {
|
||||||
|
|
||||||
it('renders children without truncation when they fit', () => {
|
it('renders children without truncation when they fit', () => {
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Hello, World!</Text>
|
<Text>Hello, World!</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</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(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={80} maxHeight={2}>
|
<MaxSizedBox maxWidth={80} maxHeight={2}>
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Line 1</Text>
|
<Text>Line 1</Text>
|
||||||
|
@ -39,7 +43,8 @@ describe('<MaxSizedBox />', () => {
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Line 3</Text>
|
<Text>Line 3</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</MaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
expect(lastFrame()).equals(`... first 2 lines hidden ...
|
expect(lastFrame()).equals(`... first 2 lines hidden ...
|
||||||
Line 3`);
|
Line 3`);
|
||||||
|
@ -47,6 +52,7 @@ 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(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
|
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Line 1</Text>
|
<Text>Line 1</Text>
|
||||||
|
@ -57,7 +63,8 @@ Line 3`);
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Line 3</Text>
|
<Text>Line 3</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</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(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={10} maxHeight={5}>
|
<MaxSizedBox maxWidth={10} maxHeight={5}>
|
||||||
<Box>
|
<Box>
|
||||||
<Text wrap="wrap">This is a long line of text</Text>
|
<Text wrap="wrap">This is a long line of text</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</MaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(lastFrame()).equals(`This is a
|
expect(lastFrame()).equals(`This is a
|
||||||
|
@ -82,6 +91,7 @@ of text`);
|
||||||
And has a line break.
|
And has a line break.
|
||||||
Leading spaces preserved.`;
|
Leading spaces preserved.`;
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={20} maxHeight={20}>
|
<MaxSizedBox maxWidth={20} maxHeight={20}>
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Example</Text>
|
<Text>Example</Text>
|
||||||
|
@ -94,7 +104,8 @@ And has a line break.
|
||||||
<Text>Longer No Wrap: </Text>
|
<Text>Longer No Wrap: </Text>
|
||||||
<Text wrap="wrap">This part will wrap around.</Text>
|
<Text wrap="wrap">This part will wrap around.</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</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(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
||||||
<Box>
|
<Box>
|
||||||
<Text wrap="wrap">Supercalifragilisticexpialidocious</Text>
|
<Text wrap="wrap">Supercalifragilisticexpialidocious</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</MaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(lastFrame()).equals(`... …
|
expect(lastFrame()).equals(`... …
|
||||||
|
@ -134,6 +147,7 @@ ious`);
|
||||||
|
|
||||||
it('does not truncate when maxHeight is undefined', () => {
|
it('does not truncate when maxHeight is undefined', () => {
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={80} maxHeight={undefined}>
|
<MaxSizedBox maxWidth={80} maxHeight={undefined}>
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Line 1</Text>
|
<Text>Line 1</Text>
|
||||||
|
@ -141,7 +155,8 @@ ious`);
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Line 2</Text>
|
<Text>Line 2</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</MaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
expect(lastFrame()).equals(`Line 1
|
expect(lastFrame()).equals(`Line 1
|
||||||
Line 2`);
|
Line 2`);
|
||||||
|
@ -149,6 +164,7 @@ 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(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={80} maxHeight={2}>
|
<MaxSizedBox maxWidth={80} maxHeight={2}>
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Line 1</Text>
|
<Text>Line 1</Text>
|
||||||
|
@ -159,7 +175,8 @@ Line 2`);
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Line 3</Text>
|
<Text>Line 3</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</MaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
expect(lastFrame()).equals(`... first 2 lines hidden ...
|
expect(lastFrame()).equals(`... first 2 lines hidden ...
|
||||||
Line 3`);
|
Line 3`);
|
||||||
|
@ -167,6 +184,7 @@ 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(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
|
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Line 1</Text>
|
<Text>Line 1</Text>
|
||||||
|
@ -177,7 +195,8 @@ Line 3`);
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Line 3</Text>
|
<Text>Line 3</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</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(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
||||||
<Box>
|
<Box>
|
||||||
<Text wrap="wrap">你好世界</Text>
|
<Text wrap="wrap">你好世界</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</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(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
||||||
<Box>
|
<Box>
|
||||||
<Text wrap="wrap">🐶🐶🐶🐶🐶</Text>
|
<Text wrap="wrap">🐶🐶🐶🐶🐶</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</MaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Each "🐶" has a visual width of 2.
|
// Each "🐶" has a visual width of 2.
|
||||||
|
@ -225,6 +250,7 @@ Line 3`);
|
||||||
|
|
||||||
it('accounts for additionalHiddenLinesCount', () => {
|
it('accounts for additionalHiddenLinesCount', () => {
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
|
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Line 1</Text>
|
<Text>Line 1</Text>
|
||||||
|
@ -235,7 +261,8 @@ Line 3`);
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Line 3</Text>
|
<Text>Line 3</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</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,6 +271,7 @@ Line 3`);
|
||||||
|
|
||||||
it('handles React.Fragment as a child', () => {
|
it('handles React.Fragment as a child', () => {
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||||
<>
|
<>
|
||||||
<Box>
|
<Box>
|
||||||
|
@ -256,7 +284,8 @@ Line 3`);
|
||||||
<Box>
|
<Box>
|
||||||
<Text>Line 3 direct child</Text>
|
<Text>Line 3 direct child</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</MaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
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(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||||
<Box>
|
<Box>
|
||||||
<Text>{THIRTY_LINES}</Text>
|
<Text>{THIRTY_LINES}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</MaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const expected = [
|
const expected = [
|
||||||
|
@ -292,11 +323,13 @@ Line 3 direct child`);
|
||||||
).join('\n');
|
).join('\n');
|
||||||
|
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
|
<OverflowProvider>
|
||||||
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
|
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
|
||||||
<Box>
|
<Box>
|
||||||
<Text>{THIRTY_LINES}</Text>
|
<Text>{THIRTY_LINES}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>,
|
</MaxSizedBox>
|
||||||
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const expected = [
|
const expected = [
|
||||||
|
|
|
@ -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,19 +115,13 @@ 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.
|
||||||
if (maxHeight === undefined) {
|
let targetMaxHeight;
|
||||||
return (
|
if (maxHeight !== undefined) {
|
||||||
<Box width={maxWidth} height={maxHeight} flexDirection="column">
|
targetMaxHeight = Math.max(Math.round(maxHeight), MINIMUM_MAX_HEIGHT);
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxWidth === undefined) {
|
if (maxWidth === undefined) {
|
||||||
throw new Error('maxWidth must be defined when maxHeight is set.');
|
throw new Error('maxWidth must be defined when maxHeight is set.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const laidOutStyledText: StyledText[][] = [];
|
|
||||||
function visitRows(element: React.ReactNode) {
|
function visitRows(element: React.ReactNode) {
|
||||||
if (!React.isValidElement(element)) {
|
if (!React.isValidElement(element)) {
|
||||||
return;
|
return;
|
||||||
|
@ -133,24 +139,47 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
React.Children.forEach(children, visitRows);
|
React.Children.forEach(children, visitRows);
|
||||||
|
}
|
||||||
|
|
||||||
const contentWillOverflow =
|
const contentWillOverflow =
|
||||||
(laidOutStyledText.length > maxHeight && maxHeight > 0) ||
|
(targetMaxHeight !== undefined &&
|
||||||
|
laidOutStyledText.length > targetMaxHeight) ||
|
||||||
additionalHiddenLinesCount > 0;
|
additionalHiddenLinesCount > 0;
|
||||||
const visibleContentHeight = contentWillOverflow ? maxHeight - 1 : maxHeight;
|
const visibleContentHeight =
|
||||||
|
contentWillOverflow && targetMaxHeight !== undefined
|
||||||
|
? targetMaxHeight - 1
|
||||||
|
: targetMaxHeight;
|
||||||
|
|
||||||
const hiddenLinesCount = Math.max(
|
const hiddenLinesCount =
|
||||||
0,
|
visibleContentHeight !== undefined
|
||||||
laidOutStyledText.length - visibleContentHeight,
|
? Math.max(0, laidOutStyledText.length - visibleContentHeight)
|
||||||
);
|
: 0;
|
||||||
const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
|
const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (totalHiddenLines > 0) {
|
||||||
|
addOverflowingId?.(id);
|
||||||
|
} else {
|
||||||
|
removeOverflowingId?.(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeOverflowingId?.(id);
|
||||||
|
};
|
||||||
|
}, [id, totalHiddenLines, addOverflowingId, removeOverflowingId]);
|
||||||
|
|
||||||
|
if (maxHeight === undefined) {
|
||||||
|
return (
|
||||||
|
<Box width={maxWidth} height={maxHeight} flexDirection="column">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue