mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-14 16:10:59 -07:00
Add setting to support OSC 52 paste (#15336)
This commit is contained in:
committed by
GitHub
parent
2cb33b2f76
commit
384fb6a465
@@ -1443,6 +1443,16 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
useOSC52Paste: {
|
||||
type: 'boolean',
|
||||
label: 'Use OSC 52 Paste',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).',
|
||||
showInDialog: true,
|
||||
},
|
||||
introspectionAgentSettings: {
|
||||
type: 'object',
|
||||
label: 'Introspection Agent Settings',
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import {
|
||||
renderWithProviders,
|
||||
createMockSettings,
|
||||
} from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { act } from 'react';
|
||||
import type { InputPromptProps } from './InputPrompt.js';
|
||||
@@ -598,7 +601,7 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
'Error handling clipboard image:',
|
||||
'Error handling paste:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
@@ -633,6 +636,31 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should use OSC 52 when useOSC52Paste setting is enabled', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
const settings = createMockSettings({
|
||||
experimental: { useOSC52Paste: true },
|
||||
});
|
||||
|
||||
const { stdout, stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
const writeSpy = vi.spyOn(stdout, 'write');
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeSpy).toHaveBeenCalledWith('\x1b]52;c;?\x07');
|
||||
});
|
||||
// Should NOT call clipboardy.read()
|
||||
expect(clipboardy.read).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import type React from 'react';
|
||||
import clipboardy from 'clipboardy';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import { Box, Text, useStdout, type DOMElement } from 'ink';
|
||||
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
@@ -43,6 +43,7 @@ import * as path from 'node:path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { useMouseClick } from '../hooks/useMouseClick.js';
|
||||
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
|
||||
@@ -129,6 +130,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
suggestionsPosition = 'below',
|
||||
setBannerVisible,
|
||||
}) => {
|
||||
const { stdout } = useStdout();
|
||||
const { merged: settings } = useSettings();
|
||||
const kittyProtocol = useKittyKeyboardProtocol();
|
||||
const isShellFocused = useShellFocusState();
|
||||
const { setEmbeddedShellFocused } = useUIActions();
|
||||
@@ -350,13 +353,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const textToInsert = await clipboardy.read();
|
||||
const offset = buffer.getOffset();
|
||||
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||||
if (settings.experimental?.useOSC52Paste) {
|
||||
stdout.write('\x1b]52;c;?\x07');
|
||||
} else {
|
||||
const textToInsert = await clipboardy.read();
|
||||
const offset = buffer.getOffset();
|
||||
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Error handling clipboard image:', error);
|
||||
debugLogger.error('Error handling paste:', error);
|
||||
}
|
||||
}, [buffer, config]);
|
||||
}, [buffer, config, stdout, settings]);
|
||||
|
||||
useMouseClick(
|
||||
innerBoxRef,
|
||||
|
||||
@@ -320,6 +320,111 @@ describe('KeypressContext', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse valid OSC 52 response', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
const base64Data = Buffer.from('Hello OSC 52').toString('base64');
|
||||
const sequence = `\x1b]52;c;${base64Data}\x07`;
|
||||
|
||||
act(() => stdin.write(sequence));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'paste',
|
||||
paste: true,
|
||||
sequence: 'Hello OSC 52',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle split OSC 52 response', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
const base64Data = Buffer.from('Split Paste').toString('base64');
|
||||
const sequence = `\x1b]52;c;${base64Data}\x07`;
|
||||
|
||||
// Split the sequence
|
||||
const part1 = sequence.slice(0, 5);
|
||||
const part2 = sequence.slice(5);
|
||||
|
||||
act(() => stdin.write(part1));
|
||||
act(() => stdin.write(part2));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'paste',
|
||||
paste: true,
|
||||
sequence: 'Split Paste',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle OSC 52 response terminated by ESC \\', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
const base64Data = Buffer.from('Terminated by ST').toString('base64');
|
||||
const sequence = `\x1b]52;c;${base64Data}\x1b\\`;
|
||||
|
||||
act(() => stdin.write(sequence));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'paste',
|
||||
paste: true,
|
||||
sequence: 'Terminated by ST',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore unknown OSC sequences', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
const sequence = `\x1b]1337;File=name=Zm9vCg==\x07`;
|
||||
|
||||
act(() => stdin.write(sequence));
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore invalid OSC 52 format', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
const sequence = `\x1b]52;x;notbase64\x07`;
|
||||
|
||||
act(() => stdin.write(sequence));
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('debug keystroke logging', () => {
|
||||
|
||||
@@ -317,12 +317,56 @@ function* emitKeys(
|
||||
}
|
||||
}
|
||||
|
||||
if (escaped && (ch === 'O' || ch === '[')) {
|
||||
if (escaped && (ch === 'O' || ch === '[' || ch === ']')) {
|
||||
// ANSI escape sequence
|
||||
code = ch;
|
||||
let modifier = 0;
|
||||
|
||||
if (ch === 'O') {
|
||||
if (ch === ']') {
|
||||
// OSC sequence
|
||||
// ESC ] <params> ; <data> BEL
|
||||
// ESC ] <params> ; <data> ESC \
|
||||
let buffer = '';
|
||||
|
||||
// Read until BEL, `ESC \`, or timeout (empty string)
|
||||
while (true) {
|
||||
const next = yield;
|
||||
if (next === '' || next === '\u0007') {
|
||||
break;
|
||||
} else if (next === ESC) {
|
||||
const afterEsc = yield;
|
||||
if (afterEsc === '' || afterEsc === '\\') {
|
||||
break;
|
||||
}
|
||||
buffer += next + afterEsc;
|
||||
continue;
|
||||
}
|
||||
buffer += next;
|
||||
}
|
||||
|
||||
// Check for OSC 52 (Clipboard) response
|
||||
// Format: 52;c;<base64> or 52;p;<base64>
|
||||
const match = /^52;[cp];(.*)$/.exec(buffer);
|
||||
if (match) {
|
||||
try {
|
||||
const base64Data = match[1];
|
||||
const decoded = Buffer.from(base64Data, 'base64').toString('utf-8');
|
||||
keypressHandler({
|
||||
name: 'paste',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: true,
|
||||
insertable: true,
|
||||
sequence: decoded,
|
||||
});
|
||||
} catch (_e) {
|
||||
debugLogger.log('Failed to decode OSC 52 clipboard data');
|
||||
}
|
||||
}
|
||||
|
||||
continue; // resume main loop
|
||||
} else if (ch === 'O') {
|
||||
// ESC O letter
|
||||
// ESC O modifier letter
|
||||
ch = yield;
|
||||
|
||||
Reference in New Issue
Block a user