From 4fc9b1cde298f7681beb93485c1c9993482ed717 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Mon, 3 Nov 2025 13:41:58 -0800 Subject: [PATCH] alternate buffer support (#12471) --- docs/cli/keyboard-shortcuts.md | 1 + docs/get-started/configuration.md | 6 + packages/cli/src/config/keyBindings.ts | 2 + packages/cli/src/config/settingsSchema.ts | 10 + packages/cli/src/gemini.tsx | 54 ++- packages/cli/src/test-utils/render.tsx | 21 +- packages/cli/src/ui/AppContainer.test.tsx | 170 ++++++++ packages/cli/src/ui/AppContainer.tsx | 31 +- .../src/ui/components/InputPrompt.test.tsx | 124 +++++- .../cli/src/ui/components/InputPrompt.tsx | 31 +- .../cli/src/ui/components/MainContent.tsx | 98 +++-- .../src/ui/components/shared/text-buffer.ts | 33 ++ .../src/ui/contexts/KeypressContext.test.tsx | 221 +++++++++- .../cli/src/ui/contexts/KeypressContext.tsx | 384 ++++++++++-------- .../cli/src/ui/contexts/MouseContext.test.tsx | 190 +++++++++ packages/cli/src/ui/contexts/MouseContext.tsx | 149 +++++++ .../cli/src/ui/hooks/useKeypress.test.tsx | 2 +- packages/cli/src/ui/hooks/useMouse.test.ts | 76 ++++ packages/cli/src/ui/hooks/useMouse.ts | 36 ++ packages/cli/src/ui/keyMatchers.test.ts | 6 + packages/cli/src/ui/utils/input.test.ts | 50 +++ packages/cli/src/ui/utils/input.ts | 58 +++ .../cli/src/ui/utils/kittyProtocolDetector.ts | 20 +- packages/cli/src/ui/utils/mouse.test.ts | 156 +++++++ packages/cli/src/ui/utils/mouse.ts | 214 ++++++++++ schemas/settings.schema.json | 7 + 26 files changed, 1893 insertions(+), 257 deletions(-) create mode 100644 packages/cli/src/ui/contexts/MouseContext.test.tsx create mode 100644 packages/cli/src/ui/contexts/MouseContext.tsx create mode 100644 packages/cli/src/ui/hooks/useMouse.test.ts create mode 100644 packages/cli/src/ui/hooks/useMouse.ts create mode 100644 packages/cli/src/ui/utils/input.test.ts create mode 100644 packages/cli/src/ui/utils/input.ts create mode 100644 packages/cli/src/ui/utils/mouse.test.ts create mode 100644 packages/cli/src/ui/utils/mouse.ts diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 5426836298..c712518504 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -11,6 +11,7 @@ This document lists the available keyboard shortcuts within Gemini CLI. | `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. | | `Ctrl+L` | Clear the screen. | | `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. | +| `Ctrl+S` | Toggle copy mode (alternate buffer mode only). | | `Ctrl+T` | Toggle the display of the todo list. | | `Ctrl+Y` | Toggle auto-approval (YOLO mode) for all tool calls. | | `Shift+Tab` | Toggle auto-accepting edits approval mode. | diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 224c6353e2..155e5943fb 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -229,6 +229,12 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Use the entire width of the terminal for output. - **Default:** `false` +- **`ui.useAlternateBuffer`** (boolean): + - **Description:** Use an alternate screen buffer for the UI, preserving shell + history. + - **Default:** `false` + - **Requires restart:** Yes + - **`ui.customWittyPhrases`** (array): - **Description:** Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults. diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 14e56b33a3..17a9c45d65 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -49,6 +49,7 @@ export enum Command { SHOW_FULL_TODOS = 'showFullTodos', TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail', TOGGLE_MARKDOWN = 'toggleMarkdown', + TOGGLE_COPY_MODE = 'toggleCopyMode', QUIT = 'quit', EXIT = 'exit', SHOW_MORE_LINES = 'showMoreLines', @@ -160,6 +161,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }], [Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], [Command.TOGGLE_MARKDOWN]: [{ key: 'm', command: true }], + [Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }], [Command.QUIT]: [{ key: 'c', ctrl: true }], [Command.EXIT]: [{ key: 'd', ctrl: true }], [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index bada965a14..1ec59060e1 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -491,6 +491,16 @@ const SETTINGS_SCHEMA = { description: 'Use the entire width of the terminal for output.', showInDialog: true, }, + useAlternateBuffer: { + type: 'boolean', + label: 'Use Alternate Screen Buffer', + category: 'UI', + requiresRestart: true, + default: false, + description: + 'Use an alternate screen buffer for the UI, preserving shell history.', + showInDialog: true, + }, customWittyPhrases: { type: 'array', label: 'Custom Witty Phrases', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index ef9239ca66..37d0f915ac 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { render, type RenderOptions } from 'ink'; +import { render } from 'ink'; import { AppContainer } from './ui/AppContainer.js'; import { loadCliConfig, parseArguments } from './config/config.js'; import * as cliConfig from './config/config.js'; @@ -57,6 +57,7 @@ import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; import { computeWindowTitle } from './utils/windowTitle.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js'; +import { MouseProvider } from './ui/contexts/MouseContext.js'; import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; import { VimModeProvider } from './ui/contexts/VimModeContext.js'; @@ -70,6 +71,7 @@ import { loadSandboxConfig } from './config/sandboxConfig.js'; import { ExtensionManager } from './config/extension-manager.js'; import { createPolicyUpdater } from './config/policy.js'; import { requestConsentNonInteractive } from './config/extensions/consent.js'; +import { disableMouseEvents, enableMouseEvents } from './ui/utils/mouse.js'; const SLOW_RENDER_MS = 200; @@ -161,13 +163,21 @@ export async function startInteractiveUI( // do not yet have support for scrolling in that mode. if (!config.getScreenReader()) { process.stdout.write('\x1b[?7l'); - - registerCleanup(() => { - // Re-enable line wrapping on exit. - process.stdout.write('\x1b[?7h'); - }); } + const mouseEventsEnabled = settings.merged.ui?.useAlternateBuffer === true; + if (mouseEventsEnabled) { + enableMouseEvents(); + } + + registerCleanup(() => { + // Re-enable line wrapping on exit. + process.stdout.write('\x1b[?7h'); + if (mouseEventsEnabled) { + disableMouseEvents(); + } + }); + const version = await getCliVersion(); setWindowTitle(basename(workspaceRoot), settings); @@ -181,17 +191,24 @@ export async function startInteractiveUI( config={config} debugKeystrokeLogging={settings.merged.general?.debugKeystrokeLogging} > - - - - - + + + + + + + ); @@ -213,7 +230,8 @@ export async function startInteractiveUI( recordSlowRender(config, renderTime); } }, - } as RenderOptions, + alternateBuffer: settings.merged.ui?.useAlternateBuffer, + }, ); checkForUpdates(settings) diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 8fc0f5308f..b82b8ed4df 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -17,6 +17,7 @@ import { StreamingState } from '../ui/types.js'; import { ConfigContext } from '../ui/contexts/ConfigContext.js'; import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js'; import { VimModeProvider } from '../ui/contexts/VimModeContext.js'; +import { MouseProvider } from '../ui/contexts/MouseContext.js'; import { type Config } from '@google/gemini-cli-core'; @@ -119,6 +120,7 @@ export const renderWithProviders = ( uiState: providedUiState, width, kittyProtocolEnabled = true, + mouseEventsEnabled = false, config = configProxy as unknown as Config, }: { shellFocus?: boolean; @@ -126,6 +128,7 @@ export const renderWithProviders = ( uiState?: Partial; width?: number; kittyProtocolEnabled?: boolean; + mouseEventsEnabled?: boolean; config?: Config; } = {}, ): ReturnType => { @@ -163,14 +166,16 @@ export const renderWithProviders = ( - - {component} - + + + {component} + + diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 25e53d2657..98d50e977e 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -108,6 +108,10 @@ vi.mock('../utils/events.js'); vi.mock('../utils/handleAutoUpdate.js'); vi.mock('./utils/ConsolePatcher.js'); vi.mock('../utils/cleanup.js'); +vi.mock('./utils/mouse.js', () => ({ + enableMouseEvents: vi.fn(), + disableMouseEvents: vi.fn(), +})); import { useHistory } from './hooks/useHistoryManager.js'; import { useThemeCommand } from './hooks/useThemeCommand.js'; @@ -134,6 +138,7 @@ import { measureElement } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; import { type ExtensionManager } from '../config/extension-manager.js'; +import { enableMouseEvents, disableMouseEvents } from './utils/mouse.js'; describe('AppContainer State Management', () => { let mockConfig: Config; @@ -1367,6 +1372,171 @@ describe('AppContainer State Management', () => { }); }); + describe('Copy Mode (CTRL+S)', () => { + let handleGlobalKeypress: (key: Key) => void; + let rerender: () => void; + let unmount: () => void; + + const setupCopyModeTest = async (isAlternateMode = false) => { + // Update settings for this test run + const testSettings = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + useAlternateBuffer: isAlternateMode, + }, + }, + } as unknown as LoadedSettings; + + const renderResult = render( + , + ); + await act(async () => { + vi.advanceTimersByTime(0); + }); + + rerender = () => + renderResult.rerender( + , + ); + unmount = renderResult.unmount; + }; + + beforeEach(() => { + mockStdout.write.mockClear(); + mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => { + handleGlobalKeypress = callback; + }); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe.each([ + { + isAlternateMode: false, + shouldEnable: false, + modeName: 'Normal Mode', + }, + { + isAlternateMode: true, + shouldEnable: true, + modeName: 'Alternate Buffer Mode', + }, + ])('$modeName', ({ isAlternateMode, shouldEnable }) => { + it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when Ctrl+S is pressed`, async () => { + await setupCopyModeTest(isAlternateMode); + mockStdout.write.mockClear(); // Clear initial enable call + + act(() => { + handleGlobalKeypress({ + name: 's', + ctrl: true, + meta: false, + shift: false, + paste: false, + sequence: '\x13', + }); + }); + rerender(); + + if (shouldEnable) { + expect(disableMouseEvents).toHaveBeenCalled(); + } else { + expect(disableMouseEvents).not.toHaveBeenCalled(); + } + unmount(); + }); + + if (shouldEnable) { + it('should toggle mouse back on when Ctrl+S is pressed again', async () => { + await setupCopyModeTest(isAlternateMode); + mockStdout.write.mockClear(); + + // Turn it on (disable mouse) + act(() => { + handleGlobalKeypress({ + name: 's', + ctrl: true, + meta: false, + shift: false, + paste: false, + sequence: '\x13', + }); + }); + rerender(); + expect(disableMouseEvents).toHaveBeenCalled(); + + // Turn it off (enable mouse) + act(() => { + handleGlobalKeypress({ + name: 'any', // Any key should exit copy mode + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: 'a', + }); + }); + rerender(); + + expect(enableMouseEvents).toHaveBeenCalled(); + unmount(); + }); + + it('should exit copy mode on any key press', async () => { + await setupCopyModeTest(isAlternateMode); + + // Enter copy mode + act(() => { + handleGlobalKeypress({ + name: 's', + ctrl: true, + meta: false, + shift: false, + paste: false, + sequence: '\x13', + }); + }); + rerender(); + + mockStdout.write.mockClear(); + + // Press any other key + act(() => { + handleGlobalKeypress({ + name: 'a', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: 'a', + }); + }); + rerender(); + + // Should have re-enabled mouse + expect(enableMouseEvents).toHaveBeenCalled(); + unmount(); + }); + } + }); + }); + describe('Model Dialog Integration', () => { it('should provide isModelDialogOpen in the UIStateContext', async () => { mockedUseModelCommand.mockReturnValue({ diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index da4394877c..abf92108c3 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -103,6 +103,7 @@ import { import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { type ExtensionManager } from '../config/extension-manager.js'; import { requestConsentInteractive } from '../config/extensions/consent.js'; +import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; @@ -154,6 +155,7 @@ export const AppContainer = (props: AppContainerProps) => { const [isProcessing, setIsProcessing] = useState(false); const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false); const [showDebugProfiler, setShowDebugProfiler] = useState(false); + const [copyModeEnabled, setCopyModeEnabled] = useState(false); const [geminiMdFileCount, setGeminiMdFileCount] = useState( initializationResult.geminiMdFileCount, @@ -249,6 +251,8 @@ export const AppContainer = (props: AppContainerProps) => { setConfigInitialized(true); })(); registerCleanup(async () => { + // Turn off mouse scroll. + disableMouseEvents(); const ideClient = await IdeClient.getInstance(); await ideClient.disconnect(); }); @@ -351,9 +355,11 @@ export const AppContainer = (props: AppContainerProps) => { }, [historyManager.history, logger]); const refreshStatic = useCallback(() => { - stdout.write(ansiEscapes.clearTerminal); + if (settings.merged.ui?.useAlternateBuffer === false) { + stdout.write(ansiEscapes.clearTerminal); + } setHistoryRemountKey((prev) => prev + 1); - }, [setHistoryRemountKey, stdout]); + }, [setHistoryRemountKey, stdout, settings]); const { isThemeDialogOpen, @@ -1016,11 +1022,27 @@ Logging in with Google... Please restart Gemini CLI to continue. const handleGlobalKeypress = useCallback( (key: Key) => { + if (copyModeEnabled) { + setCopyModeEnabled(false); + enableMouseEvents(); + // We don't want to process any other keys if we're in copy mode. + return; + } + // Debug log keystrokes if enabled if (settings.merged.general?.debugKeystrokeLogging) { debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); } + if ( + settings.merged.ui?.useAlternateBuffer && + keyMatchers[Command.TOGGLE_COPY_MODE](key) + ) { + setCopyModeEnabled(true); + disableMouseEvents(); + return; + } + if (keyMatchers[Command.QUIT](key)) { // If the user presses Ctrl+C, we want to cancel any ongoing requests. // This should happen regardless of the count. @@ -1085,6 +1107,9 @@ Logging in with Google... Please restart Gemini CLI to continue. embeddedShellFocused, settings.merged.general?.debugKeystrokeLogging, refreshStatic, + setCopyModeEnabled, + copyModeEnabled, + settings.merged.ui?.useAlternateBuffer, ], ); @@ -1301,6 +1326,7 @@ Logging in with Google... Please restart Gemini CLI to continue. activePtyId, embeddedShellFocused, showDebugProfiler, + copyModeEnabled, }), [ isThemeDialogOpen, @@ -1385,6 +1411,7 @@ Logging in with Google... Please restart Gemini CLI to continue. showDebugProfiler, apiKeyDefaultValue, authState, + copyModeEnabled, ], ); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 2eb50aa550..8372358a09 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -130,6 +130,7 @@ describe('InputPrompt', () => { moveToOffset: vi.fn((offset: number) => { mockBuffer.cursor = [0, offset]; }), + moveToVisualPosition: vi.fn(), killLineRight: vi.fn(), killLineLeft: vi.fn(), openInExternalEditor: vi.fn(), @@ -1590,28 +1591,42 @@ describe('InputPrompt', () => { unmount(); }); - it('resets reverse search state on Escape', async () => { - const { stdin, stdout, unmount } = renderWithProviders( - , - ); + it.each([ + { name: 'standard', kittyProtocolEnabled: false, escapeSequence: '\x1B' }, + { + name: 'kitty', + kittyProtocolEnabled: true, + escapeSequence: '\u001b[27u', + }, + ])( + 'resets reverse search state on Escape ($name)', + async ({ kittyProtocolEnabled, escapeSequence }) => { + const { stdin, stdout, unmount } = renderWithProviders( + , + { kittyProtocolEnabled }, + ); - await act(async () => { - stdin.write('\x12'); - }); - await act(async () => { - stdin.write('\x1B'); - }); - await act(async () => { - stdin.write('\u001b[27u'); // Press kitty escape key - }); + await act(async () => { + stdin.write('\x12'); + }); - await waitFor(() => { - expect(stdout.lastFrame()).not.toContain('(r:)'); - expect(stdout.lastFrame()).not.toContain('echo hello'); - }); + // Wait for reverse search to be active + await waitFor(() => { + expect(stdout.lastFrame()).toContain('(r:)'); + }); - unmount(); - }); + await act(async () => { + stdin.write(escapeSequence); + }); + + await waitFor(() => { + expect(stdout.lastFrame()).not.toContain('(r:)'); + expect(stdout.lastFrame()).not.toContain('echo hello'); + }); + + unmount(); + }, + ); it('completes the highlighted entry on Tab and exits reverse-search', async () => { // Mock the reverse search completion @@ -1936,6 +1951,77 @@ describe('InputPrompt', () => { }); }); + describe('mouse interaction', () => { + it.each([ + { + name: 'first line, first char', + relX: 0, + relY: 0, + mouseCol: 5, + mouseRow: 2, + }, + { + name: 'first line, middle char', + relX: 6, + relY: 0, + mouseCol: 11, + mouseRow: 2, + }, + { + name: 'second line, first char', + relX: 0, + relY: 1, + mouseCol: 5, + mouseRow: 3, + }, + { + name: 'second line, end char', + relX: 5, + relY: 1, + mouseCol: 10, + mouseRow: 3, + }, + ])( + 'should move cursor on mouse click - $name', + async ({ relX, relY, mouseCol, mouseRow }) => { + props.buffer.text = 'hello world\nsecond line'; + props.buffer.lines = ['hello world', 'second line']; + props.buffer.viewportVisualLines = ['hello world', 'second line']; + props.buffer.visualToLogicalMap = [ + [0, 0], + [1, 0], + ]; + props.buffer.visualCursor = [0, 11]; + props.buffer.visualScrollRow = 0; + + const { stdin, stdout, unmount } = renderWithProviders( + , + { mouseEventsEnabled: true }, + ); + + // Wait for initial render + await waitFor(() => { + expect(stdout.lastFrame()).toContain('hello world'); + }); + + // Simulate left mouse press at calculated coordinates. + // Assumes inner box is at x=4, y=1 based on border(1)+padding(1)+prompt(2) and border-top(1). + await act(async () => { + stdin.write(`\x1b[<0;${mouseCol};${mouseRow}M`); + }); + + await waitFor(() => { + expect(props.buffer.moveToVisualPosition).toHaveBeenCalledWith( + relY, + relX, + ); + }); + + unmount(); + }, + ); + }); + describe('queued message editing', () => { it('should load all queued messages when up arrow is pressed with empty input', async () => { const mockPopAllMessages = vi.fn(); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index d7ffcf8729..e1f359c302 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -6,7 +6,7 @@ import type React from 'react'; import { useCallback, useEffect, useState, useRef } from 'react'; -import { Box, Text } from 'ink'; +import { Box, Text, getBoundingBox, type DOMElement } from 'ink'; import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js'; import { theme } from '../semantic-colors.js'; import { useInputHistory } from '../hooks/useInputHistory.js'; @@ -40,6 +40,7 @@ import { useShellFocusState } from '../contexts/ShellFocusContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { StreamingState } from '../types.js'; import { isSlashCommand } from '../utils/commandUtils.js'; +import { useMouse, type MouseEvent } from '../contexts/MouseContext.js'; /** * Returns if the terminal can be trusted to handle paste events atomically @@ -127,6 +128,7 @@ export const InputPrompt: React.FC = ({ number | null >(null); const pasteTimeoutRef = useRef(null); + const innerBoxRef = useRef(null); const [dirs, setDirs] = useState( config.getWorkspaceContext().getDirectories(), @@ -356,6 +358,31 @@ 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); + } + } + }, + [buffer], + ); + + useMouse(handleMouse, { isActive: focus && !isEmbeddedShellFocused }); + const handleInput = useCallback( (key: Key) => { // TODO(jacobr): this special case is likely not needed anymore. @@ -972,7 +999,7 @@ export const InputPrompt: React.FC = ({ '>' )}{' '} - + {buffer.text.length === 0 && placeholder ? ( showCursor ? ( diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index c193bb0d7b..7d20407f14 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -11,6 +11,7 @@ import { OverflowProvider } from '../contexts/OverflowContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; +import { useSettings } from '../contexts/SettingsContext.js'; // Limit Gemini messages to a very high number of lines to mitigate performance // issues in the worst case if we somehow get an enormous response from Gemini. @@ -21,6 +22,9 @@ const MAX_GEMINI_MESSAGE_LINES = 65536; export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); + const settings = useSettings(); + const useAlternateBuffer = settings.merged.ui?.useAlternateBuffer ?? true; + const { pendingHistoryItems, mainAreaWidth, @@ -28,46 +32,68 @@ export const MainContent = () => { availableTerminalHeight, } = uiState; + const historyItems = [ + , + ...uiState.history.map((h) => ( + + )), + ]; + + const pendingItems = ( + + + {pendingHistoryItems.map((item, i) => ( + + ))} + + + + ); + + if (useAlternateBuffer) { + // Placeholder alternate buffer implementation using a scrollable box that + // is always scrolled to the bottom. In follow up PRs we will switch this + // to a proper alternate buffer implementation. + return ( + + + {historyItems} + {pendingItems} + + + ); + } + return ( <> - , - ...uiState.history.map((h) => ( - - )), - ]} - > + {(item) => item} - - - {pendingHistoryItems.map((item, i) => ( - - ))} - - - + {pendingItems} ); }; diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 8b5792e5da..b44485cb0c 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -2016,6 +2016,36 @@ export function useTextBuffer({ dispatch({ type: 'move_to_offset', payload: { offset } }); }, []); + const moveToVisualPosition = useCallback( + (visRow: number, visCol: number): void => { + const { visualLines, visualToLogicalMap } = visualLayout; + // Clamp visRow to valid range + const clampedVisRow = Math.max( + 0, + Math.min(visRow, visualLines.length - 1), + ); + const visualLine = visualLines[clampedVisRow] || ''; + // Clamp visCol to the length of the visual line + const clampedVisCol = Math.max(0, Math.min(visCol, cpLen(visualLine))); + + if (visualToLogicalMap[clampedVisRow]) { + const [logRow, logStartCol] = visualToLogicalMap[clampedVisRow]; + const newCursorRow = logRow; + const newCursorCol = logStartCol + clampedVisCol; + + dispatch({ + type: 'set_cursor', + payload: { + cursorRow: newCursorRow, + cursorCol: newCursorCol, + preferredCol: clampedVisCol, + }, + }); + } + }, + [visualLayout], + ); + const returnValue: TextBuffer = useMemo( () => ({ lines, @@ -2041,6 +2071,7 @@ export function useTextBuffer({ replaceRange, replaceRangeByOffset, moveToOffset, + moveToVisualPosition, deleteWordLeft, deleteWordRight, @@ -2104,6 +2135,7 @@ export function useTextBuffer({ replaceRange, replaceRangeByOffset, moveToOffset, + moveToVisualPosition, deleteWordLeft, deleteWordRight, killLineRight, @@ -2265,6 +2297,7 @@ export interface TextBuffer { replacementText: string, ) => void; moveToOffset(offset: number): void; + moveToVisualPosition(visualRow: number, visualCol: number): void; // Vim-specific operations /** diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index e4605e3f83..cb11f7e185 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -188,6 +188,40 @@ describe('KeypressContext - Kitty Protocol', () => { }), ); }); + + it('should handle lone Escape key (keycode 27) with timeout when kitty protocol is enabled', async () => { + // Use real timers for this test to avoid issues with stream/buffer timing + vi.useRealTimers(); + const keyHandler = vi.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + // Send just ESC + act(() => { + stdin.write('\x1b'); + }); + + // Should be buffered initially + expect(keyHandler).not.toHaveBeenCalled(); + + // Wait for timeout + await waitFor( + () => { + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'escape', + meta: true, + }), + ); + }, + { timeout: 500 }, + ); + }); }); describe('Tab and Backspace handling', () => { @@ -350,13 +384,13 @@ describe('KeypressContext - Kitty Protocol', () => { act(() => stdin.write('\x1b[27u')); expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Kitty buffer accumulating:', + '[DEBUG] Input buffer accumulating:', expect.stringContaining('"\\u001b[27u"'), ); const parsedCall = consoleLogSpy.mock.calls.find( (args) => typeof args[0] === 'string' && - args[0].includes('[DEBUG] Kitty sequence parsed successfully'), + args[0].includes('[DEBUG] Sequence parsed successfully'), ); expect(parsedCall).toBeTruthy(); expect(parsedCall?.[1]).toEqual(expect.stringContaining('\\u001b[27u')); @@ -383,7 +417,7 @@ describe('KeypressContext - Kitty Protocol', () => { act(() => stdin.write(longSequence)); expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Kitty buffer overflow, clearing:', + '[DEBUG] Input buffer overflow, clearing:', expect.any(String), ); }); @@ -410,7 +444,7 @@ describe('KeypressContext - Kitty Protocol', () => { act(() => stdin.write('\x03')); expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Kitty buffer cleared on Ctrl+C:', + '[DEBUG] Input buffer cleared on Ctrl+C:', INCOMPLETE_KITTY_SEQUENCE, ); @@ -444,13 +478,13 @@ describe('KeypressContext - Kitty Protocol', () => { // Verify debug logging for accumulation expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Kitty buffer accumulating:', + '[DEBUG] Input buffer accumulating:', JSON.stringify(INCOMPLETE_KITTY_SEQUENCE), ); // Verify warning for char codes expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Kitty sequence buffer has content:', + 'Input sequence buffer has content:', JSON.stringify(INCOMPLETE_KITTY_SEQUENCE), ); }); @@ -1164,4 +1198,179 @@ describe('Kitty Sequence Parsing', () => { ); vi.useRealTimers(); }); + + describe('SGR Mouse Handling', () => { + it('should ignore SGR mouse sequences', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => result.current.subscribe(keyHandler)); + + // Send various SGR mouse sequences + act(() => { + stdin.write('\x1b[<0;10;20M'); // Mouse press + stdin.write('\x1b[<0;10;20m'); // Mouse release + stdin.write('\x1b[<32;30;40M'); // Mouse drag + stdin.write('\x1b[<64;5;5M'); // Scroll up + }); + + // Should not broadcast any of these as keystrokes + expect(keyHandler).not.toHaveBeenCalled(); + }); + + it('should handle mixed SGR mouse and key sequences', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => result.current.subscribe(keyHandler)); + + // Send mouse event then a key press + act(() => { + stdin.write('\x1b[<0;10;20M'); + stdin.write('a'); + }); + + // Should only broadcast 'a' + expect(keyHandler).toHaveBeenCalledTimes(1); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'a', + sequence: 'a', + }), + ); + }); + + it('should ignore X11 mouse sequences', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => result.current.subscribe(keyHandler)); + + // Send X11 mouse sequence: ESC [ M followed by 3 bytes + // Space is 32. 32+0=32 (button 0), 32+33=65 ('A', col 33), 32+34=66 ('B', row 34) + const x11Seq = '\x1b[M AB'; + + act(() => { + stdin.write(x11Seq); + }); + + // Should not broadcast as keystrokes + expect(keyHandler).not.toHaveBeenCalled(); + }); + + it('should not flush slow SGR mouse sequences as garbage', async () => { + vi.useFakeTimers(); + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => result.current.subscribe(keyHandler)); + + // Send start of SGR sequence + act(() => stdin.write('\x1b[<')); + + // Advance time past the normal kitty timeout (50ms) + act(() => vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS + 10)); + + // Send the rest + act(() => stdin.write('0;37;25M')); + + // Should NOT have flushed the prefix as garbage, and should have consumed the whole thing + expect(keyHandler).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('should ignore specific SGR mouse sequence sandwiched between keystrokes', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => result.current.subscribe(keyHandler)); + + act(() => { + stdin.write('H'); + stdin.write('\x1b[<64;96;8M'); + stdin.write('I'); + }); + + expect(keyHandler).toHaveBeenCalledTimes(2); + expect(keyHandler).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: 'h', sequence: 'H', shift: true }), + ); + expect(keyHandler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: 'i', sequence: 'I', shift: true }), + ); + }); + }); + + describe('Ignored Sequences', () => { + describe.each([true, false])( + 'with kittyProtocolEnabled = %s', + (kittyEnabled) => { + it.each([ + { name: 'Focus In', sequence: '\x1b[I' }, + { name: 'Focus Out', sequence: '\x1b[O' }, + { name: 'SGR Mouse Release', sequence: '\u001b[<0;44;18m' }, + { name: 'something mouse', sequence: '\u001b[<0;53;19M' }, + { name: 'another mouse', sequence: '\u001b[<0;29;19m' }, + ])('should ignore $name sequence', async ({ sequence }) => { + vi.useFakeTimers(); + const keyHandler = vi.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useKeypressContext(), { + wrapper, + }); + act(() => result.current.subscribe(keyHandler)); + + for (const char of sequence) { + act(() => { + stdin.write(char); + }); + await act(async () => { + vi.advanceTimersByTime(0); + }); + } + + act(() => { + stdin.write('HI'); + }); + + expect(keyHandler).toHaveBeenCalledTimes(2); + expect(keyHandler).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: 'h', sequence: 'H', shift: true }), + ); + expect(keyHandler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: 'i', sequence: 'I', shift: true }), + ); + vi.useRealTimers(); + }); + }, + ); + + it('should handle F12 when kittyProtocolEnabled is false', async () => { + const keyHandler = vi.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => { + stdin.write('\u001b[24~'); + }); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'f12', sequence: '\u001b[24~' }), + ); + }); + }); }); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index e347d370b6..4c605fbab4 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -37,9 +37,10 @@ import { MODIFIER_CTRL_BIT, } from '../utils/platformConstants.js'; +import { ESC, couldBeMouseSequence } from '../utils/input.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; +import { isIncompleteMouseSequence, parseMouseEvent } from '../utils/mouse.js'; -const ESC = '\u001B'; export const PASTE_MODE_START = `${ESC}[200~`; export const PASTE_MODE_END = `${ESC}[201~`; export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100ms if no more input @@ -108,6 +109,8 @@ function couldBeKittySequence(buffer: string): boolean { if (!buffer.startsWith(`${ESC}[`)) return false; + if (couldBeMouseSequence(buffer)) return true; + // Check for known kitty sequence patterns: // 1. ESC[ - could be CSI-u or tilde-coded // 2. ESC[1; - parameterized functional @@ -256,7 +259,7 @@ function parseKittyPrefix(buffer: string): { key: Key; length: number } | null { shift, paste: false, sequence: buffer.slice(0, m[0].length), - kittyProtocol: true, + kittyProtocol: false, }, length: m[0].length, }; @@ -324,7 +327,7 @@ function parseKittyPrefix(buffer: string): { key: Key; length: number } | null { shift: false, paste: false, sequence: buffer.slice(0, m[0].length), - kittyProtocol: true, + kittyProtocol: false, }, length: m[0].length, }; @@ -505,9 +508,9 @@ export function KeypressProvider({ // Used to turn "\" quickly followed by a "enter" into a shift enter let backslashTimeout: NodeJS.Timeout | null = null; - // Buffers incomplete Kitty sequences and timer to flush it - let kittySequenceBuffer = ''; - let kittySequenceTimeout: NodeJS.Timeout | null = null; + // Buffers incomplete sequences (Kitty or Mouse) and timer to flush it + let inputBuffer = ''; + let inputTimeout: NodeJS.Timeout | null = null; // Used to detect filename drag-and-drops. let dragBuffer = ''; @@ -520,12 +523,12 @@ export function KeypressProvider({ } }; - const flushKittyBufferOnInterrupt = (reason: string) => { - if (kittySequenceBuffer) { + const flushInputBufferOnInterrupt = (reason: string) => { + if (inputBuffer) { if (debugKeystrokeLogging) { debugLogger.log( - `[DEBUG] Kitty sequence flushed due to ${reason}:`, - JSON.stringify(kittySequenceBuffer), + `[DEBUG] Input sequence flushed due to ${reason}:`, + JSON.stringify(inputBuffer), ); } broadcast({ @@ -534,23 +537,23 @@ export function KeypressProvider({ meta: false, shift: false, paste: false, - sequence: kittySequenceBuffer, + sequence: inputBuffer, }); - kittySequenceBuffer = ''; + inputBuffer = ''; } - if (kittySequenceTimeout) { - clearTimeout(kittySequenceTimeout); - kittySequenceTimeout = null; + if (inputTimeout) { + clearTimeout(inputTimeout); + inputTimeout = null; } }; const handleKeypress = (_: unknown, key: Key) => { if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) { - flushKittyBufferOnInterrupt('focus event'); + flushInputBufferOnInterrupt('focus event'); return; } if (key.name === 'paste-start') { - flushKittyBufferOnInterrupt('paste start'); + flushInputBufferOnInterrupt('paste start'); pasteBuffer = Buffer.alloc(0); return; } @@ -649,16 +652,16 @@ export function KeypressProvider({ (key.ctrl && key.name === 'c') || key.sequence === `${ESC}${KITTY_CTRL_C}` ) { - if (kittySequenceBuffer && debugKeystrokeLogging) { + if (inputBuffer && debugKeystrokeLogging) { debugLogger.log( - '[DEBUG] Kitty buffer cleared on Ctrl+C:', - kittySequenceBuffer, + '[DEBUG] Input buffer cleared on Ctrl+C:', + inputBuffer, ); } - kittySequenceBuffer = ''; - if (kittySequenceTimeout) { - clearTimeout(kittySequenceTimeout); - kittySequenceTimeout = null; + inputBuffer = ''; + if (inputTimeout) { + clearTimeout(inputTimeout); + inputTimeout = null; } if (key.sequence === `${ESC}${KITTY_CTRL_C}`) { broadcast({ @@ -676,153 +679,214 @@ export function KeypressProvider({ return; } - if (kittyProtocolEnabled) { - // Clear any pending timeout when new input arrives - if (kittySequenceTimeout) { - clearTimeout(kittySequenceTimeout); - kittySequenceTimeout = null; + // Clear any pending timeout when new input arrives + if (inputTimeout) { + clearTimeout(inputTimeout); + inputTimeout = null; + } + + // Always check if this could start a sequence we need to buffer (Kitty or Mouse) + // We only want to intercept if it starts with ESC[ (CSI) or is EXACTLY ESC (waiting for more). + // Other ESC sequences (like Alt+Key which is ESC+Key) should be let through if readline parsed them. + const isCSI = key.sequence.startsWith(`${ESC}[`); + const isExactEsc = key.sequence === ESC; + const shouldBuffer = isCSI || isExactEsc; + + const isExcluded = [ + PASTE_MODE_START, + PASTE_MODE_END, + FOCUS_IN, + FOCUS_OUT, + ].some((prefix) => key.sequence.startsWith(prefix)); + + if (inputBuffer || (shouldBuffer && !isExcluded)) { + inputBuffer += key.sequence; + + if (debugKeystrokeLogging && !couldBeMouseSequence(inputBuffer)) { + debugLogger.log( + '[DEBUG] Input buffer accumulating:', + JSON.stringify(inputBuffer), + ); } - // Check if this could start a kitty sequence - const startsWithEsc = key.sequence.startsWith(ESC); - const isExcluded = [ - PASTE_MODE_START, - PASTE_MODE_END, - FOCUS_IN, - FOCUS_OUT, - ].some((prefix) => key.sequence.startsWith(prefix)); + // Try immediate parsing + let remainingBuffer = inputBuffer; + let parsedAny = false; - if (kittySequenceBuffer || (startsWithEsc && !isExcluded)) { - kittySequenceBuffer += key.sequence; + while (remainingBuffer) { + const parsed = parseKittyPrefix(remainingBuffer); - if (debugKeystrokeLogging) { - debugLogger.log( - '[DEBUG] Kitty buffer accumulating:', - JSON.stringify(kittySequenceBuffer), - ); - } - - // Try immediate parsing - let remainingBuffer = kittySequenceBuffer; - let parsedAny = false; - - while (remainingBuffer) { - const parsed = parseKittyPrefix(remainingBuffer); - - if (parsed) { + if (parsed) { + // If kitty protocol is disabled, only allow legacy/standard sequences. + // parseKittyPrefix returns true for kittyProtocol if it's a modern kitty sequence. + if (kittyProtocolEnabled || !parsed.key.kittyProtocol) { if (debugKeystrokeLogging) { const parsedSequence = remainingBuffer.slice(0, parsed.length); debugLogger.log( - '[DEBUG] Kitty sequence parsed successfully:', + '[DEBUG] Sequence parsed successfully:', JSON.stringify(parsedSequence), ); } broadcast(parsed.key); remainingBuffer = remainingBuffer.slice(parsed.length); parsedAny = true; - } else { - // If we can't parse a sequence at the start, check if there's - // another ESC later in the buffer. If so, the data before it - // is garbage/incomplete and should be dropped so we can - // process the next sequence. - const nextEscIndex = remainingBuffer.indexOf(ESC, 1); - if (nextEscIndex !== -1) { - const garbage = remainingBuffer.slice(0, nextEscIndex); - if (debugKeystrokeLogging) { - debugLogger.log( - '[DEBUG] Dropping incomplete sequence before next ESC:', - JSON.stringify(garbage), - ); - } - // Drop garbage and continue parsing from next ESC - remainingBuffer = remainingBuffer.slice(nextEscIndex); - // We made progress, so we can continue the loop to parse the next sequence - continue; - } - - // Check if buffer could become a valid kitty sequence - const couldBeValid = couldBeKittySequence(remainingBuffer); - - if (!couldBeValid) { - // Not a kitty sequence - flush as regular input immediately - if (debugKeystrokeLogging) { - debugLogger.log( - '[DEBUG] Not a kitty sequence, flushing:', - JSON.stringify(remainingBuffer), - ); - } - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence: remainingBuffer, - }); - remainingBuffer = ''; - parsedAny = true; - } else if (remainingBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) { - // Buffer overflow - log and clear - if (debugKeystrokeLogging) { - debugLogger.log( - '[DEBUG] Kitty buffer overflow, clearing:', - JSON.stringify(remainingBuffer), - ); - } - if (config) { - const event = new KittySequenceOverflowEvent( - remainingBuffer.length, - remainingBuffer, - ); - logKittySequenceOverflow(config, event); - } - // Flush as regular input - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence: remainingBuffer, - }); - remainingBuffer = ''; - parsedAny = true; - } else { - if (config?.getDebugMode() || debugKeystrokeLogging) { - debugLogger.warn( - 'Kitty sequence buffer has content:', - JSON.stringify(kittySequenceBuffer), - ); - } - // Could be valid but incomplete - set timeout - kittySequenceTimeout = setTimeout(() => { - if (kittySequenceBuffer) { - if (debugKeystrokeLogging) { - debugLogger.log( - '[DEBUG] Kitty sequence timeout, flushing:', - JSON.stringify(kittySequenceBuffer), - ); - } - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: false, - sequence: kittySequenceBuffer, - }); - kittySequenceBuffer = ''; - } - kittySequenceTimeout = null; - }, KITTY_SEQUENCE_TIMEOUT_MS); - break; - } + continue; } } - kittySequenceBuffer = remainingBuffer; - if (parsedAny || kittySequenceBuffer) return; + const mouseParsed = parseMouseEvent(remainingBuffer); + if (mouseParsed) { + // These are handled by the separate mouse sequence parser. + // All we need to do is make sure we don't get confused by these + // sequences. + remainingBuffer = remainingBuffer.slice(mouseParsed.length); + parsedAny = true; + continue; + } + // If we can't parse a sequence at the start, check if there's + // another ESC later in the buffer. If so, the data before it + // is garbage/incomplete and should be dropped so we can + // process the next sequence. + const nextEscIndex = remainingBuffer.indexOf(ESC, 1); + if (nextEscIndex !== -1) { + const garbage = remainingBuffer.slice(0, nextEscIndex); + + // Special case: if garbage is exactly ESC, it's likely a rapid ESC press. + if (garbage === ESC) { + if (debugKeystrokeLogging) { + debugLogger.log( + '[DEBUG] Flushing rapid ESC before next ESC:', + JSON.stringify(garbage), + ); + } + broadcast({ + name: 'escape', + ctrl: false, + meta: true, + shift: false, + paste: false, + sequence: garbage, + }); + } else { + if (debugKeystrokeLogging) { + debugLogger.log( + '[DEBUG] Dropping incomplete sequence before next ESC:', + JSON.stringify(garbage), + ); + } + } + + // Continue parsing from next ESC + remainingBuffer = remainingBuffer.slice(nextEscIndex); + // We made progress, so we can continue the loop to parse the next sequence + continue; + } + + // Check if buffer could become a valid sequence + const couldBeValidKitty = + kittyProtocolEnabled && couldBeKittySequence(remainingBuffer); + const isMouse = isIncompleteMouseSequence(remainingBuffer); + const couldBeValid = couldBeValidKitty || isMouse; + + if (!couldBeValid) { + // Not a valid sequence - flush as regular input immediately + if (debugKeystrokeLogging) { + debugLogger.log( + '[DEBUG] Not a valid sequence, flushing:', + JSON.stringify(remainingBuffer), + ); + } + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: remainingBuffer, + }); + remainingBuffer = ''; + parsedAny = true; + } else if (remainingBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) { + // Buffer overflow - log and clear + if (debugKeystrokeLogging) { + debugLogger.log( + '[DEBUG] Input buffer overflow, clearing:', + JSON.stringify(remainingBuffer), + ); + } + if (config && kittyProtocolEnabled) { + const event = new KittySequenceOverflowEvent( + remainingBuffer.length, + remainingBuffer, + ); + logKittySequenceOverflow(config, event); + } + // Flush as regular input + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: remainingBuffer, + }); + remainingBuffer = ''; + parsedAny = true; + } else { + if ( + (config?.getDebugMode() || debugKeystrokeLogging) && + !couldBeMouseSequence(inputBuffer) + ) { + debugLogger.warn( + 'Input sequence buffer has content:', + JSON.stringify(inputBuffer), + ); + } + // Could be valid but incomplete - set timeout + // Only set timeout if it's NOT a mouse sequence. + // Mouse sequences might be slow (e.g. over network) and we don't want to + // flush them as garbage keypresses. + // However, if it's just ESC or ESC[, it might be a user typing slowly, + // so we should still timeout in that case. + const isAmbiguousPrefix = + remainingBuffer === ESC || remainingBuffer === `${ESC}[`; + + if (!isMouse || isAmbiguousPrefix) { + inputTimeout = setTimeout(() => { + if (inputBuffer) { + if (debugKeystrokeLogging) { + debugLogger.log( + '[DEBUG] Input sequence timeout, flushing:', + JSON.stringify(inputBuffer), + ); + } + const isEscape = inputBuffer === ESC; + broadcast({ + name: isEscape ? 'escape' : '', + ctrl: false, + meta: isEscape, + shift: false, + paste: false, + sequence: inputBuffer, + }); + inputBuffer = ''; + } + inputTimeout = null; + }, KITTY_SEQUENCE_TIMEOUT_MS); + } else { + // It IS a mouse sequence and it's long enough to be unambiguously NOT just a user hitting ESC slowly. + // We just wait for more data. + if (inputTimeout) { + clearTimeout(inputTimeout); + inputTimeout = null; + } + } + break; + } } + + inputBuffer = remainingBuffer; + if (parsedAny || inputBuffer) return; } if (key.name === 'return' && key.sequence === `${ESC}\r`) { @@ -880,22 +944,22 @@ export function KeypressProvider({ backslashTimeout = null; } - if (kittySequenceTimeout) { - clearTimeout(kittySequenceTimeout); - kittySequenceTimeout = null; + if (inputTimeout) { + clearTimeout(inputTimeout); + inputTimeout = null; } // Flush any pending kitty sequence data to avoid data loss on exit. - if (kittySequenceBuffer) { + if (inputBuffer) { broadcast({ name: '', ctrl: false, meta: false, shift: false, paste: false, - sequence: kittySequenceBuffer, + sequence: inputBuffer, }); - kittySequenceBuffer = ''; + inputBuffer = ''; } // Flush any pending paste data to avoid data loss on exit. diff --git a/packages/cli/src/ui/contexts/MouseContext.test.tsx b/packages/cli/src/ui/contexts/MouseContext.test.tsx new file mode 100644 index 0000000000..fb57f52519 --- /dev/null +++ b/packages/cli/src/ui/contexts/MouseContext.test.tsx @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook } from '../../test-utils/render.js'; +import { act } from 'react'; +import { MouseProvider, useMouseContext, useMouse } from './MouseContext.js'; +import { vi, type Mock } from 'vitest'; +import type React from 'react'; +import { useStdin } from 'ink'; +import { EventEmitter } from 'node:events'; + +// Mock the 'ink' module to control stdin +vi.mock('ink', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useStdin: vi.fn(), + }; +}); + +class MockStdin extends EventEmitter { + isTTY = true; + setRawMode = vi.fn(); + override on = this.addListener; + override removeListener = super.removeListener; + resume = vi.fn(); + pause = vi.fn(); + + write(text: string) { + this.emit('data', text); + } +} + +describe('MouseContext', () => { + let stdin: MockStdin; + let wrapper: React.FC<{ children: React.ReactNode }>; + + beforeEach(() => { + stdin = new MockStdin(); + (useStdin as Mock).mockReturnValue({ + stdin, + setRawMode: vi.fn(), + }); + wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should subscribe and unsubscribe a handler', () => { + const handler = vi.fn(); + const { result } = renderHook(() => useMouseContext(), { wrapper }); + + act(() => { + result.current.subscribe(handler); + }); + + act(() => { + stdin.write('\x1b[<0;10;20M'); + }); + + expect(handler).toHaveBeenCalledTimes(1); + + act(() => { + result.current.unsubscribe(handler); + }); + + act(() => { + stdin.write('\x1b[<0;10;20M'); + }); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should not call handler if not active', () => { + const handler = vi.fn(); + renderHook(() => useMouse(handler, { isActive: false }), { + wrapper, + }); + + act(() => { + stdin.write('\x1b[<0;10;20M'); + }); + + expect(handler).not.toHaveBeenCalled(); + }); + + describe('SGR Mouse Events', () => { + it.each([ + { + sequence: '\x1b[<0;10;20M', + expected: { + name: 'left-press', + ctrl: false, + meta: false, + shift: false, + }, + }, + { + sequence: '\x1b[<0;10;20m', + expected: { + name: 'left-release', + ctrl: false, + meta: false, + shift: false, + }, + }, + { + sequence: '\x1b[<2;10;20M', + expected: { + name: 'right-press', + ctrl: false, + meta: false, + shift: false, + }, + }, + { + sequence: '\x1b[<1;10;20M', + expected: { + name: 'middle-press', + ctrl: false, + meta: false, + shift: false, + }, + }, + { + sequence: '\x1b[<64;10;20M', + expected: { + name: 'scroll-up', + ctrl: false, + meta: false, + shift: false, + }, + }, + { + sequence: '\x1b[<65;10;20M', + expected: { + name: 'scroll-down', + ctrl: false, + meta: false, + shift: false, + }, + }, + { + sequence: '\x1b[<32;10;20M', + expected: { + name: 'move', + ctrl: false, + meta: false, + shift: false, + }, + }, + { + sequence: '\x1b[<4;10;20M', + expected: { name: 'left-press', shift: true }, + }, // Shift + left press + { + sequence: '\x1b[<8;10;20M', + expected: { name: 'left-press', meta: true }, + }, // Alt + left press + { + sequence: '\x1b[<20;10;20M', + expected: { name: 'left-press', ctrl: true, shift: true }, + }, // Ctrl + Shift + left press + { + sequence: '\x1b[<68;10;20M', + expected: { name: 'scroll-up', shift: true }, + }, // Shift + scroll up + ])( + 'should recognize sequence "$sequence" as $expected.name', + ({ sequence, expected }) => { + const mouseHandler = vi.fn(); + const { result } = renderHook(() => useMouseContext(), { wrapper }); + act(() => result.current.subscribe(mouseHandler)); + + act(() => stdin.write(sequence)); + + expect(mouseHandler).toHaveBeenCalledWith( + expect.objectContaining({ ...expected }), + ); + }, + ); + }); +}); diff --git a/packages/cli/src/ui/contexts/MouseContext.tsx b/packages/cli/src/ui/contexts/MouseContext.tsx new file mode 100644 index 0000000000..8b1b8876a8 --- /dev/null +++ b/packages/cli/src/ui/contexts/MouseContext.tsx @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useStdin } from 'ink'; +import type React from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, +} from 'react'; +import { ESC } from '../utils/input.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { + isIncompleteMouseSequence, + parseMouseEvent, + type MouseEvent, + type MouseEventName, + type MouseHandler, +} from '../utils/mouse.js'; + +export type { MouseEvent, MouseEventName, MouseHandler }; + +const MAX_MOUSE_BUFFER_SIZE = 4096; + +interface MouseContextValue { + subscribe: (handler: MouseHandler) => void; + unsubscribe: (handler: MouseHandler) => void; +} + +const MouseContext = createContext(undefined); + +export function useMouseContext() { + const context = useContext(MouseContext); + if (!context) { + throw new Error('useMouseContext must be used within a MouseProvider'); + } + return context; +} + +export function useMouse(handler: MouseHandler, { isActive = true } = {}) { + const { subscribe, unsubscribe } = useMouseContext(); + + useEffect(() => { + if (!isActive) { + return; + } + + subscribe(handler); + return () => unsubscribe(handler); + }, [isActive, handler, subscribe, unsubscribe]); +} + +export function MouseProvider({ + children, + mouseEventsEnabled, + debugKeystrokeLogging, +}: { + children: React.ReactNode; + mouseEventsEnabled?: boolean; + debugKeystrokeLogging?: boolean; +}) { + const { stdin } = useStdin(); + const subscribers = useRef>(new Set()).current; + + const subscribe = useCallback( + (handler: MouseHandler) => { + subscribers.add(handler); + }, + [subscribers], + ); + + const unsubscribe = useCallback( + (handler: MouseHandler) => { + subscribers.delete(handler); + }, + [subscribers], + ); + + useEffect(() => { + if (!mouseEventsEnabled) { + return; + } + + let mouseBuffer = ''; + + const broadcast = (event: MouseEvent) => { + for (const handler of subscribers) { + handler(event); + } + }; + + const handleData = (data: Buffer | string) => { + mouseBuffer += typeof data === 'string' ? data : data.toString('utf-8'); + + // Safety cap to prevent infinite buffer growth on garbage + if (mouseBuffer.length > MAX_MOUSE_BUFFER_SIZE) { + mouseBuffer = mouseBuffer.slice(-MAX_MOUSE_BUFFER_SIZE); + } + + while (mouseBuffer.length > 0) { + const parsed = parseMouseEvent(mouseBuffer); + + if (parsed) { + if (debugKeystrokeLogging) { + debugLogger.log( + '[DEBUG] Mouse event parsed:', + JSON.stringify(parsed.event), + ); + } + broadcast(parsed.event); + mouseBuffer = mouseBuffer.slice(parsed.length); + continue; + } + + if (isIncompleteMouseSequence(mouseBuffer)) { + break; // Wait for more data + } + + // Not a valid sequence at start, and not waiting for more data. + // Discard garbage until next possible sequence start. + const nextEsc = mouseBuffer.indexOf(ESC, 1); + if (nextEsc !== -1) { + mouseBuffer = mouseBuffer.slice(nextEsc); + // Loop continues to try parsing at new location + } else { + mouseBuffer = ''; + break; + } + } + }; + + stdin.on('data', handleData); + + return () => { + stdin.removeListener('data', handleData); + }; + }, [stdin, mouseEventsEnabled, subscribers, debugKeystrokeLogging]); + + return ( + + {children} + + ); +} diff --git a/packages/cli/src/ui/hooks/useKeypress.test.tsx b/packages/cli/src/ui/hooks/useKeypress.test.tsx index 4ede175c09..c2aac50142 100644 --- a/packages/cli/src/ui/hooks/useKeypress.test.tsx +++ b/packages/cli/src/ui/hooks/useKeypress.test.tsx @@ -194,7 +194,7 @@ describe('useKeypress', () => { stdin.write('do'); }); expect(onKeypress).toHaveBeenCalledWith( - expect.objectContaining({ code: '[200d' }), + expect.objectContaining({ sequence: '\x1B[200d' }), ); expect(onKeypress).toHaveBeenCalledWith( expect.objectContaining({ sequence: 'o' }), diff --git a/packages/cli/src/ui/hooks/useMouse.test.ts b/packages/cli/src/ui/hooks/useMouse.test.ts new file mode 100644 index 0000000000..2dea0ee16c --- /dev/null +++ b/packages/cli/src/ui/hooks/useMouse.test.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import { renderHook } from '../../test-utils/render.js'; +import { useMouse } from './useMouse.js'; +import { MouseProvider, useMouseContext } from '../contexts/MouseContext.js'; + +vi.mock('../contexts/MouseContext.js', async (importOriginal) => { + const actual = + await importOriginal(); + const subscribe = vi.fn(); + const unsubscribe = vi.fn(); + return { + ...actual, + useMouseContext: () => ({ + subscribe, + unsubscribe, + }), + }; +}); + +describe('useMouse', () => { + const mockOnMouseEvent = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should not subscribe when isActive is false', () => { + renderHook(() => useMouse(mockOnMouseEvent, { isActive: false }), { + wrapper: MouseProvider, + }); + + const { subscribe } = useMouseContext(); + expect(subscribe).not.toHaveBeenCalled(); + }); + + it('should subscribe when isActive is true', () => { + renderHook(() => useMouse(mockOnMouseEvent, { isActive: true }), { + wrapper: MouseProvider, + }); + + const { subscribe } = useMouseContext(); + expect(subscribe).toHaveBeenCalledWith(mockOnMouseEvent); + }); + + it('should unsubscribe on unmount', () => { + const { unmount } = renderHook( + () => useMouse(mockOnMouseEvent, { isActive: true }), + { wrapper: MouseProvider }, + ); + + const { unsubscribe } = useMouseContext(); + unmount(); + expect(unsubscribe).toHaveBeenCalledWith(mockOnMouseEvent); + }); + + it('should unsubscribe when isActive becomes false', () => { + const { rerender } = renderHook( + ({ isActive }: { isActive: boolean }) => + useMouse(mockOnMouseEvent, { isActive }), + { + initialProps: { isActive: true }, + wrapper: MouseProvider, + }, + ); + + const { unsubscribe } = useMouseContext(); + rerender({ isActive: false }); + expect(unsubscribe).toHaveBeenCalledWith(mockOnMouseEvent); + }); +}); diff --git a/packages/cli/src/ui/hooks/useMouse.ts b/packages/cli/src/ui/hooks/useMouse.ts new file mode 100644 index 0000000000..9db8632081 --- /dev/null +++ b/packages/cli/src/ui/hooks/useMouse.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect } from 'react'; +import type { MouseHandler, MouseEvent } from '../contexts/MouseContext.js'; +import { useMouseContext } from '../contexts/MouseContext.js'; + +export type { MouseEvent }; + +/** + * A hook that listens for mouse events from stdin. + * + * @param onMouseEvent - The callback function to execute on each mouse event. + * @param options - Options to control the hook's behavior. + * @param options.isActive - Whether the hook should be actively listening for input. + */ +export function useMouse( + onMouseEvent: MouseHandler, + { isActive }: { isActive: boolean }, +) { + const { subscribe, unsubscribe } = useMouseContext(); + + useEffect(() => { + if (!isActive) { + return; + } + + subscribe(onMouseEvent); + return () => { + unsubscribe(onMouseEvent); + }; + }, [isActive, onMouseEvent, subscribe, unsubscribe]); +} diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 37176288cf..a2adeb090b 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -55,6 +55,7 @@ describe('keyMatchers', () => { [Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) => key.ctrl && key.name === 'g', [Command.TOGGLE_MARKDOWN]: (key: Key) => key.meta && key.name === 'm', + [Command.TOGGLE_COPY_MODE]: (key: Key) => key.ctrl && key.name === 's', [Command.QUIT]: (key: Key) => key.ctrl && key.name === 'c', [Command.EXIT]: (key: Key) => key.ctrl && key.name === 'd', [Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's', @@ -230,6 +231,11 @@ describe('keyMatchers', () => { positive: [createKey('m', { meta: true })], negative: [createKey('m'), createKey('m', { shift: true })], }, + { + command: Command.TOGGLE_COPY_MODE, + positive: [createKey('s', { ctrl: true })], + negative: [createKey('s'), createKey('s', { meta: true })], + }, { command: Command.QUIT, positive: [createKey('c', { ctrl: true })], diff --git a/packages/cli/src/ui/utils/input.test.ts b/packages/cli/src/ui/utils/input.test.ts new file mode 100644 index 0000000000..0b15497081 --- /dev/null +++ b/packages/cli/src/ui/utils/input.test.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { couldBeSGRMouseSequence, SGR_MOUSE_REGEX, ESC } from './input.js'; + +describe('input utils', () => { + describe('SGR_MOUSE_REGEX', () => { + it('should match valid SGR mouse sequences', () => { + // Press left button at 10, 20 + expect('\x1b[<0;10;20M').toMatch(SGR_MOUSE_REGEX); + // Release left button at 10, 20 + expect('\x1b[<0;10;20m').toMatch(SGR_MOUSE_REGEX); + // Move with left button held at 30, 40 + expect('\x1b[<32;30;40M').toMatch(SGR_MOUSE_REGEX); + // Scroll up at 5, 5 + expect('\x1b[<64;5;5M').toMatch(SGR_MOUSE_REGEX); + }); + + it('should not match invalid sequences', () => { + expect('hello').not.toMatch(SGR_MOUSE_REGEX); + expect('\x1b[A').not.toMatch(SGR_MOUSE_REGEX); // Arrow up + expect('\x1b[<0;10;20').not.toMatch(SGR_MOUSE_REGEX); // Incomplete + }); + }); + + describe('couldBeSGRMouseSequence', () => { + it('should return true for empty string', () => { + expect(couldBeSGRMouseSequence('')).toBe(true); + }); + + it('should return true for partial SGR prefixes', () => { + expect(couldBeSGRMouseSequence(ESC)).toBe(true); + expect(couldBeSGRMouseSequence(`${ESC}[`)).toBe(true); + expect(couldBeSGRMouseSequence(`${ESC}[<`)).toBe(true); + }); + + it('should return true for full SGR sequence start', () => { + expect(couldBeSGRMouseSequence(`${ESC}[<0;10;20M`)).toBe(true); + }); + + it('should return false for non-SGR sequences', () => { + expect(couldBeSGRMouseSequence('a')).toBe(false); + expect(couldBeSGRMouseSequence(`${ESC}a`)).toBe(false); + expect(couldBeSGRMouseSequence(`${ESC}[A`)).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/input.ts b/packages/cli/src/ui/utils/input.ts new file mode 100644 index 0000000000..7ef0439af8 --- /dev/null +++ b/packages/cli/src/ui/utils/input.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const ESC = '\u001B'; +export const SGR_EVENT_PREFIX = `${ESC}[<`; +export const X11_EVENT_PREFIX = `${ESC}[M`; + +// eslint-disable-next-line no-control-regex +export const SGR_MOUSE_REGEX = /^\x1b\[<(\d+);(\d+);(\d+)([mM])/; // SGR mouse events +// X11 is ESC [ M followed by 3 bytes. +// eslint-disable-next-line no-control-regex +export const X11_MOUSE_REGEX = /^\x1b\[M([\s\S]{3})/; + +export function couldBeSGRMouseSequence(buffer: string): boolean { + if (buffer.length === 0) return true; + // Check if buffer is a prefix of a mouse sequence starter + if (SGR_EVENT_PREFIX.startsWith(buffer)) return true; + // Check if buffer is a mouse sequence prefix + if (buffer.startsWith(SGR_EVENT_PREFIX)) return true; + + return false; +} + +export function couldBeMouseSequence(buffer: string): boolean { + if (buffer.length === 0) return true; + + // Check SGR prefix + if ( + SGR_EVENT_PREFIX.startsWith(buffer) || + buffer.startsWith(SGR_EVENT_PREFIX) + ) + return true; + // Check X11 prefix + if ( + X11_EVENT_PREFIX.startsWith(buffer) || + buffer.startsWith(X11_EVENT_PREFIX) + ) + return true; + + return false; +} + +/** + * Checks if the buffer *starts* with a complete mouse sequence. + * Returns the length of the sequence if matched, or 0 if not. + */ +export function getMouseSequenceLength(buffer: string): number { + const sgrMatch = buffer.match(SGR_MOUSE_REGEX); + if (sgrMatch) return sgrMatch[0].length; + + const x11Match = buffer.match(X11_MOUSE_REGEX); + if (x11Match) return x11Match[0].length; + + return 0; +} diff --git a/packages/cli/src/ui/utils/kittyProtocolDetector.ts b/packages/cli/src/ui/utils/kittyProtocolDetector.ts index 3355330a62..2d3e7a9d70 100644 --- a/packages/cli/src/ui/utils/kittyProtocolDetector.ts +++ b/packages/cli/src/ui/utils/kittyProtocolDetector.ts @@ -7,6 +7,7 @@ let detectionComplete = false; let protocolSupported = false; let protocolEnabled = false; +let sgrMouseEnabled = false; /** * Detects Kitty keyboard protocol support. @@ -76,12 +77,17 @@ export async function detectAndEnableKittyProtocol(): Promise { process.stdout.write('\x1b[>1u'); protocolSupported = true; protocolEnabled = true; - - // Set up cleanup on exit - process.on('exit', disableProtocol); - process.on('SIGTERM', disableProtocol); } + // Broaden mouse support by enabling SGR mode if we get any device + // attribute response, which is a strong signal of a modern terminal. + process.stdout.write('\x1b[?1006h'); + sgrMouseEnabled = true; + + // Set up cleanup on exit for all enabled protocols + process.on('exit', disableAllProtocols); + process.on('SIGTERM', disableAllProtocols); + detectionComplete = true; resolve(protocolSupported); } @@ -100,11 +106,15 @@ export async function detectAndEnableKittyProtocol(): Promise { }); } -function disableProtocol() { +function disableAllProtocols() { if (protocolEnabled) { process.stdout.write('\x1b[ { + describe('parseSGRMouseEvent', () => { + it('parses a valid SGR mouse press', () => { + // Button 0 (left), col 37, row 25, press (M) + const input = `${ESC}[<0;37;25M`; + const result = parseSGRMouseEvent(input); + expect(result).not.toBeNull(); + expect(result!.event).toEqual({ + name: 'left-press', + col: 37, + row: 25, + shift: false, + meta: false, + ctrl: false, + }); + expect(result!.length).toBe(input.length); + }); + + it('parses a valid SGR mouse release', () => { + // Button 0 (left), col 37, row 25, release (m) + const input = `${ESC}[<0;37;25m`; + const result = parseSGRMouseEvent(input); + expect(result).not.toBeNull(); + expect(result!.event).toEqual({ + name: 'left-release', + col: 37, + row: 25, + shift: false, + meta: false, + ctrl: false, + }); + }); + + it('parses SGR with modifiers', () => { + // Button 0 + Shift(4) + Meta(8) + Ctrl(16) = 0 + 4 + 8 + 16 = 28 + const input = `${ESC}[<28;10;20M`; + const result = parseSGRMouseEvent(input); + expect(result).not.toBeNull(); + expect(result!.event).toEqual({ + name: 'left-press', + col: 10, + row: 20, + shift: true, + meta: true, + ctrl: true, + }); + }); + + it('parses SGR move event', () => { + // Button 0 + Move(32) = 32 + const input = `${ESC}[<32;10;20M`; + const result = parseSGRMouseEvent(input); + expect(result).not.toBeNull(); + expect(result!.event.name).toBe('move'); + }); + + it('parses SGR scroll events', () => { + expect(parseSGRMouseEvent(`${ESC}[<64;1;1M`)!.event.name).toBe( + 'scroll-up', + ); + expect(parseSGRMouseEvent(`${ESC}[<65;1;1M`)!.event.name).toBe( + 'scroll-down', + ); + }); + + it('returns null for invalid SGR', () => { + expect(parseSGRMouseEvent(`${ESC}[<;1;1M`)).toBeNull(); + expect(parseSGRMouseEvent(`${ESC}[<0;1;M`)).toBeNull(); + expect(parseSGRMouseEvent(`not sgr`)).toBeNull(); + }); + }); + + describe('parseX11MouseEvent', () => { + it('parses a valid X11 mouse press', () => { + // Button 0 (left) + 32 = ' ' (space) + // Col 1 + 32 = '!' + // Row 1 + 32 = '!' + const input = `${ESC}[M !!`; + const result = parseX11MouseEvent(input); + expect(result).not.toBeNull(); + expect(result!.event).toEqual({ + name: 'left-press', + col: 1, + row: 1, + shift: false, + meta: false, + ctrl: false, + }); + expect(result!.length).toBe(6); + }); + + it('returns null for incomplete X11', () => { + expect(parseX11MouseEvent(`${ESC}[M !`)).toBeNull(); + }); + }); + + describe('isIncompleteMouseSequence', () => { + it('returns true for prefixes', () => { + expect(isIncompleteMouseSequence(ESC)).toBe(true); + expect(isIncompleteMouseSequence(`${ESC}[`)).toBe(true); + expect(isIncompleteMouseSequence(`${ESC}[<`)).toBe(true); + expect(isIncompleteMouseSequence(`${ESC}[M`)).toBe(true); + }); + + it('returns true for partial SGR', () => { + expect(isIncompleteMouseSequence(`${ESC}[<0;10;20`)).toBe(true); + }); + + it('returns true for partial X11', () => { + expect(isIncompleteMouseSequence(`${ESC}[M `)).toBe(true); + expect(isIncompleteMouseSequence(`${ESC}[M !`)).toBe(true); + }); + + it('returns false for complete SGR', () => { + expect(isIncompleteMouseSequence(`${ESC}[<0;10;20M`)).toBe(false); + }); + + it('returns false for complete X11', () => { + expect(isIncompleteMouseSequence(`${ESC}[M !!!`)).toBe(false); + }); + + it('returns false for non-mouse sequences', () => { + expect(isIncompleteMouseSequence('a')).toBe(false); + expect(isIncompleteMouseSequence(`${ESC}[A`)).toBe(false); // Arrow up + }); + + it('returns false for garbage that started like a mouse sequence but got too long (SGR)', () => { + const longGarbage = `${ESC}[<` + '0'.repeat(100); + expect(isIncompleteMouseSequence(longGarbage)).toBe(false); + }); + }); + + describe('parseMouseEvent', () => { + it('parses SGR', () => { + expect(parseMouseEvent(`${ESC}[<0;1;1M`)).not.toBeNull(); + }); + it('parses X11', () => { + expect(parseMouseEvent(`${ESC}[M !!!`)).not.toBeNull(); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/mouse.ts b/packages/cli/src/ui/utils/mouse.ts new file mode 100644 index 0000000000..1632c64c74 --- /dev/null +++ b/packages/cli/src/ui/utils/mouse.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import process from 'node:process'; +import { + SGR_MOUSE_REGEX, + X11_MOUSE_REGEX, + SGR_EVENT_PREFIX, + X11_EVENT_PREFIX, + couldBeMouseSequence as inputCouldBeMouseSequence, +} from './input.js'; + +export type MouseEventName = + | 'left-press' + | 'left-release' + | 'right-press' + | 'right-release' + | 'middle-press' + | 'middle-release' + | 'scroll-up' + | 'scroll-down' + | 'scroll-left' + | 'scroll-right' + | 'move'; + +export interface MouseEvent { + name: MouseEventName; + col: number; + row: number; + shift: boolean; + meta: boolean; + ctrl: boolean; +} + +export type MouseHandler = (event: MouseEvent) => void; + +export function getMouseEventName( + buttonCode: number, + isRelease: boolean, +): MouseEventName | null { + const isMove = (buttonCode & 32) !== 0; + + if (buttonCode === 66) { + return 'scroll-left'; + } else if (buttonCode === 67) { + return 'scroll-right'; + } else if ((buttonCode & 64) === 64) { + if ((buttonCode & 1) === 0) { + return 'scroll-up'; + } else { + return 'scroll-down'; + } + } else if (isMove) { + return 'move'; + } else { + const button = buttonCode & 3; + const type = isRelease ? 'release' : 'press'; + switch (button) { + case 0: + return `left-${type}`; + case 1: + return `middle-${type}`; + case 2: + return `right-${type}`; + default: + return null; + } + } +} + +export function parseSGRMouseEvent( + buffer: string, +): { event: MouseEvent; length: number } | null { + const match = buffer.match(SGR_MOUSE_REGEX); + + if (match) { + const buttonCode = parseInt(match[1], 10); + const col = parseInt(match[2], 10); + const row = parseInt(match[3], 10); + const action = match[4]; + const isRelease = action === 'm'; + + const shift = (buttonCode & 4) !== 0; + const meta = (buttonCode & 8) !== 0; + const ctrl = (buttonCode & 16) !== 0; + + const name = getMouseEventName(buttonCode, isRelease); + + if (name) { + return { + event: { + name, + ctrl, + meta, + shift, + col, + row, + }, + length: match[0].length, + }; + } + return null; + } + + return null; +} + +export function parseX11MouseEvent( + buffer: string, +): { event: MouseEvent; length: number } | null { + const match = buffer.match(X11_MOUSE_REGEX); + if (!match) return null; + + // The 3 bytes are in match[1] + const b = match[1].charCodeAt(0) - 32; + const col = match[1].charCodeAt(1) - 32; + const row = match[1].charCodeAt(2) - 32; + + const shift = (b & 4) !== 0; + const meta = (b & 8) !== 0; + const ctrl = (b & 16) !== 0; + const isMove = (b & 32) !== 0; + const isWheel = (b & 64) !== 0; + + let name: MouseEventName | null = null; + + if (isWheel) { + const button = b & 3; + switch (button) { + case 0: + name = 'scroll-up'; + break; + case 1: + name = 'scroll-down'; + break; + default: + break; + } + } else if (isMove) { + name = 'move'; + } else { + const button = b & 3; + if (button === 3) { + // X11 reports 'release' (3) for all button releases without specifying which one. + // We'll default to 'left-release' as a best-effort guess if we don't track state. + name = 'left-release'; + } else { + switch (button) { + case 0: + name = 'left-press'; + break; + case 1: + name = 'middle-press'; + break; + case 2: + name = 'right-press'; + break; + default: + break; + } + } + } + + if (name) { + return { + event: { name, ctrl, meta, shift, col, row }, + length: match[0].length, + }; + } + return null; +} + +export function parseMouseEvent( + buffer: string, +): { event: MouseEvent; length: number } | null { + return parseSGRMouseEvent(buffer) || parseX11MouseEvent(buffer); +} + +export function isIncompleteMouseSequence(buffer: string): boolean { + if (!inputCouldBeMouseSequence(buffer)) return false; + + // If it matches a complete sequence, it's not incomplete. + if (parseMouseEvent(buffer)) return false; + + if (buffer.startsWith(X11_EVENT_PREFIX)) { + // X11 needs exactly 3 bytes after prefix. + return buffer.length < X11_EVENT_PREFIX.length + 3; + } + + if (buffer.startsWith(SGR_EVENT_PREFIX)) { + // SGR sequences end with 'm' or 'M'. + // If it doesn't have it yet, it's incomplete. + // Add a reasonable max length check to fail early on garbage. + return !/[mM]/.test(buffer) && buffer.length < 50; + } + + // It's a prefix of the prefix (e.g. "ESC" or "ESC [") + return true; +} + +export function enableMouseEvents() { + // Enable mouse tracking with SGR format + // ?1002h = button event tracking (clicks + drags + scroll wheel) + // ?1006h = SGR extended mouse mode (better coordinate handling) + process.stdout.write('\u001b[?1002h\u001b[?1006h'); +} + +export function disableMouseEvents() { + // Disable mouse tracking with SGR format + process.stdout.write('\u001b[?1006l\u001b[?1002l'); +} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 47a4d18534..b1c945e470 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -275,6 +275,13 @@ "default": false, "type": "boolean" }, + "useAlternateBuffer": { + "title": "Use Alternate Screen Buffer", + "description": "Use an alternate screen buffer for the UI, preserving shell history.", + "markdownDescription": "Use an alternate screen buffer for the UI, preserving shell history.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "customWittyPhrases": { "title": "Custom Witty Phrases", "description": "Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults.",