From 384fb6a465bb576f67daa1ee459db973bd554ba8 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Mon, 5 Jan 2026 16:11:50 -0800 Subject: [PATCH] Add setting to support OSC 52 paste (#15336) --- docs/get-started/configuration.md | 5 + packages/cli/src/config/settingsSchema.ts | 10 ++ .../src/ui/components/InputPrompt.test.tsx | 32 +++++- .../cli/src/ui/components/InputPrompt.tsx | 19 +++- .../src/ui/contexts/KeypressContext.test.tsx | 105 ++++++++++++++++++ .../cli/src/ui/contexts/KeypressContext.tsx | 48 +++++++- schemas/settings.schema.json | 7 ++ 7 files changed, 216 insertions(+), 10 deletions(-) diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 246617854c..4ac0ac0764 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -848,6 +848,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `"auto"` - **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): - **Description:** Enable the Introspection Agent. - **Default:** `false` diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index ff9ce70b78..a2d7b2a008 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 0c273cac86..7717b1e2f9 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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( + , + { 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([ diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 8aac3d0fd4..78031a8a9e 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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 = ({ 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 = ({ } } - 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, diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index c78ccf11a5..c4ce92ee52 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -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', () => { diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 8bfe51694f..a5ff7e92a2 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -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 ] ; BEL + // ESC ] ; 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; or 52;p; + 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; diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 4e556269dd..4900fa25d6 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1418,6 +1418,13 @@ }, "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": { "title": "Introspection Agent Settings", "description": "Configuration for Introspection Agent.",