fix(ui): made shell tool header wrap on Ctrl+O (#26229)

This commit is contained in:
Dev Randalpura
2026-04-30 12:01:47 -05:00
committed by GitHub
parent 487fb219cc
commit 071e2923bb
7 changed files with 136 additions and 7 deletions
@@ -356,4 +356,65 @@ describe('<ShellToolMessage />', () => {
unmount();
});
});
describe('Header Expansion', () => {
const LONG_DESCRIPTION = 'very long '.repeat(20);
it('truncates header by default', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ShellToolMessage
{...baseProps}
description={LONG_DESCRIPTION}
availableTerminalHeight={10}
/>,
{ uiActions },
);
await waitUntilReady();
const output = lastFrame();
// Should be a single line header
expect(output.split('\n')[1]).toContain(SHELL_COMMAND_NAME); // name
// We check if it's truncated. In our ToolInfo, it's height 1.
// The StickyHeader adds some structure, but the ToolInfo Box is inside.
});
it('expands header when availableTerminalHeight is undefined', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ShellToolMessage
{...baseProps}
description={LONG_DESCRIPTION}
availableTerminalHeight={undefined}
/>,
{ uiActions },
);
await waitUntilReady();
const output = lastFrame();
// When expanded, the header (ToolInfo) should wrap and take multiple lines.
// Since it's at the top, we check if the first few lines contain parts of the description.
const lines = output.split('\n');
expect(lines.length).toBeGreaterThan(5);
});
it('expands header when isExpanded is true in context', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ShellToolMessage
{...baseProps}
description={LONG_DESCRIPTION}
availableTerminalHeight={10}
/>,
{
uiActions,
toolActions: {
isExpanded: (id: string) => id === baseProps.callId,
},
},
);
await waitUntilReady();
const output = lastFrame();
// Should be expanded due to context
expect(output.split('\n').length).toBeGreaterThan(5);
});
});
});
@@ -24,6 +24,7 @@ import type { ToolMessageProps } from './ToolMessage.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
import {
type Config,
ShellExecutionService,
@@ -41,6 +42,7 @@ export interface ShellToolMessageProps extends ToolMessageProps {
}
export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
callId,
name,
description,
resultDisplay,
@@ -57,6 +59,12 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
isExpandable,
originalRequestName,
}) => {
const { isExpanded: isExpandedInContext } = useToolActions();
const isExpanded =
(isExpandedInContext ? isExpandedInContext(callId) : false) ||
availableTerminalHeight === undefined;
const {
activePtyId: activeShellPtyId,
embeddedShellFocused,
@@ -169,6 +177,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
description={description}
emphasis={emphasis}
originalRequestName={originalRequestName}
isExpanded={isExpanded}
/>
<FocusHint
@@ -24,6 +24,7 @@ import {
import { type Config, CoreToolCallStatus, Kind } from '@google/gemini-cli-core';
import { ShellInputPrompt } from '../ShellInputPrompt.js';
import { SUBAGENT_MAX_LINES } from '../../constants.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
export type { TextEmphasis };
@@ -42,6 +43,7 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
}
export const ToolMessage: React.FC<ToolMessageProps> = ({
callId,
name,
description,
resultDisplay,
@@ -63,6 +65,12 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
progress,
progressTotal,
}) => {
const { isExpanded: isExpandedInContext } = useToolActions();
const isExpanded =
(isExpandedInContext ? isExpandedInContext(callId) : false) ||
availableTerminalHeight === undefined;
const isThisShellFocused = checkIsShellFocused(
name,
status,
@@ -102,6 +110,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
emphasis={emphasis}
progressMessage={progressMessage}
originalRequestName={originalRequestName}
isExpanded={isExpanded}
/>
<FocusHint
shouldShowFocusHint={shouldShowFocusHint}
@@ -7,7 +7,8 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '../../../test-utils/render.js';
import { Text } from 'ink';
import { McpProgressIndicator } from './ToolShared.js';
import { McpProgressIndicator, ToolInfo } from './ToolShared.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
vi.mock('../GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: () => <Text>MockSpinner</Text>,
@@ -65,3 +66,37 @@ describe('McpProgressIndicator', () => {
expect(output).not.toContain('150%');
});
});
describe('ToolInfo', () => {
const longDescription = 'long '.repeat(50);
it('truncates description by default', async () => {
const { lastFrame } = await render(
<ToolInfo
name="test-tool"
description={longDescription}
status={CoreToolCallStatus.Success}
emphasis="medium"
/>,
);
const output = lastFrame();
// In Ink, a single line Box with wrap="truncate" will be truncated.
// Since we don't know the exact terminal width in this test, we check if it is short.
expect(output.trim().split('\n').length).toBe(1);
});
it('wraps description when isExpanded is true', async () => {
const { lastFrame } = await render(
<ToolInfo
name="test-tool"
description={longDescription}
status={CoreToolCallStatus.Success}
emphasis="medium"
isExpanded={true}
/>,
);
const output = lastFrame();
// When expanded, it should wrap into multiple lines.
expect(output.trim().split('\n').length).toBeGreaterThan(1);
});
});
@@ -194,6 +194,7 @@ type ToolInfoProps = {
emphasis: TextEmphasis;
progressMessage?: string;
originalRequestName?: string;
isExpanded?: boolean;
};
export const ToolInfo: React.FC<ToolInfoProps> = ({
@@ -203,6 +204,7 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
emphasis,
progressMessage: _progressMessage,
originalRequestName,
isExpanded = false,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
const nameColor = React.useMemo<string>(() => {
@@ -224,8 +226,16 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
const isCompletedAskUser = isCompletedAskUserTool(name, status);
return (
<Box overflow="hidden" height={1} flexGrow={1} flexShrink={1}>
<Text strikethrough={status === ToolCallStatus.Canceled} wrap="truncate">
<Box
overflow="hidden"
height={isExpanded ? undefined : 1}
flexGrow={1}
flexShrink={1}
>
<Text
strikethrough={status === ToolCallStatus.Canceled}
wrap={isExpanded ? 'wrap' : 'truncate'}
>
<Text color={nameColor} bold>
{name}
</Text>
@@ -62,9 +62,9 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls arra
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description 1. This is a long description that will need to b
│ ✓ tool-1 Description 1. This is a long description that will need to be
│ truncated if the terminal width is small. │ ▄
│──────────────────────────────────────────────────────────────────────────│ █
│ line3 │ █
│ line4 │ █
│ line5 │ █
│ │ █
@@ -161,7 +161,10 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = `
"╭──────────────────────────────────╮
│ ✓ very-long-tool-name-that-mig
│ ✓ very-long-tool-name-that-migh
│ t-wrap This is a very long │
│ description that might cause │
│ wrapping issues │
│ │
│ Test result │
╰──────────────────────────────────╯
@@ -58,7 +58,9 @@ exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output >
exports[`Focus Hint > handles long descriptions by shrinking them to show the focus hint > long-description 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (Tab to focus) │
│ ⊶ Shell Command (Tab to focus) │
│ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA │
│ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA │
│ │
"
`;