feat(shell): enable interactive commands with virtual terminal (#6694)

This commit is contained in:
Gal Zahavi
2025-09-11 13:27:27 -07:00
committed by GitHub
parent 8969a232ec
commit 181898cb5d
43 changed files with 2345 additions and 324 deletions

View File

@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { AnsiOutputText } from './AnsiOutput.js';
import type { AnsiOutput, AnsiToken } from '@google/gemini-cli-core';
// Helper to create a valid AnsiToken with default values
const createAnsiToken = (overrides: Partial<AnsiToken>): AnsiToken => ({
text: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
fg: '#ffffff',
bg: '#000000',
...overrides,
});
describe('<AnsiOutputText />', () => {
it('renders a simple AnsiOutput object correctly', () => {
const data: AnsiOutput = [
[
createAnsiToken({ text: 'Hello, ' }),
createAnsiToken({ text: 'world!' }),
],
];
const { lastFrame } = render(<AnsiOutputText data={data} />);
expect(lastFrame()).toBe('Hello, world!');
});
it('correctly applies all the styles', () => {
const data: AnsiOutput = [
[
createAnsiToken({ text: 'Bold', bold: true }),
createAnsiToken({ text: 'Italic', italic: true }),
createAnsiToken({ text: 'Underline', underline: true }),
createAnsiToken({ text: 'Dim', dim: true }),
createAnsiToken({ text: 'Inverse', inverse: true }),
],
];
// Note: ink-testing-library doesn't render styles, so we can only check the text.
// We are testing that it renders without crashing.
const { lastFrame } = render(<AnsiOutputText data={data} />);
expect(lastFrame()).toBe('BoldItalicUnderlineDimInverse');
});
it('correctly applies foreground and background colors', () => {
const data: AnsiOutput = [
[
createAnsiToken({ text: 'Red FG', fg: '#ff0000' }),
createAnsiToken({ text: 'Blue BG', bg: '#0000ff' }),
],
];
// Note: ink-testing-library doesn't render colors, so we can only check the text.
// We are testing that it renders without crashing.
const { lastFrame } = render(<AnsiOutputText data={data} />);
expect(lastFrame()).toBe('Red FGBlue BG');
});
it('handles empty lines and empty tokens', () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'First line' })],
[],
[createAnsiToken({ text: 'Third line' })],
[createAnsiToken({ text: '' })],
];
const { lastFrame } = render(<AnsiOutputText data={data} />);
const output = lastFrame();
expect(output).toBeDefined();
const lines = output!.split('\n');
expect(lines[0]).toBe('First line');
expect(lines[1]).toBe('Third line');
});
it('respects the availableTerminalHeight prop and slices the lines correctly', () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame } = render(
<AnsiOutputText data={data} availableTerminalHeight={2} />,
);
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
});
it('renders a large AnsiOutput object without crashing', () => {
const largeData: AnsiOutput = [];
for (let i = 0; i < 1000; i++) {
largeData.push([createAnsiToken({ text: `Line ${i}` })]);
}
const { lastFrame } = render(<AnsiOutputText data={largeData} />);
// We are just checking that it renders something without crashing.
expect(lastFrame()).toBeDefined();
});
});

View File

@@ -0,0 +1,46 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import type { AnsiLine, AnsiOutput, AnsiToken } from '@google/gemini-cli-core';
const DEFAULT_HEIGHT = 24;
interface AnsiOutputProps {
data: AnsiOutput;
availableTerminalHeight?: number;
}
export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
data,
availableTerminalHeight,
}) => {
const lastLines = data.slice(
-(availableTerminalHeight && availableTerminalHeight > 0
? availableTerminalHeight
: DEFAULT_HEIGHT),
);
return lastLines.map((line: AnsiLine, lineIndex: number) => (
<Text key={lineIndex}>
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.inverse ? token.bg : token.fg}
backgroundColor={token.inverse ? token.fg : token.bg}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
));
};

View File

