alternate buffer support (#12471)

This commit is contained in:
Jacob Richman
2025-11-03 13:41:58 -08:00
committed by GitHub
parent 60973aacd9
commit 4fc9b1cde2
26 changed files with 1893 additions and 257 deletions

View File

@@ -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. |

View File

@@ -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.

View File

@@ -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 }],

View File

@@ -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',

View File

@@ -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}
>
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
debugKeystrokeLogging={
settings.merged.general?.debugKeystrokeLogging
}
>
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</MouseProvider>
</KeypressProvider>
</SettingsContext.Provider>
);
@@ -213,7 +230,8 @@ export async function startInteractiveUI(
recordSlowRender(config, renderTime);
}
},
} as RenderOptions,
alternateBuffer: settings.merged.ui?.useAlternateBuffer,
},
);
checkForUpdates(settings)

View File

@@ -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<UIState>;
width?: number;
kittyProtocolEnabled?: boolean;
mouseEventsEnabled?: boolean;
config?: Config;
} = {},
): ReturnType<typeof render> => {
@@ -163,14 +166,16 @@ export const renderWithProviders = (
<VimModeProvider settings={settings}>
<ShellFocusContext.Provider value={shellFocus}>
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</MouseProvider>
</KeypressProvider>
</ShellFocusContext.Provider>
</VimModeProvider>

View File

@@ -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(
<AppContainer
config={mockConfig}
settings={testSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
await act(async () => {
vi.advanceTimersByTime(0);
});
rerender = () =>
renderResult.rerender(
<AppContainer
config={mockConfig}
settings={testSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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({

View File

@@ -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<boolean>(false);
const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false);
const [showDebugProfiler, setShowDebugProfiler] = useState(false);
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(
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,
],
);

View File

@@ -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(
<InputPrompt {...props} />,
);
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(
<InputPrompt {...props} />,
{ 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(
<InputPrompt {...props} />,
{ 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();

View File

@@ -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<InputPromptProps> = ({
number | null
>(null);
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const innerBoxRef = useRef<DOMElement>(null);
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
@@ -356,6 +358,31 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}, [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<InputPromptProps> = ({
'>'
)}{' '}
</Text>
<Box flexGrow={1} flexDirection="column">
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>

View File

@@ -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 = [
<AppHeader key="app-header" version={version} />,
...uiState.history.map((h) => (
<HistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
/>
)),
];
const pendingItems = (
<OverflowProvider>
<Box flexDirection="column" width={mainAreaWidth}>
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>
);
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 (
<Box
flexDirection="column"
overflowY="scroll"
scrollTop={Number.MAX_SAFE_INTEGER}
maxHeight={availableTerminalHeight}
>
<Box flexDirection="column" flexShrink={0}>
{historyItems}
{pendingItems}
</Box>
</Box>
);
}
return (
<>
<Static
key={uiState.historyRemountKey}
items={[
<AppHeader key="app-header" version={version} />,
...uiState.history.map((h) => (
<HistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
/>
)),
]}
>
<Static key={uiState.historyRemountKey} items={historyItems}>
{(item) => item}
</Static>
<OverflowProvider>
<Box flexDirection="column" width={mainAreaWidth}>
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>
{pendingItems}
</>
);
};

View File

@@ -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
/**

View File

@@ -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 }) => (
<KeypressProvider kittyProtocolEnabled={true}>
{children}
</KeypressProvider>
);
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 }) => (
<KeypressProvider kittyProtocolEnabled={kittyEnabled}>
{children}
</KeypressProvider>
);
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 }) => (
<KeypressProvider kittyProtocolEnabled={false}>
{children}
</KeypressProvider>
);
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~' }),
);
});
});
});

View File

@@ -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[<digit> - could be CSI-u or tilde-coded
// 2. ESC[1;<digit> - 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.

View File

@@ -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<typeof import('ink')>();
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 }) => (
<MouseProvider mouseEventsEnabled={true}>{children}</MouseProvider>
);
});
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 }),
);
},
);
});
});

View File

@@ -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<MouseContextValue | undefined>(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<Set<MouseHandler>>(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 (
<MouseContext.Provider value={{ subscribe, unsubscribe }}>
{children}
</MouseContext.Provider>
);
}

View File

@@ -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' }),

View File

@@ -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<typeof import('../contexts/MouseContext.js')>();
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);
});
});

View File

@@ -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]);
}

View File

@@ -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 })],

View File

@@ -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);
});
});
});

View File

@@ -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;
}

View File

@@ -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<boolean> {
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<boolean> {
});
}
function disableProtocol() {
function disableAllProtocols() {
if (protocolEnabled) {
process.stdout.write('\x1b[<u');
protocolEnabled = false;
}
if (sgrMouseEnabled) {
process.stdout.write('\x1b[?1006l'); // Disable SGR Mouse
sgrMouseEnabled = false;
}
}
export function isKittyProtocolEnabled(): boolean {

View File

@@ -0,0 +1,156 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
parseSGRMouseEvent,
parseX11MouseEvent,
isIncompleteMouseSequence,
parseMouseEvent,
} from './mouse.js';
import { ESC } from './input.js';
describe('mouse utils', () => {
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();
});
});
});

View File

@@ -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');
}

View File

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