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.",