Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
This commit is contained in:
Jacob Richman
2026-01-22 16:02:14 -08:00
committed by GitHub
parent beacc4f6fd
commit a1f5d39029
6 changed files with 342 additions and 130 deletions

View File

@@ -5,17 +5,9 @@
*/
import React from 'react';
import { Box, Text, type DOMElement } from 'ink';
import { ToolCallStatus } from '../../types.js';
import { Box, type DOMElement } from 'ink';
import { ShellInputPrompt } from '../ShellInputPrompt.js';
import { StickyHeader } from '../StickyHeader.js';
import {
SHELL_COMMAND_NAME,
SHELL_NAME,
SHELL_FOCUS_HINT_DELAY_MS,
} from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import { SHELL_TOOL_NAME } from '@google/gemini-cli-core';
import { useUIActions } from '../../contexts/UIActionsContext.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
import { ToolResultDisplay } from './ToolResultDisplay.js';
@@ -24,6 +16,10 @@ import {
ToolInfo,
TrailingIndicator,
STATUS_INDICATOR_WIDTH,
isThisShellFocusable as checkIsShellFocusable,
isThisShellFocused as checkIsShellFocused,
useFocusHint,
FocusHint,
} from './ToolShared.js';
import type { ToolMessageProps } from './ToolMessage.js';
import type { Config } from '@google/gemini-cli-core';
@@ -65,13 +61,13 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
borderDimColor,
}) => {
const isThisShellFocused =
(name === SHELL_COMMAND_NAME ||
name === SHELL_NAME ||
name === SHELL_TOOL_NAME) &&
status === ToolCallStatus.Executing &&
ptyId === activeShellPtyId &&
embeddedShellFocused;
const isThisShellFocused = checkIsShellFocused(
name,
status,
ptyId,
activeShellPtyId,
embeddedShellFocused,
);
const { setEmbeddedShellFocused } = useUIActions();
@@ -81,12 +77,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
// The shell is focusable if it's the shell command, it's executing, and the interactive shell is enabled.
const isThisShellFocusable =
(name === SHELL_COMMAND_NAME ||
name === SHELL_NAME ||
name === SHELL_TOOL_NAME) &&
status === ToolCallStatus.Executing &&
config?.getEnableInteractiveShell();
const isThisShellFocusable = checkIsShellFocusable(name, status, config);
const handleFocus = () => {
if (isThisShellFocusable) {
@@ -112,38 +103,11 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
}
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
const [lastUpdateTime, setLastUpdateTime] = React.useState<Date | null>(null);
const [userHasFocused, setUserHasFocused] = React.useState(false);
const [showFocusHint, setShowFocusHint] = React.useState(false);
React.useEffect(() => {
if (resultDisplay) {
setLastUpdateTime(new Date());
}
}, [resultDisplay]);
React.useEffect(() => {
if (!lastUpdateTime) {
return;
}
const timer = setTimeout(() => {
setShowFocusHint(true);
}, SHELL_FOCUS_HINT_DELAY_MS);
return () => clearTimeout(timer);
}, [lastUpdateTime]);
React.useEffect(() => {
if (isThisShellFocused) {
setUserHasFocused(true);
}
}, [isThisShellFocused]);
const shouldShowFocusHint =
isThisShellFocusable && (showFocusHint || userHasFocused);
const { shouldShowFocusHint } = useFocusHint(
isThisShellFocusable,
isThisShellFocused,
resultDisplay,
);
return (
<>
@@ -163,13 +127,10 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
emphasis={emphasis}
/>
{shouldShowFocusHint && (
<Box marginLeft={1} flexShrink={0}>
<Text color={theme.text.accent}>
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
</Text>
</Box>
)}
<FocusHint
shouldShowFocusHint={shouldShowFocusHint}
isThisShellFocused={isThisShellFocused}
/>
{emphasis === 'high' && <TrailingIndicator />}
</StickyHeader>

View File

@@ -13,9 +13,8 @@ import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { theme } from '../../semantic-colors.js';
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
import { SHELL_TOOL_NAME } from '@google/gemini-cli-core';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js';
interface ToolGroupMessageProps {
groupId: number;
@@ -37,21 +36,22 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
activeShellPtyId,
embeddedShellFocused,
}) => {
const isEmbeddedShellFocused =
embeddedShellFocused &&
toolCalls.some(
(t) =>
t.ptyId === activeShellPtyId && t.status === ToolCallStatus.Executing,
);
const isEmbeddedShellFocused = toolCalls.some((t) =>
isThisShellFocused(
t.name,
t.status,
t.ptyId,
activeShellPtyId,
embeddedShellFocused,
),
);
const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success,
);
const config = useConfig();
const isShellCommand = toolCalls.some(
(t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME,
);
const isShellCommand = toolCalls.some((t) => isShellTool(t.name));
const borderColor =
(isShellCommand && hasPending) || isEmbeddedShellFocused
? theme.ui.symbol
@@ -105,10 +105,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
{toolCalls.map((tool, index) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
const isFirst = index === 0;
const isShellTool =
tool.name === SHELL_COMMAND_NAME ||
tool.name === SHELL_NAME ||
tool.name === SHELL_TOOL_NAME;
const isShellToolCall = isShellTool(tool.name);
const commonProps = {
...tool,
@@ -131,7 +128,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
minHeight={1}
width={terminalWidth}
>
{isShellTool ? (
{isShellToolCall ? (
<ShellToolMessage
{...commonProps}
activeShellPtyId={activeShellPtyId}

View File

@@ -5,8 +5,7 @@
*/
import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { Box } from 'ink';
import type { IndividualToolCallDisplay } from '../../types.js';
import { StickyHeader } from '../StickyHeader.js';
import { ToolResultDisplay } from './ToolResultDisplay.js';
@@ -16,15 +15,12 @@ import {
TrailingIndicator,
type TextEmphasis,
STATUS_INDICATOR_WIDTH,
isThisShellFocusable as checkIsShellFocusable,
isThisShellFocused as checkIsShellFocused,
useFocusHint,
FocusHint,
} from './ToolShared.js';
import {
SHELL_COMMAND_NAME,
SHELL_FOCUS_HINT_DELAY_MS,
} from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import type { Config } from '@google/gemini-cli-core';
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
import { ToolCallStatus } from '../../types.js';
import { type Config } from '@google/gemini-cli-core';
import { ShellInputPrompt } from '../ShellInputPrompt.js';
export type { TextEmphasis };
@@ -60,39 +56,21 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
ptyId,
config,
}) => {
const isThisShellFocused =
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
status === ToolCallStatus.Executing &&
ptyId === activeShellPtyId &&
embeddedShellFocused;
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
const [userHasFocused, setUserHasFocused] = useState(false);
const showFocusHint = useInactivityTimer(
!!lastUpdateTime,
lastUpdateTime ? lastUpdateTime.getTime() : 0,
SHELL_FOCUS_HINT_DELAY_MS,
const isThisShellFocused = checkIsShellFocused(
name,
status,
ptyId,
activeShellPtyId,
embeddedShellFocused,
);
useEffect(() => {
if (resultDisplay) {
setLastUpdateTime(new Date());
}
}, [resultDisplay]);
const isThisShellFocusable = checkIsShellFocusable(name, status, config);
useEffect(() => {
if (isThisShellFocused) {
setUserHasFocused(true);
}
}, [isThisShellFocused]);
const isThisShellFocusable =
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
status === ToolCallStatus.Executing &&
config?.getEnableInteractiveShell();
const shouldShowFocusHint =
isThisShellFocusable && (showFocusHint || userHasFocused);
const { shouldShowFocusHint } = useFocusHint(
isThisShellFocusable,
isThisShellFocused,
resultDisplay,
);
return (
// It is crucial we don't replace this <> with a Box because otherwise the
@@ -112,13 +90,10 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
description={description}
emphasis={emphasis}
/>
{shouldShowFocusHint && (
<Box marginLeft={1} flexShrink={0}>
<Text color={theme.text.accent}>
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
</Text>
</Box>
)}
<FocusHint
shouldShowFocusHint={shouldShowFocusHint}
isThisShellFocused={isThisShellFocused}
/>
{emphasis === 'high' && <TrailingIndicator />}
</StickyHeader>
<Box

View File

@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { ToolCallStatus, StreamingState } from '../../types.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
SHELL_COMMAND_NAME,
SHELL_FOCUS_HINT_DELAY_MS,
} from '../../constants.js';
import type { Config, ToolResultDisplay } from '@google/gemini-cli-core';
vi.mock('../GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: () => null,
}));
vi.mock('./ToolResultDisplay.js', () => ({
ToolResultDisplay: () => null,
}));
describe('Focus Hint', () => {
const mockConfig = {
getEnableInteractiveShell: () => true,
} as Config;
const baseProps = {
callId: 'tool-123',
name: SHELL_COMMAND_NAME,
description: 'A tool for testing',
resultDisplay: undefined as ToolResultDisplay | undefined,
status: ToolCallStatus.Executing,
terminalWidth: 80,
confirmationDetails: undefined,
emphasis: 'medium' as const,
isFirst: true,
borderColor: 'green',
borderDimColor: false,
config: mockConfig,
ptyId: 1,
activeShellPtyId: 1,
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
const testCases = [
{ Component: ToolMessage, componentName: 'ToolMessage' },
{ Component: ShellToolMessage, componentName: 'ShellToolMessage' },
];
describe.each(testCases)('$componentName', ({ Component }) => {
it('shows focus hint after delay even with NO output', async () => {
const { lastFrame } = renderWithProviders(
<Component {...baseProps} resultDisplay={undefined} />,
{ uiState: { streamingState: StreamingState.Idle } },
);
// Initially, no focus hint
expect(lastFrame()).toMatchSnapshot('initial-no-output');
// Advance timers by the delay
act(() => {
vi.advanceTimersByTime(SHELL_FOCUS_HINT_DELAY_MS + 100);
});
// Now it SHOULD contain the focus hint
expect(lastFrame()).toMatchSnapshot('after-delay-no-output');
expect(lastFrame()).toContain('(tab to focus)');
});
it('shows focus hint after delay with output', async () => {
const { lastFrame } = renderWithProviders(
<Component {...baseProps} resultDisplay="Some output" />,
{ uiState: { streamingState: StreamingState.Idle } },
);
// Initially, no focus hint
expect(lastFrame()).toMatchSnapshot('initial-with-output');
// Advance timers
act(() => {
vi.advanceTimersByTime(SHELL_FOCUS_HINT_DELAY_MS + 100);
});
expect(lastFrame()).toMatchSnapshot('after-delay-with-output');
expect(lastFrame()).toContain('(tab to focus)');
});
});
it('handles long descriptions by shrinking them to show the focus hint', async () => {
const longDescription = 'A'.repeat(100);
const { lastFrame } = renderWithProviders(
<ToolMessage
{...baseProps}
description={longDescription}
resultDisplay="output"
/>,
{ uiState: { streamingState: StreamingState.Idle } },
);
act(() => {
vi.advanceTimersByTime(SHELL_FOCUS_HINT_DELAY_MS + 100);
});
// The focus hint should be visible
expect(lastFrame()).toMatchSnapshot('long-description');
expect(lastFrame()).toContain('(tab to focus)');
// The name should still be visible
expect(lastFrame()).toContain(SHELL_COMMAND_NAME);
});
});

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import React, { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { ToolCallStatus } from '../../types.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
@@ -12,12 +12,116 @@ import {
SHELL_COMMAND_NAME,
SHELL_NAME,
TOOL_STATUS,
SHELL_FOCUS_HINT_DELAY_MS,
} from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import { SHELL_TOOL_NAME } from '@google/gemini-cli-core';
import {
type Config,
SHELL_TOOL_NAME,
type ToolResultDisplay,
} from '@google/gemini-cli-core';
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
export const STATUS_INDICATOR_WIDTH = 3;
/**
* Returns true if the tool name corresponds to a shell tool.
*/
export function isShellTool(name: string): boolean {
return (
name === SHELL_COMMAND_NAME ||
name === SHELL_NAME ||
name === SHELL_TOOL_NAME
);
}
/**
* Returns true if the shell tool call is currently focusable.
*/
export function isThisShellFocusable(
name: string,
status: ToolCallStatus,
config?: Config,
): boolean {
return !!(
isShellTool(name) &&
status === ToolCallStatus.Executing &&
config?.getEnableInteractiveShell()
);
}
/**
* Returns true if this specific shell tool call is currently focused.
*/
export function isThisShellFocused(
name: string,
status: ToolCallStatus,
ptyId?: number,
activeShellPtyId?: number | null,
embeddedShellFocused?: boolean,
): boolean {
return !!(
isShellTool(name) &&
status === ToolCallStatus.Executing &&
ptyId === activeShellPtyId &&
embeddedShellFocused
);
}
/**
* Hook to manage focus hint state.
*/
export function useFocusHint(
isThisShellFocusable: boolean,
isThisShellFocused: boolean,
resultDisplay: ToolResultDisplay | undefined,
) {
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
const [userHasFocused, setUserHasFocused] = useState(false);
const showFocusHint = useInactivityTimer(
isThisShellFocusable,
lastUpdateTime ? lastUpdateTime.getTime() : 0,
SHELL_FOCUS_HINT_DELAY_MS,
);
useEffect(() => {
if (resultDisplay) {
setLastUpdateTime(new Date());
}
}, [resultDisplay]);
useEffect(() => {
if (isThisShellFocused) {
setUserHasFocused(true);
}
}, [isThisShellFocused]);
const shouldShowFocusHint =
isThisShellFocusable && (showFocusHint || userHasFocused);
return { shouldShowFocusHint };
}
/**
* Component to render the focus hint.
*/
export const FocusHint: React.FC<{
shouldShowFocusHint: boolean;
isThisShellFocused: boolean;
}> = ({ shouldShowFocusHint, isThisShellFocused }) => {
if (!shouldShowFocusHint) {
return null;
}
return (
<Box marginLeft={1} flexShrink={0}>
<Text color={theme.text.accent}>
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
</Text>
</Box>
);
};
export type TextEmphasis = 'high' | 'medium' | 'low';
type ToolStatusIndicatorProps = {
@@ -29,10 +133,7 @@ export const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
status,
name,
}) => {
const isShell =
name === SHELL_COMMAND_NAME ||
name === SHELL_NAME ||
name === SHELL_TOOL_NAME;
const isShell = isShellTool(name);
const statusColor = isShell ? theme.ui.symbol : theme.status.warning;
return (

View File

@@ -0,0 +1,55 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ │"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > initial-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing │
│ │"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ │"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > initial-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing │
│ │"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ │"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > initial-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing │
│ │"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ │"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > initial-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing │
│ │"
`;
exports[`Focus Hint > handles long descriptions by shrinking them to show the focus hint > long-description 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (tab to focus) │
│ │"
`;