@@ -58,20 +58,22 @@ export const Composer = () => {
return (
<Box flexDirection="column">
<LoadingIndicator
thought={
uiState.streamingState === StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.disableLoadingPhrases
? undefined
: uiState.thought
}
currentLoadingPhrase={
config.getAccessibility()?.disableLoadingPhrases
? undefined
: uiState.currentLoadingPhrase
}
elapsedTime={uiState.elapsedTime}
/>
{!uiState.shellFocused && (
<LoadingIndicator
thought={
uiState.streamingState === StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.disableLoadingPhrases
? undefined
: uiState.thought
}
currentLoadingPhrase={
config.getAccessibility()?.disableLoadingPhrases
? undefined
: uiState.currentLoadingPhrase
}
elapsedTime={uiState.elapsedTime}
/>
)}
{!uiState.isConfigInitialized && <ConfigInitDisplay />}
@@ -178,6 +180,7 @@ export const Composer = () => {
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={uiState.isFocused}
vimHandleInput={uiActions.vimHandleInput}
isShellFocused={uiState.shellFocused}
placeholder={
vimEnabled
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."

View File

@@ -30,6 +30,8 @@ interface HistoryItemDisplayProps {
isPending: boolean;
isFocused?: boolean;
commands?: readonly SlashCommand[];
activeShellPtyId?: number | null;
shellFocused?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -39,6 +41,8 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isPending,
commands,
isFocused = true,
activeShellPtyId,
shellFocused,
}) => (
<Box flexDirection="column" key={item.id}>
{/* Render standard message types */}
@@ -85,6 +89,8 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
isFocused={isFocused}
activeShellPtyId={activeShellPtyId}
shellFocused={shellFocused}
/>
)}
{item.type === 'compression' && (

View File

@@ -50,6 +50,7 @@ export interface InputPromptProps {
approvalMode: ApprovalMode;
onEscapePromptChange?: (showPrompt: boolean) => void;
vimHandleInput?: (key: Key) => boolean;
isShellFocused?: boolean;
}
export const InputPrompt: React.FC<InputPromptProps> = ({
@@ -69,6 +70,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
approvalMode,
onEscapePromptChange,
vimHandleInput,
isShellFocused,
}) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
@@ -591,7 +593,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
);
useKeypress(handleInput, {
isActive: true,
isActive: !isShellFocused,
});
const linesToRender = buffer.viewportVisualLines;

View File

@@ -54,6 +54,8 @@ export const MainContent = () => {
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
shellFocused={uiState.shellFocused}
/>
))}
<ShowMoreLines constrainHeight={uiState.constrainHeight} />

View File

@@ -0,0 +1,57 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback } from 'react';
import type React from 'react';
import { useKeypress } from '../hooks/useKeypress.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
export interface ShellInputPromptProps {
activeShellPtyId: number | null;
focus?: boolean;
}
export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
activeShellPtyId,
focus = true,
}) => {
const handleShellInputSubmit = useCallback(
(input: string) => {
if (activeShellPtyId) {
ShellExecutionService.writeToPty(activeShellPtyId, input);
}
},
[activeShellPtyId],
);
const handleInput = useCallback(
(key: Key) => {
if (!focus || !activeShellPtyId) {
return;
}
if (key.ctrl && key.shift && key.name === 'up') {
ShellExecutionService.scrollPty(activeShellPtyId, -1);
return;
}
if (key.ctrl && key.shift && key.name === 'down') {
ShellExecutionService.scrollPty(activeShellPtyId, 1);
return;
}
const ansiSequence = keyToAnsi(key);
if (ansiSequence) {
handleShellInputSubmit(ansiSequence);
}
},
[focus, handleShellInputSubmit, activeShellPtyId],
);
useKeypress(handleInput, { isActive: focus });
return null;
};

View File

