Add setting to support OSC 52 paste (#15336)

This commit is contained in:
Tommaso Sciortino
2026-01-05 16:11:50 -08:00
committed by GitHub
parent 2cb33b2f76
commit 384fb6a465
7 changed files with 216 additions and 10 deletions

View File

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

View File

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

View File

@@ -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([

View File

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

View File

@@ -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', () => {

View File

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

View File

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