diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 5acf740074..d39f676f11 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -8,6 +8,7 @@ import { render as inkRender } from 'ink-testing-library'; import { Box } from 'ink'; import type React from 'react'; import { act } from 'react'; +import { vi } from 'vitest'; import { LoadedSettings, type Settings } from '../config/settings.js'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; @@ -20,6 +21,10 @@ import { VimModeProvider } from '../ui/contexts/VimModeContext.js'; import { MouseProvider } from '../ui/contexts/MouseContext.js'; import { ScrollProvider } from '../ui/contexts/ScrollProvider.js'; import { StreamingContext } from '../ui/contexts/StreamingContext.js'; +import { + type UIActions, + UIActionsContext, +} from '../ui/contexts/UIActionsContext.js'; import { type Config } from '@google/gemini-cli-core'; @@ -65,6 +70,19 @@ export const render = ( }; }; +export const simulateClick = async ( + stdin: ReturnType['stdin'], + col: number, + row: number, + button: 0 | 1 | 2 = 0, // 0 for left, 1 for middle, 2 for right +) => { + // Terminal mouse events are 1-based, so convert if necessary. + const mouseEventString = `\x1b[<${button};${col};${row}M`; + await act(async () => { + stdin.write(mouseEventString); + }); +}; + const mockConfig = { getModel: () => 'gemini-pro', getTargetDir: () => @@ -117,6 +135,38 @@ const baseMockUiState = { currentModel: 'gemini-pro', }; +const mockUIActions: UIActions = { + handleThemeSelect: vi.fn(), + closeThemeDialog: vi.fn(), + handleThemeHighlight: vi.fn(), + handleAuthSelect: vi.fn(), + setAuthState: vi.fn(), + onAuthError: vi.fn(), + handleEditorSelect: vi.fn(), + exitEditorDialog: vi.fn(), + exitPrivacyNotice: vi.fn(), + closeSettingsDialog: vi.fn(), + closeModelDialog: vi.fn(), + openPermissionsDialog: vi.fn(), + closePermissionsDialog: vi.fn(), + setShellModeActive: vi.fn(), + vimHandleInput: vi.fn(), + handleIdePromptComplete: vi.fn(), + handleFolderTrustSelect: vi.fn(), + setConstrainHeight: vi.fn(), + onEscapePromptChange: vi.fn(), + refreshStatic: vi.fn(), + handleFinalSubmit: vi.fn(), + handleClearScreen: vi.fn(), + handleProQuotaChoice: vi.fn(), + setQueueErrorMessage: vi.fn(), + popAllMessages: vi.fn(), + handleApiKeySubmit: vi.fn(), + handleApiKeyCancel: vi.fn(), + setBannerVisible: vi.fn(), + setEmbeddedShellFocused: vi.fn(), +}; + export const renderWithProviders = ( component: React.ReactElement, { @@ -127,6 +177,7 @@ export const renderWithProviders = ( mouseEventsEnabled = false, config = configProxy as unknown as Config, useAlternateBuffer, + uiActions, }: { shellFocus?: boolean; settings?: LoadedSettings; @@ -135,8 +186,9 @@ export const renderWithProviders = ( mouseEventsEnabled?: boolean; config?: Config; useAlternateBuffer?: boolean; + uiActions?: Partial; } = {}, -): ReturnType => { +): ReturnType & { simulateClick: typeof simulateClick } => { const baseState: UIState = new Proxy( { ...baseMockUiState, ...providedUiState }, { @@ -175,27 +227,31 @@ export const renderWithProviders = ( mainAreaWidth, }; - return render( + const finalUIActions = { ...mockUIActions, ...uiActions }; + + const renderResult = render( - - - - - {component} - - - - + + + + + + {component} + + + + + @@ -204,6 +260,8 @@ export const renderWithProviders = ( , terminalWidth, ); + + return { ...renderResult, simulateClick }; }; export function renderHook( diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index e048179a1d..2b51f98613 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1578,6 +1578,7 @@ Logging in with Google... Please restart Gemini CLI to continue. handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, + setEmbeddedShellFocused, }), [ handleThemeSelect, @@ -1608,6 +1609,7 @@ Logging in with Google... Please restart Gemini CLI to continue. handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, + setEmbeddedShellFocused, ], ); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 060c9f7475..33e76b0f69 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -104,6 +104,10 @@ describe('InputPrompt', () => { useReverseSearchCompletion, ); const mockedUseKittyKeyboardProtocol = vi.mocked(useKittyKeyboardProtocol); + const mockSetEmbeddedShellFocused = vi.fn(); + const uiActions = { + setEmbeddedShellFocused: mockSetEmbeddedShellFocused, + }; beforeEach(() => { vi.resetAllMocks(); @@ -240,7 +244,9 @@ describe('InputPrompt', () => { it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => { props.shellModeActive = true; - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\u001B[A'); @@ -253,7 +259,9 @@ describe('InputPrompt', () => { it('should call shellHistory.getNextCommand on down arrow in shell mode', async () => { props.shellModeActive = true; - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\u001B[B'); @@ -269,7 +277,9 @@ describe('InputPrompt', () => { vi.mocked(mockShellHistory.getPreviousCommand).mockReturnValue( 'previous command', ); - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\u001B[A'); @@ -284,7 +294,9 @@ describe('InputPrompt', () => { it('should call shellHistory.addCommandToHistory on submit in shell mode', async () => { props.shellModeActive = true; props.buffer.setText('ls -l'); - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\r'); @@ -300,7 +312,9 @@ describe('InputPrompt', () => { it('should NOT call shell history methods when not in shell mode', async () => { props.buffer.setText('some text'); - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\u001B[A'); // Up arrow @@ -339,7 +353,9 @@ describe('InputPrompt', () => { props.buffer.setText('/mem'); - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); // Test up arrow await act(async () => { @@ -371,7 +387,9 @@ describe('InputPrompt', () => { }); props.buffer.setText('/mem'); - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); // Test down arrow await act(async () => { @@ -398,7 +416,9 @@ describe('InputPrompt', () => { showSuggestions: false, }); props.buffer.setText('some text'); - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\u001B[A'); // Up arrow @@ -628,7 +648,9 @@ describe('InputPrompt', () => { activeSuggestionIndex: activeIndex, }); props.buffer.setText(bufferText); - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => stdin.write('\t')); await waitFor(() => @@ -648,7 +670,9 @@ describe('InputPrompt', () => { }); props.buffer.setText('/mem'); - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\r'); @@ -680,7 +704,9 @@ describe('InputPrompt', () => { }); props.buffer.setText('/?'); - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\t'); // Press Tab for autocomplete @@ -694,7 +720,9 @@ describe('InputPrompt', () => { it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => { props.buffer.setText(' '); // Set buffer to whitespace - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\r'); // Press Enter @@ -714,7 +742,9 @@ describe('InputPrompt', () => { }); props.buffer.setText('/clear'); - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\r'); @@ -731,7 +761,9 @@ describe('InputPrompt', () => { }); props.buffer.setText('/clear'); - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\r'); @@ -749,7 +781,9 @@ describe('InputPrompt', () => { }); props.buffer.setText('@src/components/'); - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\r'); @@ -767,7 +801,9 @@ describe('InputPrompt', () => { mockBuffer.cursor = [0, 11]; mockBuffer.lines = ['first line\\']; - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\r'); @@ -785,7 +821,9 @@ describe('InputPrompt', () => { await act(async () => { props.buffer.setText('some text to clear'); }); - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\x03'); // Ctrl+C character @@ -800,7 +838,9 @@ describe('InputPrompt', () => { it('should NOT clear the buffer on Ctrl+C if it is empty', async () => { props.buffer.text = ''; - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\x03'); // Ctrl+C character @@ -813,7 +853,9 @@ describe('InputPrompt', () => { }); it('should call setBannerVisible(false) when clear screen key is pressed', async () => { - const { stdin, unmount } = renderWithProviders(); + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); await act(async () => { stdin.write('\x0C'); // Ctrl+L @@ -918,7 +960,9 @@ describe('InputPrompt', () => { : [], }); - const { unmount } = renderWithProviders(); + const { unmount } = renderWithProviders(, { + uiActions, + }); await waitFor(() => { expect(mockedUseCommandCompletion).toHaveBeenCalledWith( @@ -1988,7 +2032,7 @@ describe('InputPrompt', () => { const { stdin, stdout, unmount } = renderWithProviders( , - { mouseEventsEnabled: true }, + { mouseEventsEnabled: true, uiActions }, ); // Wait for initial render @@ -2012,6 +2056,33 @@ describe('InputPrompt', () => { unmount(); }, ); + + it('should unfocus embedded shell on click', async () => { + props.buffer.text = 'hello'; + props.buffer.lines = ['hello']; + props.buffer.viewportVisualLines = ['hello']; + props.buffer.visualToLogicalMap = [[0, 0]]; + props.isEmbeddedShellFocused = true; + + const { stdin, stdout, unmount } = renderWithProviders( + , + { mouseEventsEnabled: true, uiActions }, + ); + await waitFor(() => { + expect(stdout.lastFrame()).toContain('hello'); + }); + + await act(async () => { + // Click somewhere in the prompt + stdin.write(`\x1b[<0;5;2M`); + }); + + await waitFor(() => { + expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false); + }); + + unmount(); + }); }); describe('queued message editing', () => { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 073cd1c16a..640ee5b2ca 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -7,7 +7,7 @@ import type React from 'react'; import clipboardy from 'clipboardy'; import { useCallback, useEffect, useState, useRef } from 'react'; -import { Box, Text, getBoundingBox, type DOMElement } from 'ink'; +import { Box, Text, type DOMElement } from 'ink'; import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js'; import { theme } from '../semantic-colors.js'; import { useInputHistory } from '../hooks/useInputHistory.js'; @@ -41,7 +41,9 @@ import { useShellFocusState } from '../contexts/ShellFocusContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { StreamingState } from '../types.js'; import { isSlashCommand } from '../utils/commandUtils.js'; +import { useMouseClick } from '../hooks/useMouseClick.js'; import { useMouse, type MouseEvent } from '../contexts/MouseContext.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; /** * Returns if the terminal can be trusted to handle paste events atomically @@ -126,6 +128,7 @@ export const InputPrompt: React.FC = ({ }) => { const kittyProtocol = useKittyKeyboardProtocol(); const isShellFocused = useShellFocusState(); + const { setEmbeddedShellFocused } = useUIActions(); const { mainAreaWidth } = useUIState(); const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const escPressCount = useRef(0); @@ -363,34 +366,26 @@ export const InputPrompt: React.FC = ({ } }, [buffer, config]); - const handleMouse = useCallback( - (event: MouseEvent) => { - if (event.name === 'left-press' && innerBoxRef.current) { - const { x, y, width, height } = getBoundingBox(innerBoxRef.current); - // Terminal mouse events are 1-based, Ink layout is 0-based. - const mouseX = event.col - 1; - const mouseY = event.row - 1; - if ( - mouseX >= x && - mouseX < x + width && - mouseY >= y && - mouseY < y + height - ) { - const relX = mouseX - x; - const relY = mouseY - y; - const visualRow = buffer.visualScrollRow + relY; - buffer.moveToVisualPosition(visualRow, relX); - return true; - } - } else if (event.name === 'right-release') { - handleClipboardPaste(); + useMouseClick( + innerBoxRef, + (_event, relX, relY) => { + if (isEmbeddedShellFocused) { + setEmbeddedShellFocused(false); } - return false; + const visualRow = buffer.visualScrollRow + relY; + buffer.moveToVisualPosition(visualRow, relX); }, - [buffer, handleClipboardPaste], + { isActive: focus }, ); - useMouse(handleMouse, { isActive: focus && !isEmbeddedShellFocused }); + useMouse( + (event: MouseEvent) => { + if (event.name === 'right-release') { + handleClipboardPaste(); + } + }, + { isActive: focus }, + ); const handleInput = useCallback( (key: Key) => { diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx new file mode 100644 index 0000000000..7f288f53a2 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { act } from 'react'; +import { + ShellToolMessage, + type ShellToolMessageProps, +} from './ShellToolMessage.js'; +import { StreamingState, ToolCallStatus } from '../../types.js'; +import { Text } from 'ink'; +import type { Config } from '@google/gemini-cli-core'; +import { renderWithProviders } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SHELL_TOOL_NAME } from '@google/gemini-cli-core'; +import { SHELL_COMMAND_NAME } from '../../constants.js'; +import { StreamingContext } from '../../contexts/StreamingContext.js'; + +vi.mock('../TerminalOutput.js', () => ({ + TerminalOutput: function MockTerminalOutput({ + cursor, + }: { + cursor: { x: number; y: number } | null; + }) { + return ( + + MockCursor:({cursor?.x},{cursor?.y}) + + ); + }, +})); + +// Mock child components or utilities if they are complex or have side effects +vi.mock('../GeminiRespondingSpinner.js', () => ({ + GeminiRespondingSpinner: ({ + nonRespondingDisplay, + }: { + nonRespondingDisplay?: string; + }) => { + const streamingState = React.useContext(StreamingContext)!; + if (streamingState === StreamingState.Responding) { + return MockRespondingSpinner; + } + return nonRespondingDisplay ? {nonRespondingDisplay} : null; + }, +})); + +vi.mock('../../utils/MarkdownDisplay.js', () => ({ + MarkdownDisplay: function MockMarkdownDisplay({ text }: { text: string }) { + return MockMarkdown:{text}; + }, +})); + +describe('', () => { + const baseProps: ShellToolMessageProps = { + callId: 'tool-123', + name: SHELL_COMMAND_NAME, + description: 'A shell command', + resultDisplay: 'Test result', + status: ToolCallStatus.Executing, + terminalWidth: 80, + confirmationDetails: undefined, + emphasis: 'medium', + isFirst: true, + borderColor: 'green', + borderDimColor: false, + config: { + getEnableInteractiveShell: () => true, + } as unknown as Config, + }; + + const mockSetEmbeddedShellFocused = vi.fn(); + const uiActions = { + setEmbeddedShellFocused: mockSetEmbeddedShellFocused, + }; + + // Helper to render with context + const renderWithContext = ( + ui: React.ReactElement, + streamingState: StreamingState, + ) => + renderWithProviders(ui, { + uiActions, + uiState: { streamingState }, + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('interactive shell focus', () => { + const shellProps: ShellToolMessageProps = { + ...baseProps, + }; + + it('clicks inside the shell area sets focus to true', async () => { + const { stdin, lastFrame, simulateClick } = renderWithProviders( + , + { + mouseEventsEnabled: true, + uiActions, + }, + ); + + await waitFor(() => { + expect(lastFrame()).toContain('A shell command'); // Wait for render + }); + + await simulateClick(stdin, 2, 2); // Click at column 2, row 2 (1-based) + + await waitFor(() => { + expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true); + }); + }); + + it('handles focus for SHELL_TOOL_NAME (core shell tool)', async () => { + const coreShellProps: ShellToolMessageProps = { + ...shellProps, + name: SHELL_TOOL_NAME, + }; + + const { stdin, lastFrame, simulateClick } = renderWithProviders( + , + { + mouseEventsEnabled: true, + uiActions, + }, + ); + + await waitFor(() => { + expect(lastFrame()).toContain('A shell command'); + }); + + await simulateClick(stdin, 2, 2); + + await waitFor(() => { + expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true); + }); + }); + + it('resets focus when shell finishes', async () => { + let updateStatus: (s: ToolCallStatus) => void = () => {}; + + const Wrapper = () => { + const [status, setStatus] = React.useState(ToolCallStatus.Executing); + updateStatus = setStatus; + return ( + + ); + }; + + const { lastFrame } = renderWithContext(, StreamingState.Idle); + + // Verify it is initially focused + await waitFor(() => { + expect(lastFrame()).toContain('(Focused)'); + }); + + // Now update status to Success + await act(async () => { + updateStatus(ToolCallStatus.Success); + }); + + // Should call setEmbeddedShellFocused(false) because isThisShellFocused became false + await waitFor(() => { + expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx new file mode 100644 index 0000000000..12d322c4fa --- /dev/null +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Box, Text, type DOMElement } from 'ink'; +import { ToolCallStatus } from '../../types.js'; +import { ShellInputPrompt } from '../ShellInputPrompt.js'; +import { StickyHeader } from '../StickyHeader.js'; +import { SHELL_COMMAND_NAME, SHELL_NAME } 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'; +import { + ToolStatusIndicator, + ToolInfo, + TrailingIndicator, + STATUS_INDICATOR_WIDTH, +} from './ToolShared.js'; +import type { ToolMessageProps } from './ToolMessage.js'; +import type { Config } from '@google/gemini-cli-core'; + +export interface ShellToolMessageProps extends ToolMessageProps { + activeShellPtyId?: number | null; + embeddedShellFocused?: boolean; + config?: Config; +} + +export const ShellToolMessage: React.FC = ({ + name, + description, + resultDisplay, + status, + availableTerminalHeight, + terminalWidth, + emphasis = 'medium', + renderOutputAsMarkdown = true, + activeShellPtyId, + embeddedShellFocused, + ptyId, + config, + isFirst, + borderColor, + borderDimColor, +}) => { + const isThisShellFocused = + (name === SHELL_COMMAND_NAME || + name === SHELL_NAME || + name === SHELL_TOOL_NAME) && + status === ToolCallStatus.Executing && + ptyId === activeShellPtyId && + embeddedShellFocused; + + const { setEmbeddedShellFocused } = useUIActions(); + const containerRef = React.useRef(null); + // 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(); + + useMouseClick( + containerRef, + () => { + if (isThisShellFocusable) { + setEmbeddedShellFocused(true); + } + }, + { isActive: !!isThisShellFocusable }, + ); + + const wasFocusedRef = React.useRef(false); + React.useEffect(() => { + if (isThisShellFocused) { + wasFocusedRef.current = true; + } else if (wasFocusedRef.current) { + if (embeddedShellFocused) { + setEmbeddedShellFocused(false); + } + wasFocusedRef.current = false; + } + }, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]); + + const [lastUpdateTime, setLastUpdateTime] = React.useState(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); + }, 5000); + + return () => clearTimeout(timer); + }, [lastUpdateTime]); + + React.useEffect(() => { + if (isThisShellFocused) { + setUserHasFocused(true); + } + }, [isThisShellFocused]); + + const shouldShowFocusHint = + isThisShellFocusable && (showFocusHint || userHasFocused); + + return ( + + + + + {shouldShowFocusHint && ( + + + {isThisShellFocused ? '(Focused)' : '(ctrl+f to focus)'} + + + )} + {emphasis === 'high' && } + + + + {isThisShellFocused && config && ( + + + + )} + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 9cf83e65e5..d41ff534d0 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -10,9 +10,11 @@ import { Box, Text } from 'ink'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; 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'; interface ToolGroupMessageProps { @@ -103,6 +105,25 @@ export const ToolGroupMessage: React.FC = ({ {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 commonProps = { + ...tool, + availableTerminalHeight: availableTerminalHeightPerToolMessage, + terminalWidth, + emphasis: isConfirming + ? ('high' as const) + : toolAwaitingApproval + ? ('low' as const) + : ('medium' as const), + isFirst, + borderColor, + borderDimColor, + }; + return ( = ({ minHeight={1} width={terminalWidth} > - + {isShellTool ? ( + + ) : ( + + )} ({ TerminalOutput: function MockTerminalOutput({ @@ -66,19 +67,6 @@ vi.mock('../../utils/MarkdownDisplay.js', () => ({ }, })); -// Helper to render with context -const renderWithContext = ( - ui: React.ReactElement, - streamingState: StreamingState, -) => { - const contextValue: StreamingState = streamingState; - return renderWithProviders( - - {ui} - , - ); -}; - describe('', () => { const baseProps: ToolMessageProps = { callId: 'tool-123', @@ -94,6 +82,25 @@ describe('', () => { borderDimColor: false, }; + const mockSetEmbeddedShellFocused = vi.fn(); + const uiActions = { + setEmbeddedShellFocused: mockSetEmbeddedShellFocused, + }; + + // Helper to render with context + const renderWithContext = ( + ui: React.ReactElement, + streamingState: StreamingState, + ) => + renderWithProviders(ui, { + uiActions, + uiState: { streamingState }, + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + it('renders basic tool information', () => { const { lastFrame } = renderWithContext( , diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 729a447573..d2d41d7766 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -4,48 +4,28 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { Box, Text } from 'ink'; +import type React from 'react'; +import { Box } from 'ink'; 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 { ShellInputPrompt } from '../ShellInputPrompt.js'; import { StickyHeader } from '../StickyHeader.js'; +import { ToolResultDisplay } from './ToolResultDisplay.js'; import { - SHELL_COMMAND_NAME, - SHELL_NAME, - TOOL_STATUS, -} from '../../constants.js'; -import { theme } from '../../semantic-colors.js'; -import type { AnsiOutput, Config } from '@google/gemini-cli-core'; -import { useUIState } from '../../contexts/UIStateContext.js'; -import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; + ToolStatusIndicator, + ToolInfo, + TrailingIndicator, + type TextEmphasis, +} from './ToolShared.js'; -const STATIC_HEIGHT = 1; -const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. -const STATUS_INDICATOR_WIDTH = 3; -const MIN_LINES_SHOWN = 2; // show at least this many lines - -// Large threshold to ensure we don't cause performance issues for very large -// outputs that will get truncated further MaxSizedBox anyway. -const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000; -export type TextEmphasis = 'high' | 'medium' | 'low'; +export type { TextEmphasis }; export interface ToolMessageProps extends IndividualToolCallDisplay { availableTerminalHeight?: number; terminalWidth: number; emphasis?: TextEmphasis; renderOutputAsMarkdown?: boolean; - activeShellPtyId?: number | null; - embeddedShellFocused?: boolean; isFirst: boolean; borderColor: string; borderDimColor: boolean; - config?: Config; } export const ToolMessage: React.FC = ({ @@ -57,285 +37,44 @@ export const ToolMessage: React.FC = ({ terminalWidth, emphasis = 'medium', renderOutputAsMarkdown = true, - activeShellPtyId, - embeddedShellFocused, - ptyId, - config, isFirst, borderColor, borderDimColor, -}) => { - const { renderMarkdown } = useUIState(); - const isAlternateBuffer = useAlternateBuffer(); - const isThisShellFocused = - (name === SHELL_COMMAND_NAME || name === 'Shell') && - status === ToolCallStatus.Executing && - ptyId === activeShellPtyId && - embeddedShellFocused; - - const [lastUpdateTime, setLastUpdateTime] = React.useState(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); - }, 5000); - - return () => clearTimeout(timer); - }, [lastUpdateTime]); - - React.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 availableHeight = availableTerminalHeight - ? Math.max( - availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT, - MIN_LINES_SHOWN + 1, // enforce minimum lines shown - ) - : undefined; - - // Long tool call response in MarkdownDisplay doesn't respect availableTerminalHeight properly, - // so if we aren't using alternate buffer mode, we're forcing it to not render as markdown when the response is too long, it will fallback - // to render as plain text, which is contained within the terminal using MaxSizedBox - if (availableHeight && !isAlternateBuffer) { - renderOutputAsMarkdown = false; - } - const combinedPaddingAndBorderWidth = 4; - const childWidth = terminalWidth - combinedPaddingAndBorderWidth; - - const truncatedResultDisplay = React.useMemo(() => { - if (typeof resultDisplay === 'string') { - if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) { - return '...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); - } - } - return resultDisplay; - }, [resultDisplay]); - - const renderedResult = React.useMemo(() => { - if (!truncatedResultDisplay) return null; - - return ( - - - {typeof truncatedResultDisplay === 'string' && - renderOutputAsMarkdown ? ( - - - - ) : typeof truncatedResultDisplay === 'string' && - !renderOutputAsMarkdown ? ( - isAlternateBuffer ? ( - - - {truncatedResultDisplay} - - - ) : ( - - - - {truncatedResultDisplay} - - - - ) - ) : typeof truncatedResultDisplay === 'object' && - 'fileDiff' in truncatedResultDisplay ? ( - - ) : typeof truncatedResultDisplay === 'object' && - 'todos' in truncatedResultDisplay ? ( - // display nothing, as the TodoTray will handle rendering todos - <> - ) : ( - - )} - - - ); - }, [ - truncatedResultDisplay, - renderOutputAsMarkdown, - childWidth, - renderMarkdown, - isAlternateBuffer, - availableHeight, - ]); - - return ( - <> - - - - {shouldShowFocusHint && ( - - - {isThisShellFocused ? '(Focused)' : '(ctrl+f to focus)'} - - - )} - {emphasis === 'high' && } - - - {renderedResult} - {isThisShellFocused && config && ( - - - - )} - - - ); -}; - -type ToolStatusIndicatorProps = { - status: ToolCallStatus; - name: string; -}; - -const ToolStatusIndicator: React.FC = ({ - status, - name, -}) => { - const isShell = name === SHELL_COMMAND_NAME || name === SHELL_NAME; - const statusColor = isShell ? theme.ui.symbol : theme.status.warning; - - return ( - - {status === ToolCallStatus.Pending && ( - {TOOL_STATUS.PENDING} - )} - {status === ToolCallStatus.Executing && ( - - )} - {status === ToolCallStatus.Success && ( - - {TOOL_STATUS.SUCCESS} - - )} - {status === ToolCallStatus.Confirming && ( - - {TOOL_STATUS.CONFIRMING} - - )} - {status === ToolCallStatus.Canceled && ( - - {TOOL_STATUS.CANCELED} - - )} - {status === ToolCallStatus.Error && ( - - {TOOL_STATUS.ERROR} - - )} +}) => ( + + + + + {emphasis === 'high' && } + + + - ); -}; - -type ToolInfo = { - name: string; - description: string; - status: ToolCallStatus; - emphasis: TextEmphasis; -}; -const ToolInfo: React.FC = ({ - name, - description, - status, - emphasis, -}) => { - const nameColor = React.useMemo(() => { - switch (emphasis) { - case 'high': - return theme.text.primary; - case 'medium': - return theme.text.primary; - case 'low': - return theme.text.secondary; - default: { - const exhaustiveCheck: never = emphasis; - return exhaustiveCheck; - } - } - }, [emphasis]); - return ( - - - - {name} - {' '} - {description} - - - ); -}; - -const TrailingIndicator: React.FC = () => ( - - {' '} - ← - + ); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx new file mode 100644 index 0000000000..b02f616eb0 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Box, Text } from 'ink'; +import { DiffRenderer } from './DiffRenderer.js'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; +import { AnsiOutputText } from '../AnsiOutput.js'; +import { MaxSizedBox } from '../shared/MaxSizedBox.js'; +import { theme } from '../../semantic-colors.js'; +import type { AnsiOutput } from '@google/gemini-cli-core'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; + +const STATIC_HEIGHT = 1; +const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. +const MIN_LINES_SHOWN = 2; // show at least this many lines + +// Large threshold to ensure we don't cause performance issues for very large +// outputs that will get truncated further MaxSizedBox anyway. +const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000; + +export interface ToolResultDisplayProps { + resultDisplay: string | object | undefined; + availableTerminalHeight?: number; + terminalWidth: number; + renderOutputAsMarkdown?: boolean; +} + +interface FileDiffResult { + fileDiff: string; + fileName: string; +} + +export const ToolResultDisplay: React.FC = ({ + resultDisplay, + availableTerminalHeight, + terminalWidth, + renderOutputAsMarkdown = true, +}) => { + const { renderMarkdown } = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); + + const availableHeight = availableTerminalHeight + ? Math.max( + availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT, + MIN_LINES_SHOWN + 1, // enforce minimum lines shown + ) + : undefined; + + // Long tool call response in MarkdownDisplay doesn't respect availableTerminalHeight properly, + // so if we aren't using alternate buffer mode, we're forcing it to not render as markdown when the response is too long, it will fallback + // to render as plain text, which is contained within the terminal using MaxSizedBox + if (availableHeight && !isAlternateBuffer) { + renderOutputAsMarkdown = false; + } + + const combinedPaddingAndBorderWidth = 4; + const childWidth = terminalWidth - combinedPaddingAndBorderWidth; + + const truncatedResultDisplay = React.useMemo(() => { + if (typeof resultDisplay === 'string') { + if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) { + return '...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); + } + } + return resultDisplay; + }, [resultDisplay]); + + if (!truncatedResultDisplay) return null; + + return ( + + + {typeof truncatedResultDisplay === 'string' && + renderOutputAsMarkdown ? ( + + + + ) : typeof truncatedResultDisplay === 'string' && + !renderOutputAsMarkdown ? ( + isAlternateBuffer ? ( + + + {truncatedResultDisplay} + + + ) : ( + + + + {truncatedResultDisplay} + + + + ) + ) : typeof truncatedResultDisplay === 'object' && + 'fileDiff' in truncatedResultDisplay ? ( + + ) : typeof truncatedResultDisplay === 'object' && + 'todos' in truncatedResultDisplay ? ( + // display nothing, as the TodoTray will handle rendering todos + <> + ) : ( + + )} + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolShared.tsx b/packages/cli/src/ui/components/messages/ToolShared.tsx new file mode 100644 index 0000000000..df567ddb3f --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolShared.tsx @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Box, Text } from 'ink'; +import { ToolCallStatus } from '../../types.js'; +import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; +import { + SHELL_COMMAND_NAME, + SHELL_NAME, + TOOL_STATUS, +} from '../../constants.js'; +import { theme } from '../../semantic-colors.js'; +import { SHELL_TOOL_NAME } from '@google/gemini-cli-core'; + +export const STATUS_INDICATOR_WIDTH = 3; + +export type TextEmphasis = 'high' | 'medium' | 'low'; + +type ToolStatusIndicatorProps = { + status: ToolCallStatus; + name: string; +}; + +export const ToolStatusIndicator: React.FC = ({ + status, + name, +}) => { + const isShell = + name === SHELL_COMMAND_NAME || + name === SHELL_NAME || + name === SHELL_TOOL_NAME; + const statusColor = isShell ? theme.ui.symbol : theme.status.warning; + + return ( + + {status === ToolCallStatus.Pending && ( + {TOOL_STATUS.PENDING} + )} + {status === ToolCallStatus.Executing && ( + + )} + {status === ToolCallStatus.Success && ( + + {TOOL_STATUS.SUCCESS} + + )} + {status === ToolCallStatus.Confirming && ( + + {TOOL_STATUS.CONFIRMING} + + )} + {status === ToolCallStatus.Canceled && ( + + {TOOL_STATUS.CANCELED} + + )} + {status === ToolCallStatus.Error && ( + + {TOOL_STATUS.ERROR} + + )} + + ); +}; + +type ToolInfoProps = { + name: string; + description: string; + status: ToolCallStatus; + emphasis: TextEmphasis; +}; + +export const ToolInfo: React.FC = ({ + name, + description, + status, + emphasis, +}) => { + const nameColor = React.useMemo(() => { + switch (emphasis) { + case 'high': + return theme.text.primary; + case 'medium': + return theme.text.primary; + case 'low': + return theme.text.secondary; + default: { + const exhaustiveCheck: never = emphasis; + return exhaustiveCheck; + } + } + }, [emphasis]); + return ( + + + + {name} + {' '} + {description} + + + ); +}; + +export const TrailingIndicator: React.FC = () => ( + + {' '} + ← + +); diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index e276f38bea..d9de9994d5 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -50,6 +50,7 @@ export interface UIActions { handleApiKeySubmit: (apiKey: string) => Promise; handleApiKeyCancel: () => void; setBannerVisible: (visible: boolean) => void; + setEmbeddedShellFocused: (value: boolean) => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx index aac77e6a28..e99b594d0d 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx @@ -419,11 +419,10 @@ describe('useShellCommandProcessor', () => { }); await act(async () => await execPromise); - const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0]; - expect(finalHistoryItem.tools[0].status).toBe(ToolCallStatus.Canceled); - expect(finalHistoryItem.tools[0].resultDisplay).toContain( - 'Command was cancelled.', - ); + // With the new logic, cancelled commands are not added to history by this hook + // to avoid duplication/flickering, as they are handled by useGeminiStream. + expect(addItemToHistoryMock).toHaveBeenCalledTimes(1); + expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null); expect(setShellInputFocusedMock).toHaveBeenCalledWith(false); }); diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index abd66b829c..a463c0f4df 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -284,13 +284,17 @@ export const useShellCommandProcessor = ( }; // Add the complete, contextual result to the local UI history. - addItemToHistory( - { - type: 'tool_group', - tools: [finalToolDisplay], - } as HistoryItemWithoutId, - userMessageTimestamp, - ); + // We skip this for cancelled commands because useGeminiStream handles the + // immediate addition of the cancelled item to history to prevent flickering/duplicates. + if (finalStatus !== ToolCallStatus.Canceled) { + addItemToHistory( + { + type: 'tool_group', + tools: [finalToolDisplay], + } as HistoryItemWithoutId, + userMessageTimestamp, + ); + } // Add the same complete, contextual result to the LLM's history. addShellCommandToGeminiHistory( diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 5840752996..f6fe51bb63 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -54,6 +54,7 @@ import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; import { useStateAndRef } from './useStateAndRef.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { useLogger } from './useLogger.js'; +import { SHELL_COMMAND_NAME } from '../constants.js'; import { useReactToolScheduler, mapToDisplay as mapTrackedToolCallsToDisplay, @@ -232,6 +233,22 @@ export const useGeminiStream = ( } }, [activePtyId, setShellInputFocused]); + const prevActiveShellPtyIdRef = useRef(null); + useEffect(() => { + if ( + turnCancelledRef.current && + prevActiveShellPtyIdRef.current !== null && + activeShellPtyId === null + ) { + addItem( + { type: MessageType.INFO, text: 'Request cancelled.' }, + Date.now(), + ); + setIsResponding(false); + } + prevActiveShellPtyIdRef.current = activeShellPtyId; + }, [activeShellPtyId, addItem]); + const streamingState = useMemo(() => { if (toolCalls.some((tc) => tc.status === 'awaiting_approval')) { return StreamingState.WaitingForConfirmation; @@ -306,7 +323,33 @@ export const useGeminiStream = ( cancelAllToolCalls(abortControllerRef.current.signal); if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, Date.now()); + const isShellCommand = + pendingHistoryItemRef.current.type === 'tool_group' && + pendingHistoryItemRef.current.tools.some( + (t) => t.name === SHELL_COMMAND_NAME, + ); + + // If it is a shell command, we update the status to Canceled and clear the output + // to avoid artifacts, then add it to history immediately. + if (isShellCommand) { + const toolGroup = pendingHistoryItemRef.current as HistoryItemToolGroup; + const updatedTools = toolGroup.tools.map((tool) => { + if (tool.name === SHELL_COMMAND_NAME) { + return { + ...tool, + status: ToolCallStatus.Canceled, + resultDisplay: tool.resultDisplay, + }; + } + return tool; + }); + addItem( + { ...toolGroup, tools: updatedTools } as HistoryItemWithoutId, + Date.now(), + ); + } else { + addItem(pendingHistoryItemRef.current, Date.now()); + } } setPendingHistoryItem(null); @@ -314,14 +357,18 @@ export const useGeminiStream = ( // Otherwise, we let handleCompletedTools figure out the next step, // which might involve sending partial results back to the model. if (isFullCancellation) { - addItem( - { - type: MessageType.INFO, - text: 'Request cancelled.', - }, - Date.now(), - ); - setIsResponding(false); + // If shell is active, we delay this message to ensure correct ordering + // (Shell item first, then Info message). + if (!activeShellPtyId) { + addItem( + { + type: MessageType.INFO, + text: 'Request cancelled.', + }, + Date.now(), + ); + setIsResponding(false); + } } onCancelSubmit(false); @@ -335,6 +382,7 @@ export const useGeminiStream = ( setShellInputFocused, cancelAllToolCalls, toolCalls, + activeShellPtyId, ]); useKeypress( diff --git a/packages/cli/src/ui/hooks/useMouseClick.test.ts b/packages/cli/src/ui/hooks/useMouseClick.test.ts new file mode 100644 index 0000000000..5808ed5809 --- /dev/null +++ b/packages/cli/src/ui/hooks/useMouseClick.test.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; +import { renderHook } from '../../test-utils/render.js'; +import { useMouseClick } from './useMouseClick.js'; +import { getBoundingBox, type DOMElement } from 'ink'; +import type React from 'react'; + +// Mock ink +vi.mock('ink', async () => ({ + getBoundingBox: vi.fn(), +})); + +// Mock MouseContext +const mockUseMouse = vi.fn(); +vi.mock('../contexts/MouseContext.js', async () => ({ + useMouse: (cb: unknown, opts: unknown) => mockUseMouse(cb, opts), +})); + +describe('useMouseClick', () => { + let handler: Mock; + let containerRef: React.RefObject; + + beforeEach(() => { + vi.clearAllMocks(); + handler = vi.fn(); + containerRef = { current: {} as DOMElement }; + }); + + it('should call handler with relative coordinates when click is inside bounds', () => { + vi.mocked(getBoundingBox).mockReturnValue({ + x: 10, + y: 5, + width: 20, + height: 10, + } as unknown as ReturnType); + + renderHook(() => useMouseClick(containerRef, handler)); + + // Get the callback registered with useMouse + expect(mockUseMouse).toHaveBeenCalled(); + const callback = mockUseMouse.mock.calls[0][0]; + + // Simulate click inside: x=15 (col 16), y=7 (row 8) + // Terminal events are 1-based. col 16 -> mouseX 15. row 8 -> mouseY 7. + // relativeX = 15 - 10 = 5 + // relativeY = 7 - 5 = 2 + callback({ name: 'left-press', col: 16, row: 8 }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'left-press' }), + 5, + 2, + ); + }); + + it('should not call handler when click is outside bounds', () => { + vi.mocked(getBoundingBox).mockReturnValue({ + x: 10, + y: 5, + width: 20, + height: 10, + } as unknown as ReturnType); + + renderHook(() => useMouseClick(containerRef, handler)); + const callback = mockUseMouse.mock.calls[0][0]; + + // Click outside: x=5 (col 6), y=7 (row 8) -> left of box + callback({ name: 'left-press', col: 6, row: 8 }); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/hooks/useMouseClick.ts b/packages/cli/src/ui/hooks/useMouseClick.ts new file mode 100644 index 0000000000..18ff9ad6f7 --- /dev/null +++ b/packages/cli/src/ui/hooks/useMouseClick.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getBoundingBox, type DOMElement } from 'ink'; +import type React from 'react'; +import { useMouse, type MouseEvent } from '../contexts/MouseContext.js'; + +export const useMouseClick = ( + containerRef: React.RefObject, + handler: (event: MouseEvent, relativeX: number, relativeY: number) => void, + options: { isActive?: boolean; button?: 'left' | 'right' } = {}, +) => { + const { isActive = true, button = 'left' } = options; + + useMouse( + (event: MouseEvent) => { + const eventName = button === 'left' ? 'left-press' : 'right-release'; + if (event.name === eventName && containerRef.current) { + const { x, y, width, height } = getBoundingBox(containerRef.current); + // Terminal mouse events are 1-based, Ink layout is 0-based. + const mouseX = event.col - 1; + const mouseY = event.row - 1; + + const relativeX = mouseX - x; + const relativeY = mouseY - y; + + if ( + relativeX >= 0 && + relativeX < width && + relativeY >= 0 && + relativeY < height + ) { + handler(event, relativeX, relativeY); + } + } + }, + { isActive }, + ); +};