mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
alternate buffer support (#12471)
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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~' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
190
packages/cli/src/ui/contexts/MouseContext.test.tsx
Normal file
190
packages/cli/src/ui/contexts/MouseContext.test.tsx
Normal 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 }),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
149
packages/cli/src/ui/contexts/MouseContext.tsx
Normal file
149
packages/cli/src/ui/contexts/MouseContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' }),
|
||||
|
||||
76
packages/cli/src/ui/hooks/useMouse.test.ts
Normal file
76
packages/cli/src/ui/hooks/useMouse.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
36
packages/cli/src/ui/hooks/useMouse.ts
Normal file
36
packages/cli/src/ui/hooks/useMouse.ts
Normal 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]);
|
||||
}
|
||||
@@ -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 })],
|
||||
|
||||
50
packages/cli/src/ui/utils/input.test.ts
Normal file
50
packages/cli/src/ui/utils/input.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
packages/cli/src/ui/utils/input.ts
Normal file
58
packages/cli/src/ui/utils/input.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
156
packages/cli/src/ui/utils/mouse.test.ts
Normal file
156
packages/cli/src/ui/utils/mouse.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
214
packages/cli/src/ui/utils/mouse.ts
Normal file
214
packages/cli/src/ui/utils/mouse.ts
Normal 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');
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user