feat(cli): improve CTRL+O experience for both standard and alternate screen buffer (ASB) modes (#19010)

Co-authored-by: jacob314 <jacob314@gmail.com>
This commit is contained in:
Jarrod Whelan
2026-02-20 16:26:11 -08:00
committed by GitHub
parent 547f5d45f5
commit 727f9b67b1
39 changed files with 1622 additions and 428 deletions
@@ -17,10 +17,15 @@ import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool } from './ToolShared.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js';
import { shouldHideToolCall } from '@google/gemini-cli-core';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import {
calculateShellMaxLines,
calculateToolContentMaxLines,
} from '../../utils/toolLayoutUtils.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
interface ToolGroupMessageProps {
@@ -31,6 +36,7 @@ interface ToolGroupMessageProps {
onShellInputSubmit?: (input: string) => void;
borderTop?: boolean;
borderBottom?: boolean;
isExpandable?: boolean;
}
// Main component renders the border and maps the tools using ToolMessage
@@ -43,6 +49,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
terminalWidth,
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
isExpandable,
}) => {
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
const toolCalls = useMemo(
@@ -67,6 +74,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
backgroundShells,
pendingHistoryItems,
} = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const { borderColor, borderDimColor } = useMemo(
() =>
@@ -106,14 +114,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const staticHeight = /* border */ 2;
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
// only render if we need to close a border from previous
// tool groups. borderBottomOverride=true means we must render the closing border;
// undefined or false means there's nothing to display.
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
return null;
}
let countToolCallsWithResults = 0;
for (const tool of visibleToolCalls) {
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
@@ -134,21 +134,91 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
return (
// This box doesn't have a border even though it conceptually does because
// we need to allow the sticky headers to render the borders themselves so
// that the top border can be sticky.
/*
* ToolGroupMessage calculates its own overflow state locally and passes
* it as a prop to ShowMoreLines. This isolates it from global overflow
* reports in ASB mode, while allowing it to contribute to the global
* 'Toast' hint in Standard mode.
*
* Because of this prop-based isolation and the explicit mode-checks in
* AppContainer, we do not need to shadow the OverflowProvider here.
*/
const hasOverflow = useMemo(() => {
if (!availableTerminalHeightPerToolMessage) return false;
return visibleToolCalls.some((tool) => {
const isShellToolCall = isShellTool(tool.name);
const isFocused = isThisShellFocused(
tool.name,
tool.status,
tool.ptyId,
activePtyId,
embeddedShellFocused,
);
let maxLines: number | undefined;
if (isShellToolCall) {
maxLines = calculateShellMaxLines({
status: tool.status,
isAlternateBuffer,
isThisShellFocused: isFocused,
availableTerminalHeight: availableTerminalHeightPerToolMessage,
constrainHeight,
isExpandable,
});
}
// Standard tools and Shell tools both eventually use ToolResultDisplay's logic.
// ToolResultDisplay uses calculateToolContentMaxLines to find the final line budget.
const contentMaxLines = calculateToolContentMaxLines({
availableTerminalHeight: availableTerminalHeightPerToolMessage,
isAlternateBuffer,
maxLinesLimit: maxLines,
});
if (!contentMaxLines) return false;
if (typeof tool.resultDisplay === 'string') {
const text = tool.resultDisplay;
const hasTrailingNewline = text.endsWith('\n');
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
const lineCount = contentText.split('\n').length;
return lineCount > contentMaxLines;
}
if (Array.isArray(tool.resultDisplay)) {
return tool.resultDisplay.length > contentMaxLines;
}
return false;
});
}, [
visibleToolCalls,
availableTerminalHeightPerToolMessage,
activePtyId,
embeddedShellFocused,
isAlternateBuffer,
constrainHeight,
isExpandable,
]);
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
// only render if we need to close a border from previous
// tool groups. borderBottomOverride=true means we must render the closing border;
// undefined or false means there's nothing to display.
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
return null;
}
const content = (
<Box
flexDirection="column"
/*
This width constraint is highly important and protects us from an Ink rendering bug.
Since the ToolGroup can typically change rendering states frequently, it can cause
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
This width constraint is highly important and protects us from an Ink rendering bug.
Since the ToolGroup can typically change rendering states frequently, it can cause
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
marginBottom={borderBottomOverride === false ? 0 : 1}
>
{visibleToolCalls.map((tool, index) => {
const isFirst = index === 0;
@@ -165,6 +235,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
: isFirst,
borderColor,
borderDimColor,
isExpandable,
};
return (
@@ -179,34 +250,34 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
) : (
<ToolMessage {...commonProps} />
)}
<Box
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
{tool.outputFile && (
{tool.outputFile && (
<Box
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
<Box>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
)}
</Box>
</Box>
)}
</Box>
);
})}
{
/*
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
<Box
height={0}
@@ -222,8 +293,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
)
}
{(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
<ShowMoreLines constrainHeight={constrainHeight} />
<ShowMoreLines
constrainHeight={constrainHeight && !!isExpandable}
isOverflowing={hasOverflow}
/>
)}
</Box>
);
return content;
};