@@ -21,6 +21,9 @@ interface ToolGroupMessageProps {
availableTerminalHeight?: number;
terminalWidth: number;
isFocused?: boolean;
activeShellPtyId?: number | null;
shellFocused?: boolean;
onShellInputSubmit?: (input: string) => void;
}
// Main component renders the border and maps the tools using ToolMessage
@@ -29,14 +32,26 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
availableTerminalHeight,
terminalWidth,
isFocused = true,
activeShellPtyId,
shellFocused,
}) => {
const config = useConfig();
const isShellFocused =
shellFocused &&
toolCalls.some(
(t) =>
t.ptyId === activeShellPtyId && t.status === ToolCallStatus.Executing,
);
const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success,
);
const config = useConfig();
const isShellCommand = toolCalls.some((t) => t.name === SHELL_COMMAND_NAME);
const borderColor =
hasPending || isShellCommand ? theme.status.warning : theme.border.default;
hasPending || isShellCommand || isShellFocused
? theme.status.warning
: theme.border.default;
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
// This is a bit of a magic number, but it accounts for the border and
@@ -89,12 +104,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
<Box key={tool.callId} flexDirection="column" minHeight={1}>
<Box flexDirection="row" alignItems="center">
<ToolMessage
callId={tool.callId}
name={tool.name}
description={tool.description}
resultDisplay={tool.resultDisplay}
status={tool.status}
confirmationDetails={tool.confirmationDetails}
{...tool}
availableTerminalHeight={availableTerminalHeightPerToolMessage}
terminalWidth={innerWidth}
emphasis={
@@ -104,7 +114,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
? 'low'
: 'medium'
}
renderOutputAsMarkdown={tool.renderOutputAsMarkdown}
activeShellPtyId={activeShellPtyId}
shellFocused={shellFocused}
config={config}
/>
</Box>
{tool.status === ToolCallStatus.Confirming &&

View File

@@ -11,6 +11,31 @@ import { ToolMessage } from './ToolMessage.js';
import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import type { AnsiOutput } from '@google/gemini-cli-core';
vi.mock('../TerminalOutput.js', () => ({
TerminalOutput: function MockTerminalOutput({
cursor,
}: {
cursor: { x: number; y: number } | null;
}) {
return (
<Text>
MockCursor:({cursor?.x},{cursor?.y})
</Text>
);
},
}));
vi.mock('../AnsiOutput.js', () => ({
AnsiOutputText: function MockAnsiOutputText({ data }: { data: AnsiOutput }) {
// Simple serialization for snapshot stability
const serialized = data
.map((line) => line.map((token) => token.text || '').join(''))
.join('\n');
return <Text>MockAnsiOutput:{serialized}</Text>;
},
}));
// Mock child components or utilities if they are complex or have side effects
vi.mock('../GeminiRespondingSpinner.js', () => ({
@@ -181,4 +206,26 @@ describe('<ToolMessage />', () => {
// We can at least ensure it doesn't have the high emphasis indicator.
expect(lowEmphasisFrame()).not.toContain('←');
});
it('renders AnsiOutputText for AnsiOutput results', () => {
const ansiResult: AnsiOutput = [
[
{
text: 'hello',
fg: '#ffffff',
bg: '#000000',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
},
],
];
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} resultDisplay={ansiResult} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('MockAnsiOutput:hello');
});
});

View File

@@ -10,10 +10,13 @@ import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { DiffRenderer } from './DiffRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText } from '../AnsiOutput.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { TOOL_STATUS } from '../../constants.js';
import { ShellInputPrompt } from '../ShellInputPrompt.js';
import { SHELL_COMMAND_NAME, TOOL_STATUS } from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import type { AnsiOutput, Config } from '@google/gemini-cli-core';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
@@ -30,6 +33,9 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
terminalWidth: number;
emphasis?: TextEmphasis;
renderOutputAsMarkdown?: boolean;
activeShellPtyId?: number | null;
shellFocused?: boolean;
config?: Config;
}
export const ToolMessage: React.FC<ToolMessageProps> = ({
@@ -41,7 +47,17 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
terminalWidth,
emphasis = 'medium',
renderOutputAsMarkdown = true,
activeShellPtyId,
shellFocused,
ptyId,
config,
}) => {
const isThisShellFocused =
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
status === ToolCallStatus.Executing &&
ptyId === activeShellPtyId &&
shellFocused;
const availableHeight = availableTerminalHeight
? Math.max(
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
@@ -74,12 +90,17 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
description={description}
emphasis={emphasis}
/>
{isThisShellFocused && (
<Box marginLeft={1}>
<Text color={theme.text.accent}>[Focused]</Text>
</Box>
)}
{emphasis === 'high' && <TrailingIndicator />}
</Box>
{resultDisplay && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
<Box flexDirection="column">
{typeof resultDisplay === 'string' && renderOutputAsMarkdown && (
{typeof resultDisplay === 'string' && renderOutputAsMarkdown ? (
<Box flexDirection="column">
<MarkdownDisplay
text={resultDisplay}
@@ -88,25 +109,37 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
terminalWidth={childWidth}
/>
</Box>
)}
{typeof resultDisplay === 'string' && !renderOutputAsMarkdown && (
) : typeof resultDisplay === 'string' && !renderOutputAsMarkdown ? (
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
<Box>
<Text wrap="wrap">{resultDisplay}</Text>
</Box>
</MaxSizedBox>
)}
{typeof resultDisplay !== 'string' && (
) : typeof resultDisplay === 'object' &&
!Array.isArray(resultDisplay) ? (
<DiffRenderer
diffContent={resultDisplay.fileDiff}
filename={resultDisplay.fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
) : (
<AnsiOutputText
data={resultDisplay as AnsiOutput}
availableTerminalHeight={availableHeight}
/>
)}
</Box>
</Box>
)}
{isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
<ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null}
focus={shellFocused}
/>
</Box>
)}
</Box>
);
};