diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index a5e6f361..48d045e3 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -68,6 +68,8 @@ import * as fs from 'fs'; import { UpdateNotification } from './components/UpdateNotification.js'; import { checkForUpdates } from './utils/updateCheck.js'; import ansiEscapes from 'ansi-escapes'; +import { OverflowProvider } from './contexts/OverflowContext.js'; +import { ShowMoreLines } from './components/ShowMoreLines.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -560,23 +562,27 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { > {(item) => item} - - {pendingHistoryItems.map((item, i) => ( - - ))} - + + + {pendingHistoryItems.map((item, i) => ( + + ))} + + + + {showHelp && } @@ -700,13 +706,16 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { {showErrorDetails && ( - + + + + )} {isInputActive && ( diff --git a/packages/cli/src/ui/components/ShowMoreLines.tsx b/packages/cli/src/ui/components/ShowMoreLines.tsx new file mode 100644 index 00000000..bfcefcbf --- /dev/null +++ b/packages/cli/src/ui/components/ShowMoreLines.tsx @@ -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 ( + + + Press Ctrl-S to show more lines + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx index 52152f55..a6f906a6 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx @@ -4,12 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { OverflowProvider } from '../../contexts/OverflowContext.js'; import { render } from 'ink-testing-library'; import { DiffRenderer } from './DiffRenderer.js'; import * as CodeColorizer from '../../utils/CodeColorizer.js'; import { vi } from 'vitest'; -describe('', () => { +describe('', () => { const mockColorizeCode = vi.spyOn(CodeColorizer, 'colorizeCode'); beforeEach(() => { @@ -30,11 +31,13 @@ index 0000000..e69de29 +print("hello world") `; render( - , + + + , ); expect(mockColorizeCode).toHaveBeenCalledWith( 'print("hello world")', @@ -55,11 +58,13 @@ index 0000000..e69de29 +some content `; render( - , + + + , ); expect(mockColorizeCode).toHaveBeenCalledWith( 'some content', @@ -80,7 +85,9 @@ index 0000000..e69de29 +some text content `; render( - , + + + , ); expect(mockColorizeCode).toHaveBeenCalledWith( 'some text content', @@ -101,11 +108,13 @@ index 0000001..0000002 100644 +new line `; const { lastFrame } = render( - , + + + , ); // colorizeCode is used internally by the line-by-line rendering, not for the whole block expect(mockColorizeCode).not.toHaveBeenCalledWith( @@ -129,11 +138,13 @@ index 1234567..1234567 100644 +++ b/file.txt `; const { lastFrame } = render( - , + + + , ); expect(lastFrame()).toContain('No changes detected'); expect(mockColorizeCode).not.toHaveBeenCalled(); @@ -141,7 +152,9 @@ index 1234567..1234567 100644 it('should handle empty diff content', () => { const { lastFrame } = render( - , + + + , ); expect(lastFrame()).toContain('No diff content'); expect(mockColorizeCode).not.toHaveBeenCalled(); @@ -162,11 +175,13 @@ index 123..456 100644 context line 11 `; const { lastFrame } = render( - , + + + , ); const output = lastFrame(); expect(output).toContain('═'); // Check for the border character used in the gap @@ -197,11 +212,13 @@ index abc..def 100644 context line 15 `; const { lastFrame } = render( - , + + + , ); const output = lastFrame(); expect(output).not.toContain('═'); // Ensure no separator is rendered @@ -267,12 +284,14 @@ index 123..789 100644 'with terminalWidth $terminalWidth and height $height', ({ terminalWidth, height, expected }) => { const { lastFrame } = render( - , + + + , ); const output = lastFrame(); expect(sanitizeOutput(output, terminalWidth)).toEqual(expected); @@ -297,11 +316,13 @@ fileDiff Index: file.txt \\ No newline at end of file `; const { lastFrame } = render( - , + + + , ); const output = lastFrame(); @@ -325,11 +346,13 @@ fileDiff Index: Dockerfile \\ No newline at end of file `; const { lastFrame } = render( - , + + + , ); const output = lastFrame(); expect(output).toEqual(`1 FROM node:14 diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 445a157c..df692246 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -77,7 +77,6 @@ export const ToolGroupMessage: React.FC = ({ marginLeft={1} borderDimColor={hasPending} borderColor={borderColor} - marginBottom={1} > {toolCalls.map((tool) => { const isConfirming = toolAwaitingApproval?.callId === tool.callId; diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx index 7abd19a2..2fa72f96 100644 --- a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx +++ b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx @@ -5,6 +5,7 @@ */ import { render } from 'ink-testing-library'; +import { OverflowProvider } from '../../contexts/OverflowContext.js'; import { MaxSizedBox, setMaxSizedBoxDebugging } from './MaxSizedBox.js'; import { Box, Text } from 'ink'; import { describe, it, expect } from 'vitest'; @@ -18,28 +19,32 @@ describe('', () => { it('renders children without truncation when they fit', () => { const { lastFrame } = render( - - - Hello, World! - - , + + + + Hello, World! + + + , ); expect(lastFrame()).equals('Hello, World!'); }); it('hides lines when content exceeds maxHeight', () => { const { lastFrame } = render( - - - Line 1 - - - Line 2 - - - Line 3 - - , + + + + Line 1 + + + Line 2 + + + Line 3 + + + , ); expect(lastFrame()).equals(`... first 2 lines hidden ... Line 3`); @@ -47,17 +52,19 @@ Line 3`); it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', () => { const { lastFrame } = render( - - - Line 1 - - - Line 2 - - - Line 3 - - , + + + + Line 1 + + + Line 2 + + + Line 3 + + + , ); expect(lastFrame()).equals(`Line 1 ... last 2 lines hidden ...`); @@ -65,11 +72,13 @@ Line 3`); it('wraps text that exceeds maxWidth', () => { const { lastFrame } = render( - - - This is a long line of text - - , + + + + This is a long line of text + + + , ); expect(lastFrame()).equals(`This is a @@ -82,19 +91,21 @@ of text`); And has a line break. Leading spaces preserved.`; const { lastFrame } = render( - - - Example - - - No Wrap: - {multilineText} - - - Longer No Wrap: - This part will wrap around. - - , + + + + Example + + + No Wrap: + {multilineText} + + + Longer No Wrap: + This part will wrap around. + + + , ); expect(lastFrame()).equals( @@ -118,11 +129,13 @@ Longer No Wrap: This it('handles words longer than maxWidth by splitting them', () => { const { lastFrame } = render( - - - Supercalifragilisticexpialidocious - - , + + + + Supercalifragilisticexpialidocious + + + , ); expect(lastFrame()).equals(`... … @@ -134,14 +147,16 @@ ious`); it('does not truncate when maxHeight is undefined', () => { const { lastFrame } = render( - - - Line 1 - - - Line 2 - - , + + + + Line 1 + + + Line 2 + + + , ); expect(lastFrame()).equals(`Line 1 Line 2`); @@ -149,17 +164,19 @@ Line 2`); it('shows plural "lines" when more than one line is hidden', () => { const { lastFrame } = render( - - - Line 1 - - - Line 2 - - - Line 3 - - , + + + + Line 1 + + + Line 2 + + + Line 3 + + + , ); expect(lastFrame()).equals(`... first 2 lines hidden ... Line 3`); @@ -167,17 +184,19 @@ Line 3`); it('shows plural "lines" when more than one line is hidden and overflowDirection is bottom', () => { const { lastFrame } = render( - - - Line 1 - - - Line 2 - - - Line 3 - - , + + + + Line 1 + + + Line 2 + + + Line 3 + + + , ); expect(lastFrame()).equals(`Line 1 ... last 2 lines hidden ...`); @@ -185,7 +204,9 @@ Line 3`); it('renders an empty box for empty children', () => { const { lastFrame } = render( - , + + + , ); // Expect an empty string or a box with nothing in it. // Ink renders an empty box as an empty string. @@ -194,11 +215,13 @@ Line 3`); it('wraps text with multi-byte unicode characters correctly', () => { const { lastFrame } = render( - - - 你好世界 - - , + + + + 你好世界 + + + , ); // "你好" 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', () => { const { lastFrame } = render( - - - 🐶🐶🐶🐶🐶 - - , + + + + 🐶🐶🐶🐶🐶 + + + , ); // Each "🐶" has a visual width of 2. @@ -225,17 +250,19 @@ Line 3`); it('accounts for additionalHiddenLinesCount', () => { const { lastFrame } = render( - - - Line 1 - - - Line 2 - - - Line 3 - - , + + + + Line 1 + + + Line 2 + + + Line 3 + + + , ); // 1 line is hidden by overflow, 5 are additionally hidden. expect(lastFrame()).equals(`... first 7 lines hidden ... @@ -244,19 +271,21 @@ Line 3`); it('handles React.Fragment as a child', () => { const { lastFrame } = render( - - <> + + + <> + + Line 1 from Fragment + + + Line 2 from Fragment + + - Line 1 from Fragment + Line 3 direct child - - Line 2 from Fragment - - - - Line 3 direct child - - , + + , ); expect(lastFrame()).equals(`Line 1 from Fragment Line 2 from Fragment @@ -270,11 +299,13 @@ Line 3 direct child`); ).join('\n'); const { lastFrame } = render( - - - {THIRTY_LINES} - - , + + + + {THIRTY_LINES} + + + , ); const expected = [ @@ -292,11 +323,13 @@ Line 3 direct child`); ).join('\n'); const { lastFrame } = render( - - - {THIRTY_LINES} - - , + + + + {THIRTY_LINES} + + + , ); const expected = [ diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx index 1b5b90aa..faa1052a 100644 --- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx +++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx @@ -4,14 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { Fragment } from 'react'; +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: boolean) { enableDebugLog = value; } @@ -95,6 +103,10 @@ export const MaxSizedBox: React.FC = ({ overflowDirection = 'top', additionalHiddenLinesCount = 0, }) => { + const id = useId(); + const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {}; + + const laidOutStyledText: StyledText[][] = []; // When maxHeight is not set, we render the content normally rather // than using our custom layout logic. This should slightly improve // performance for the case where there is no height limit and is @@ -103,6 +115,59 @@ export const MaxSizedBox: React.FC = ({ // In the future we might choose to still apply our layout logic // even in this case particularlly if there are cases where we // 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 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) { return ( @@ -111,46 +176,10 @@ export const MaxSizedBox: React.FC = ({ ); } - 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 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 = hiddenLinesCount > 0 ? overflowDirection === 'top' - ? laidOutStyledText.slice( - laidOutStyledText.length - visibleContentHeight, - ) + ? laidOutStyledText.slice(hiddenLinesCount, laidOutStyledText.length) : laidOutStyledText.slice(0, visibleContentHeight) : laidOutStyledText; diff --git a/packages/cli/src/ui/contexts/OverflowContext.tsx b/packages/cli/src/ui/contexts/OverflowContext.tsx new file mode 100644 index 00000000..f21a4e0f --- /dev/null +++ b/packages/cli/src/ui/contexts/OverflowContext.tsx @@ -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; +} + +interface OverflowActions { + addOverflowingId: (id: string) => void; + removeOverflowingId: (id: string) => void; +} + +const OverflowStateContext = createContext( + undefined, +); + +const OverflowActionsContext = createContext( + 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()); + + 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 ( + + + {children} + + + ); +}; diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx index f96e6c9a..9bb7c362 100644 --- a/packages/cli/src/ui/utils/CodeColorizer.tsx +++ b/packages/cli/src/ui/utils/CodeColorizer.tsx @@ -16,7 +16,10 @@ import type { } from 'hast'; import { themeManager } from '../themes/theme-manager.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. const lowlight = createLowlight(common); @@ -85,8 +88,6 @@ function renderHastNode( return null; } -const RESERVED_LINES_FOR_TRUNCATION_MESSAGE = 2; - /** * Renders syntax-highlighted code for Ink applications using a selected theme. * @@ -111,11 +112,11 @@ export function colorizeCode( let hiddenLinesCount = 0; - // Optimizaiton to avoid highlighting lines that cannot possibly be displayed. - if (availableHeight && lines.length > availableHeight) { - const sliceIndex = - lines.length - availableHeight + RESERVED_LINES_FOR_TRUNCATION_MESSAGE; - if (sliceIndex > 0) { + // Optimization to avoid highlighting lines that cannot possibly be displayed. + if (availableHeight !== undefined) { + availableHeight = Math.max(availableHeight, MINIMUM_MAX_HEIGHT); + if (lines.length > availableHeight) { + const sliceIndex = lines.length - availableHeight; hiddenLinesCount = sliceIndex; lines = lines.slice(sliceIndex); }