diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 01fce895f3..5eaa6922a5 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -109,7 +109,7 @@ import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js'; import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useSettings } from './contexts/SettingsContext.js'; -const CTRL_EXIT_PROMPT_DURATION_MS = 1000; +const WARNING_PROMPT_DURATION_MS = 1000; const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { @@ -892,6 +892,7 @@ Logging in with Google... Please restart Gemini CLI to continue. >(); const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); + const [selectionWarning, setSelectionWarning] = useState(false); const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); @@ -901,6 +902,26 @@ Logging in with Google... Please restart Gemini CLI to continue. } = useIdeTrustListener(); const isInitialMount = useRef(true); + useEffect(() => { + let timeoutId: NodeJS.Timeout; + const handleSelectionWarning = () => { + setSelectionWarning(true); + if (timeoutId) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => { + setSelectionWarning(false); + }, WARNING_PROMPT_DURATION_MS); + }; + appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning); + return () => { + appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning); + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, []); + useEffect(() => { if (ideNeedsRestart) { // IDE trust changed, force a restart. @@ -976,7 +997,7 @@ Logging in with Google... Please restart Gemini CLI to continue. ctrlCTimerRef.current = setTimeout(() => { setCtrlCPressCount(0); ctrlCTimerRef.current = null; - }, CTRL_EXIT_PROMPT_DURATION_MS); + }, WARNING_PROMPT_DURATION_MS); } }, [ctrlCPressCount, config, setCtrlCPressCount, handleSlashCommand]); @@ -994,7 +1015,7 @@ Logging in with Google... Please restart Gemini CLI to continue. ctrlDTimerRef.current = setTimeout(() => { setCtrlDPressCount(0); ctrlDTimerRef.current = null; - }, CTRL_EXIT_PROMPT_DURATION_MS); + }, WARNING_PROMPT_DURATION_MS); } }, [ctrlDPressCount, config, setCtrlDPressCount, handleSlashCommand]); @@ -1345,6 +1366,7 @@ Logging in with Google... Please restart Gemini CLI to continue. embeddedShellFocused, showDebugProfiler, copyModeEnabled, + selectionWarning, }), [ isThemeDialogOpen, @@ -1430,6 +1452,7 @@ Logging in with Google... Please restart Gemini CLI to continue. apiKeyDefaultValue, authState, copyModeEnabled, + selectionWarning, ], ); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 6962ab2160..6654541816 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -99,6 +99,10 @@ export const Composer = () => { Press Ctrl+C again to exit. + ) : uiState.selectionWarning ? ( + + Press Ctrl-S to enter selection mode to copy text. + ) : uiState.ctrlDPressedOnce ? ( Press Ctrl+D again to exit. diff --git a/packages/cli/src/ui/components/Help.test.tsx b/packages/cli/src/ui/components/Help.test.tsx index c43c1e71df..6a2907350b 100644 --- a/packages/cli/src/ui/components/Help.test.tsx +++ b/packages/cli/src/ui/components/Help.test.tsx @@ -60,4 +60,15 @@ describe('Help Component', () => { expect(output).not.toContain('hidden-child'); unmount(); }); + + it('should render keyboard shortcuts', () => { + const { lastFrame, unmount } = render(); + const output = lastFrame(); + + expect(output).toContain('Keyboard Shortcuts:'); + expect(output).toContain('Ctrl+C'); + expect(output).toContain('Ctrl+S'); + expect(output).toContain('Page Up/Down'); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx index a3e124d759..385f7edfa3 100644 --- a/packages/cli/src/ui/components/Help.tsx +++ b/packages/cli/src/ui/components/Help.tsx @@ -136,6 +136,12 @@ export const Help: React.FC = ({ commands }) => ( {' '} - Clear the screen + + + Ctrl+S + {' '} + - Enter selection mode to copy text + {process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'} @@ -160,6 +166,12 @@ export const Help: React.FC = ({ commands }) => ( {' '} - Cancel operation / Clear input (double press) + + + Page Up/Down + {' '} + - Scroll page up/down + Shift+Tab diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 06bbf4da74..5e908614db 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -379,8 +379,10 @@ export const InputPrompt: React.FC = ({ const relY = mouseY - y; const visualRow = buffer.visualScrollRow + relY; buffer.moveToVisualPosition(visualRow, relX); + return true; } } + return false; }, [buffer], ); diff --git a/packages/cli/src/ui/contexts/MouseContext.test.tsx b/packages/cli/src/ui/contexts/MouseContext.test.tsx index fb57f52519..0224721568 100644 --- a/packages/cli/src/ui/contexts/MouseContext.test.tsx +++ b/packages/cli/src/ui/contexts/MouseContext.test.tsx @@ -11,6 +11,7 @@ import { vi, type Mock } from 'vitest'; import type React from 'react'; import { useStdin } from 'ink'; import { EventEmitter } from 'node:events'; +import { appEvents, AppEvent } from '../../utils/events.js'; // Mock the 'ink' module to control stdin vi.mock('ink', async (importOriginal) => { @@ -21,6 +22,18 @@ vi.mock('ink', async (importOriginal) => { }; }); +// Mock appEvents +vi.mock('../../utils/events.js', () => ({ + appEvents: { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }, + AppEvent: { + SelectionWarning: 'selection-warning', + }, +})); + class MockStdin extends EventEmitter { isTTY = true; setRawMode = vi.fn(); @@ -47,6 +60,7 @@ describe('MouseContext', () => { wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); + vi.mocked(appEvents.emit).mockClear(); }); afterEach(() => { @@ -91,6 +105,34 @@ describe('MouseContext', () => { expect(handler).not.toHaveBeenCalled(); }); + it('should emit SelectionWarning when move event is unhandled and has coordinates', () => { + renderHook(() => useMouseContext(), { wrapper }); + + act(() => { + // Move event (32) at 10, 20 + stdin.write('\x1b[<32;10;20M'); + }); + + expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.SelectionWarning); + }); + + it('should not emit SelectionWarning when move event is handled', () => { + const handler = vi.fn().mockReturnValue(true); + const { result } = renderHook(() => useMouseContext(), { wrapper }); + + act(() => { + result.current.subscribe(handler); + }); + + act(() => { + // Move event (32) at 10, 20 + stdin.write('\x1b[<32;10;20M'); + }); + + expect(handler).toHaveBeenCalled(); + expect(appEvents.emit).not.toHaveBeenCalled(); + }); + describe('SGR Mouse Events', () => { it.each([ { diff --git a/packages/cli/src/ui/contexts/MouseContext.tsx b/packages/cli/src/ui/contexts/MouseContext.tsx index 8b1b8876a8..fef5b8c343 100644 --- a/packages/cli/src/ui/contexts/MouseContext.tsx +++ b/packages/cli/src/ui/contexts/MouseContext.tsx @@ -15,6 +15,7 @@ import { } from 'react'; import { ESC } from '../utils/input.js'; import { debugLogger } from '@google/gemini-cli-core'; +import { appEvents, AppEvent } from '../../utils/events.js'; import { isIncompleteMouseSequence, parseMouseEvent, @@ -89,8 +90,23 @@ export function MouseProvider({ let mouseBuffer = ''; const broadcast = (event: MouseEvent) => { + let handled = false; for (const handler of subscribers) { - handler(event); + if (handler(event) === true) { + handled = true; + } + } + if ( + !handled && + event.name === 'move' && + event.col >= 0 && + event.row >= 0 + ) { + // Terminal apps only receive mouse move events when the mouse is down + // so this always indicates a mouse drag that the user was expecting + // would trigger text selection but does not as we are handling mouse + // events not the terminal. + appEvents.emit(AppEvent.SelectionWarning); } }; diff --git a/packages/cli/src/ui/contexts/ScrollProvider.test.tsx b/packages/cli/src/ui/contexts/ScrollProvider.test.tsx index a741579f15..df1906b610 100644 --- a/packages/cli/src/ui/contexts/ScrollProvider.test.tsx +++ b/packages/cli/src/ui/contexts/ScrollProvider.test.tsx @@ -16,12 +16,12 @@ import { Box, type DOMElement } from 'ink'; import type { MouseEvent } from '../hooks/useMouse.js'; // Mock useMouse hook -const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void>(); +const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void | boolean>(); vi.mock('../hooks/useMouse.js', async () => { // We need to import React dynamically because this factory runs before top-level imports const React = await import('react'); return { - useMouse: (callback: (event: MouseEvent) => void) => { + useMouse: (callback: (event: MouseEvent) => void | boolean) => { React.useEffect(() => { mockUseMouseCallbacks.add(callback); return () => { @@ -81,6 +81,81 @@ describe('ScrollProvider', () => { vi.useRealTimers(); }); + describe('Event Handling Status', () => { + it('returns true when scroll event is handled', () => { + const scrollBy = vi.fn(); + const getScrollState = vi.fn(() => ({ + scrollTop: 0, + scrollHeight: 100, + innerHeight: 10, + })); + + render( + + + , + ); + + let handled = false; + for (const callback of mockUseMouseCallbacks) { + if ( + callback({ + name: 'scroll-down', + col: 5, + row: 5, + shift: false, + ctrl: false, + meta: false, + }) === true + ) { + handled = true; + } + } + expect(handled).toBe(true); + }); + + it('returns false when scroll event is ignored (cannot scroll further)', () => { + const scrollBy = vi.fn(); + // Already at bottom + const getScrollState = vi.fn(() => ({ + scrollTop: 90, + scrollHeight: 100, + innerHeight: 10, + })); + + render( + + + , + ); + + let handled = false; + for (const callback of mockUseMouseCallbacks) { + if ( + callback({ + name: 'scroll-down', + col: 5, + row: 5, + shift: false, + ctrl: false, + meta: false, + }) === true + ) { + handled = true; + } + } + expect(handled).toBe(false); + }); + }); + it('calls scrollTo when clicking scrollbar track if available', async () => { const scrollBy = vi.fn(); const scrollTo = vi.fn(); diff --git a/packages/cli/src/ui/contexts/ScrollProvider.tsx b/packages/cli/src/ui/contexts/ScrollProvider.tsx index 3c272f596d..b461622fb2 100644 --- a/packages/cli/src/ui/contexts/ScrollProvider.tsx +++ b/packages/cli/src/ui/contexts/ScrollProvider.tsx @@ -146,15 +146,16 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ if (direction === 'up' && canScrollUp) { pendingScrollsRef.current.set(candidate.id, pendingDelta + delta); scheduleFlush(); - return; + return true; } if (direction === 'down' && canScrollDown) { pendingScrollsRef.current.set(candidate.id, pendingDelta + delta); scheduleFlush(); - return; + return true; } } + return false; }; const handleLeftPress = (mouseEvent: MouseEvent) => { @@ -238,7 +239,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ id: entry.id, offset, }; - return; + return true; } } @@ -250,21 +251,27 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ if (candidates.length > 0) { // The first candidate is the innermost one. candidates[0].flashScrollbar(); + // We don't consider just flashing the scrollbar as handling the event + // in a way that should prevent other handlers (like drag warning) + // from checking it, although for left-press it doesn't matter much. + // But returning false is safer. + return false; } + return false; }; const handleMove = (mouseEvent: MouseEvent) => { const state = dragStateRef.current; - if (!state.active || !state.id) return; + if (!state.active || !state.id) return false; const entry = scrollablesRef.current.get(state.id); if (!entry || !entry.ref.current) { state.active = false; - return; + return false; } const boundingBox = getBoundingBox(entry.ref.current); - if (!boundingBox) return; + if (!boundingBox) return false; const { y } = boundingBox; const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState(); @@ -276,7 +283,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ const maxScrollTop = scrollHeight - innerHeight; const maxThumbY = innerHeight - thumbHeight; - if (maxThumbY <= 0) return; + if (maxThumbY <= 0) return false; const relativeMouseY = mouseEvent.row - y; // Calculate the target thumb position based on the mouse position and the offset. @@ -295,6 +302,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ } else { entry.scrollBy(targetScrollTop - scrollTop); } + return true; }; const handleLeftRelease = () => { @@ -304,22 +312,25 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ id: null, offset: 0, }; + return true; } + return false; }; useMouse( (event: MouseEvent) => { if (event.name === 'scroll-up') { - handleScroll('up', event); + return handleScroll('up', event); } else if (event.name === 'scroll-down') { - handleScroll('down', event); + return handleScroll('down', event); } else if (event.name === 'left-press') { - handleLeftPress(event); + return handleLeftPress(event); } else if (event.name === 'move') { - handleMove(event); + return handleMove(event); } else if (event.name === 'left-release') { - handleLeftRelease(); + return handleLeftRelease(); } + return false; }, { isActive: true }, ); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 7812ea3431..6fc19b2808 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -124,6 +124,7 @@ export interface UIState { showDebugProfiler: boolean; showFullTodos: boolean; copyModeEnabled: boolean; + selectionWarning: boolean; } export const UIStateContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 99afe37b10..503fc7e718 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -28,8 +28,27 @@ import { } from '@google/gemini-cli-core'; import { appEvents } from '../../utils/events.js'; -const { logSlashCommand } = vi.hoisted(() => ({ +const { + logSlashCommand, + mockBuiltinLoadCommands, + mockFileLoadCommands, + mockMcpLoadCommands, + mockIdeClientGetInstance, + mockUseAlternateBuffer, +} = vi.hoisted(() => ({ logSlashCommand: vi.fn(), + mockBuiltinLoadCommands: vi.fn().mockResolvedValue([]), + mockFileLoadCommands: vi.fn().mockResolvedValue([]), + mockMcpLoadCommands: vi.fn().mockResolvedValue([]), + mockIdeClientGetInstance: vi.fn().mockResolvedValue({ + addStatusChangeListener: vi.fn(), + removeStatusChangeListener: vi.fn(), + }), + mockUseAlternateBuffer: vi.fn().mockReturnValue(false), +})); + +vi.mock('./useAlternateBuffer.js', () => ({ + useAlternateBuffer: mockUseAlternateBuffer, })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -41,10 +60,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { logSlashCommand, getIdeInstaller: vi.fn().mockReturnValue(null), IdeClient: { - getInstance: vi.fn().mockResolvedValue({ - addStatusChangeListener: vi.fn(), - removeStatusChangeListener: vi.fn(), - }), + getInstance: mockIdeClientGetInstance, }, }; }); @@ -65,23 +81,20 @@ vi.mock('node:process', () => { }; }); -const mockBuiltinLoadCommands = vi.fn(); vi.mock('../../services/BuiltinCommandLoader.js', () => ({ - BuiltinCommandLoader: vi.fn().mockImplementation(() => ({ + BuiltinCommandLoader: vi.fn(() => ({ loadCommands: mockBuiltinLoadCommands, })), })); -const mockFileLoadCommands = vi.fn(); vi.mock('../../services/FileCommandLoader.js', () => ({ - FileCommandLoader: vi.fn().mockImplementation(() => ({ + FileCommandLoader: vi.fn(() => ({ loadCommands: mockFileLoadCommands, })), })); -const mockMcpLoadCommands = vi.fn(); vi.mock('../../services/McpPromptLoader.js', () => ({ - McpPromptLoader: vi.fn().mockImplementation(() => ({ + McpPromptLoader: vi.fn(() => ({ loadCommands: mockMcpLoadCommands, })), })); @@ -130,6 +143,12 @@ describe('useSlashCommandProcessor', () => { mockBuiltinLoadCommands.mockResolvedValue([]); mockFileLoadCommands.mockResolvedValue([]); mockMcpLoadCommands.mockResolvedValue([]); + mockUseAlternateBuffer.mockReturnValue(false); + mockIdeClientGetInstance.mockResolvedValue({ + addStatusChangeListener: vi.fn(), + removeStatusChangeListener: vi.fn(), + }); + vi.spyOn(console, 'clear').mockImplementation(() => {}); }); afterEach(async () => { @@ -137,6 +156,7 @@ describe('useSlashCommandProcessor', () => { await unmountHook(); unmountHook = undefined; } + vi.restoreAllMocks(); }); const setupProcessorHook = async ( @@ -205,6 +225,44 @@ describe('useSlashCommandProcessor', () => { }; }; + describe('Console Clear Safety', () => { + it('should not call console.clear if alternate buffer is active', async () => { + mockUseAlternateBuffer.mockReturnValue(true); + const clearCommand = createTestCommand({ + name: 'clear', + action: async (context) => { + context.ui.clear(); + }, + }); + const result = await setupProcessorHook([clearCommand]); + + await act(async () => { + await result.current.handleSlashCommand('/clear'); + }); + + expect(mockClearItems).toHaveBeenCalled(); + expect(console.clear).not.toHaveBeenCalled(); + }); + + it('should call console.clear if alternate buffer is not active', async () => { + mockUseAlternateBuffer.mockReturnValue(false); + const clearCommand = createTestCommand({ + name: 'clear', + action: async (context) => { + context.ui.clear(); + }, + }); + const result = await setupProcessorHook([clearCommand]); + + await act(async () => { + await result.current.handleSlashCommand('/clear'); + }); + + expect(mockClearItems).toHaveBeenCalled(); + expect(console.clear).toHaveBeenCalled(); + }); + }); + describe('Initialization and Command Loading', () => { it('should initialize CommandService with all required loaders', async () => { await setupProcessorHook(); @@ -947,36 +1005,37 @@ describe('useSlashCommandProcessor', () => { describe('Slash Command Logging', () => { const mockCommandAction = vi.fn().mockResolvedValue({ type: 'handled' }); - const loggingTestCommands: SlashCommand[] = [ - createTestCommand({ - name: 'logtest', - action: vi - .fn() - .mockResolvedValue({ type: 'message', content: 'hello world' }), - }), - createTestCommand({ - name: 'logwithsub', - subCommands: [ - createTestCommand({ - name: 'sub', - action: mockCommandAction, - }), - ], - }), - createTestCommand({ - name: 'fail', - action: vi.fn().mockRejectedValue(new Error('oh no!')), - }), - createTestCommand({ - name: 'logalias', - altNames: ['la'], - action: mockCommandAction, - }), - ]; + let loggingTestCommands: SlashCommand[]; beforeEach(() => { mockCommandAction.mockClear(); vi.mocked(logSlashCommand).mockClear(); + loggingTestCommands = [ + createTestCommand({ + name: 'logtest', + action: vi + .fn() + .mockResolvedValue({ type: 'message', content: 'hello world' }), + }), + createTestCommand({ + name: 'logwithsub', + subCommands: [ + createTestCommand({ + name: 'sub', + action: mockCommandAction, + }), + ], + }), + createTestCommand({ + name: 'fail', + action: vi.fn().mockRejectedValue(new Error('oh no!')), + }), + createTestCommand({ + name: 'logalias', + altNames: ['la'], + action: mockCommandAction, + }), + ]; }); it.each([ diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 692869f167..47d9ea7f10 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -44,6 +44,7 @@ import { type ExtensionUpdateStatus, } from '../state/extensions.js'; import { appEvents } from '../../utils/events.js'; +import { useAlternateBuffer } from './useAlternateBuffer.js'; interface SlashCommandProcessorActions { openAuthDialog: () => void; @@ -81,6 +82,7 @@ export const useSlashCommandProcessor = ( const [commands, setCommands] = useState( undefined, ); + const alternateBuffer = useAlternateBuffer(); const [reloadTrigger, setReloadTrigger] = useState(0); const reloadCommands = useCallback(() => { @@ -196,7 +198,9 @@ export const useSlashCommandProcessor = ( addItem, clear: () => { clearItems(); - console.clear(); + if (!alternateBuffer) { + console.clear(); + } refreshStatic(); }, loadHistory, @@ -218,6 +222,7 @@ export const useSlashCommandProcessor = ( }, }), [ + alternateBuffer, config, settings, gitService, diff --git a/packages/cli/src/ui/utils/mouse.ts b/packages/cli/src/ui/utils/mouse.ts index 1632c64c74..e9381273ba 100644 --- a/packages/cli/src/ui/utils/mouse.ts +++ b/packages/cli/src/ui/utils/mouse.ts @@ -35,7 +35,7 @@ export interface MouseEvent { ctrl: boolean; } -export type MouseHandler = (event: MouseEvent) => void; +export type MouseHandler = (event: MouseEvent) => void | boolean; export function getMouseEventName( buttonCode: number, diff --git a/packages/cli/src/utils/events.ts b/packages/cli/src/utils/events.ts index ac714fd8e6..50415d150f 100644 --- a/packages/cli/src/utils/events.ts +++ b/packages/cli/src/utils/events.ts @@ -13,6 +13,7 @@ export enum AppEvent { OauthDisplayMessage = 'oauth-display-message', Flicker = 'flicker', McpClientUpdate = 'mcp-client-update', + SelectionWarning = 'selection-warning', } export interface AppEvents extends ExtensionEvents { @@ -21,6 +22,7 @@ export interface AppEvents extends ExtensionEvents { [AppEvent.OauthDisplayMessage]: string[]; [AppEvent.Flicker]: never[]; [AppEvent.McpClientUpdate]: Array | never>; + [AppEvent.SelectionWarning]: never[]; } export const appEvents = new EventEmitter();