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);
}