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