mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 22:55:13 +00:00
Add setting to support OSC 52 paste (#15336)
This commit is contained in:
committed by
GitHub
parent
2cb33b2f76
commit
384fb6a465
@@ -848,6 +848,11 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
- **Default:** `"auto"`
|
- **Default:** `"auto"`
|
||||||
- **Requires restart:** Yes
|
- **Requires restart:** Yes
|
||||||
|
|
||||||
|
- **`experimental.useOSC52Paste`** (boolean):
|
||||||
|
- **Description:** Use OSC 52 sequence for pasting instead of clipboardy
|
||||||
|
(useful for remote sessions).
|
||||||
|
- **Default:** `false`
|
||||||
|
|
||||||
- **`experimental.introspectionAgentSettings.enabled`** (boolean):
|
- **`experimental.introspectionAgentSettings.enabled`** (boolean):
|
||||||
- **Description:** Enable the Introspection Agent.
|
- **Description:** Enable the Introspection Agent.
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
|||||||
@@ -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: {
|
introspectionAgentSettings: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
label: 'Introspection Agent Settings',
|
label: 'Introspection Agent Settings',
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 { waitFor } from '../../test-utils/async.js';
|
||||||
import { act } from 'react';
|
import { act } from 'react';
|
||||||
import type { InputPromptProps } from './InputPrompt.js';
|
import type { InputPromptProps } from './InputPrompt.js';
|
||||||
@@ -598,7 +601,7 @@ describe('InputPrompt', () => {
|
|||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||||
'Error handling clipboard image:',
|
'Error handling paste:',
|
||||||
expect.any(Error),
|
expect.any(Error),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -633,6 +636,31 @@ describe('InputPrompt', () => {
|
|||||||
});
|
});
|
||||||
unmount();
|
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([
|
it.each([
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import clipboardy from 'clipboardy';
|
import clipboardy from 'clipboardy';
|
||||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
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 { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { useInputHistory } from '../hooks/useInputHistory.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 { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
import { StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
import { useMouseClick } from '../hooks/useMouseClick.js';
|
import { useMouseClick } from '../hooks/useMouseClick.js';
|
||||||
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
|
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
|
||||||
@@ -129,6 +130,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
suggestionsPosition = 'below',
|
suggestionsPosition = 'below',
|
||||||
setBannerVisible,
|
setBannerVisible,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { stdout } = useStdout();
|
||||||
|
const { merged: settings } = useSettings();
|
||||||
const kittyProtocol = useKittyKeyboardProtocol();
|
const kittyProtocol = useKittyKeyboardProtocol();
|
||||||
const isShellFocused = useShellFocusState();
|
const isShellFocused = useShellFocusState();
|
||||||
const { setEmbeddedShellFocused } = useUIActions();
|
const { setEmbeddedShellFocused } = useUIActions();
|
||||||
@@ -350,13 +353,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const textToInsert = await clipboardy.read();
|
if (settings.experimental?.useOSC52Paste) {
|
||||||
const offset = buffer.getOffset();
|
stdout.write('\x1b]52;c;?\x07');
|
||||||
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
} else {
|
||||||
|
const textToInsert = await clipboardy.read();
|
||||||
|
const offset = buffer.getOffset();
|
||||||
|
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugLogger.error('Error handling clipboard image:', error);
|
debugLogger.error('Error handling paste:', error);
|
||||||
}
|
}
|
||||||
}, [buffer, config]);
|
}, [buffer, config, stdout, settings]);
|
||||||
|
|
||||||
useMouseClick(
|
useMouseClick(
|
||||||
innerBoxRef,
|
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', () => {
|
describe('debug keystroke logging', () => {
|
||||||
|
|||||||
@@ -317,12 +317,56 @@ function* emitKeys(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (escaped && (ch === 'O' || ch === '[')) {
|
if (escaped && (ch === 'O' || ch === '[' || ch === ']')) {
|
||||||
// ANSI escape sequence
|
// ANSI escape sequence
|
||||||
code = ch;
|
code = ch;
|
||||||
let modifier = 0;
|
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 letter
|
||||||
// ESC O modifier letter
|
// ESC O modifier letter
|
||||||
ch = yield;
|
ch = yield;
|
||||||
|
|||||||
@@ -1418,6 +1418,13 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"useOSC52Paste": {
|
||||||
|
"title": "Use OSC 52 Paste",
|
||||||
|
"description": "Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).",
|
||||||
|
"markdownDescription": "Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`",
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"introspectionAgentSettings": {
|
"introspectionAgentSettings": {
|
||||||
"title": "Introspection Agent Settings",
|
"title": "Introspection Agent Settings",
|
||||||
"description": "Configuration for Introspection Agent.",
|
"description": "Configuration for Introspection Agent.",
|
||||||
|
|||||||
Reference in New Issue
Block a user