feat(cli): add experimental.useOSC52Copy setting (#19488)

This commit is contained in:
Tommaso Sciortino
2026-02-19 10:22:11 -08:00
committed by GitHub
parent ba3e327ba1
commit 09b623fbd7
8 changed files with 98 additions and 19 deletions

View File

@@ -128,12 +128,13 @@ they appear in the UI.
### Experimental
| UI Label | Setting | Description | Default |
| -------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| UI Label | Setting | Description | Default |
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
### Skills

View File

@@ -941,8 +941,15 @@ their corresponding top-level category object in your `settings.json` file.
- **Requires restart:** Yes
- **`experimental.useOSC52Paste`** (boolean):
- **Description:** Use OSC 52 sequence for pasting instead of clipboardy
(useful for remote sessions).
- **Description:** Use OSC 52 for pasting. This may be more robust than the
default system when using remote terminal sessions (if your terminal is
configured to allow it).
- **Default:** `false`
- **`experimental.useOSC52Copy`** (boolean):
- **Description:** Use OSC 52 for copying. This may be more robust than the
default system when using remote terminal sessions (if your terminal is
configured to allow it).
- **Default:** `false`
- **`experimental.plan`** (boolean):

View File

@@ -1632,7 +1632,17 @@ const SETTINGS_SCHEMA = {
requiresRestart: false,
default: false,
description:
'Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).',
'Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).',
showInDialog: true,
},
useOSC52Copy: {
type: 'boolean',
label: 'Use OSC 52 Copy',
category: 'Experimental',
requiresRestart: false,
default: false,
description:
'Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).',
showInDialog: true,
},
plan: {

View File

@@ -125,6 +125,7 @@ describe('copyCommand', () => {
expect(mockCopyToClipboard).toHaveBeenCalledWith(
'Hi there! How can I help you?',
expect.anything(),
);
});
@@ -143,7 +144,10 @@ describe('copyCommand', () => {
const result = await copyCommand.action(mockContext, '');
expect(mockCopyToClipboard).toHaveBeenCalledWith('Part 1: Part 2: Part 3');
expect(mockCopyToClipboard).toHaveBeenCalledWith(
'Part 1: Part 2: Part 3',
expect.anything(),
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -170,7 +174,10 @@ describe('copyCommand', () => {
const result = await copyCommand.action(mockContext, '');
expect(mockCopyToClipboard).toHaveBeenCalledWith('Text part more text');
expect(mockCopyToClipboard).toHaveBeenCalledWith(
'Text part more text',
expect.anything(),
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -201,7 +208,10 @@ describe('copyCommand', () => {
const result = await copyCommand.action(mockContext, '');
expect(mockCopyToClipboard).toHaveBeenCalledWith('Second AI response');
expect(mockCopyToClipboard).toHaveBeenCalledWith(
'Second AI response',
expect.anything(),
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -230,6 +240,11 @@ describe('copyCommand', () => {
messageType: 'error',
content: `Failed to copy to the clipboard. ${clipboardError.message}`,
});
expect(mockCopyToClipboard).toHaveBeenCalledWith(
'AI response',
expect.anything(),
);
});
it('should handle non-Error clipboard errors', async () => {
@@ -253,6 +268,11 @@ describe('copyCommand', () => {
messageType: 'error',
content: `Failed to copy to the clipboard. ${rejectedValue}`,
});
expect(mockCopyToClipboard).toHaveBeenCalledWith(
'AI response',
expect.anything(),
);
});
it('should return info message when no text parts found in AI message', async () => {

View File

@@ -38,7 +38,8 @@ export const copyCommand: SlashCommand = {
if (lastAiOutput) {
try {
await copyToClipboard(lastAiOutput);
const settings = context.services.settings.merged;
await copyToClipboard(lastAiOutput, settings);
return {
type: 'message',

View File

@@ -14,6 +14,7 @@ import {
copyToClipboard,
getUrlOpenCommand,
} from './commandUtils.js';
import type { Settings } from '../../config/settingsSchema.js';
// Constants used by OSC-52 tests
const ESC = '\u001B';
@@ -257,6 +258,29 @@ describe('commandUtils', () => {
expect(mockClipboardyWrite).not.toHaveBeenCalled();
});
it('uses OSC-52 when useOSC52Copy setting is enabled', async () => {
const testText = 'forced-osc52';
const tty = makeWritable({ isTTY: true });
mockFs.createWriteStream.mockImplementation(() => {
setTimeout(() => tty.emit('open'), 0);
return tty;
});
// NO environment signals for SSH/WSL/etc.
const settings = {
experimental: { useOSC52Copy: true },
} as unknown as Settings;
await copyToClipboard(testText, settings);
const b64 = Buffer.from(testText, 'utf8').toString('base64');
const expected = `${ESC}]52;c;${b64}${BEL}`;
expect(tty.write).toHaveBeenCalledTimes(1);
expect(tty.write.mock.calls[0][0]).toBe(expected);
expect(mockClipboardyWrite).not.toHaveBeenCalled();
});
it('wraps OSC-52 for tmux when in SSH', async () => {
const testText = 'tmux-copy';
const tty = makeWritable({ isTTY: true });

View File

@@ -9,6 +9,7 @@ import clipboardy from 'clipboardy';
import type { SlashCommand } from '../commands/types.js';
import fs from 'node:fs';
import type { Writable } from 'node:stream';
import type { Settings } from '../../config/settingsSchema.js';
/**
* Checks if a query string potentially represents an '@' command.
@@ -157,8 +158,13 @@ const isWindowsTerminal = (): boolean =>
const isDumbTerm = (): boolean => (process.env['TERM'] ?? '') === 'dumb';
const shouldUseOsc52 = (tty: TtyTarget): boolean =>
Boolean(tty) && !isDumbTerm() && (isSSH() || isWSL() || isWindowsTerminal());
const shouldUseOsc52 = (tty: TtyTarget, settings?: Settings): boolean =>
Boolean(tty) &&
!isDumbTerm() &&
(settings?.experimental?.useOSC52Copy ||
isSSH() ||
isWSL() ||
isWindowsTerminal());
const safeUtf8Truncate = (buf: Buffer, maxBytes: number): Buffer => {
if (buf.length <= maxBytes) return buf;
@@ -237,12 +243,15 @@ const writeAll = (stream: Writable, data: string): Promise<void> =>
});
// Copies a string snippet to the clipboard with robust OSC-52 support.
export const copyToClipboard = async (text: string): Promise<void> => {
export const copyToClipboard = async (
text: string,
settings?: Settings,
): Promise<void> => {
if (!text) return;
const tty = await pickTty();
if (shouldUseOsc52(tty)) {
if (shouldUseOsc52(tty, settings)) {
const osc = buildOsc52(text);
const payload = inTmux()
? wrapForTmux(osc)

View File

@@ -1587,8 +1587,15 @@
},
"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`",
"description": "Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).",
"markdownDescription": "Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"useOSC52Copy": {
"title": "Use OSC 52 Copy",
"description": "Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).",
"markdownDescription": "Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},