diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 58ba252ec9..e29ed30214 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -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 diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 77065ab3c8..c61aab5d18 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -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): diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 40dd6a10f2..1138614235 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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: { diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index de75090401..e8aace1bcc 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -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 () => { diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 07f529869b..c2c6ab13d1 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -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', diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 6e64e292a5..737948ce98 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -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 }); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index f87a4f583a..0d52c83863 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -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 => }); // Copies a string snippet to the clipboard with robust OSC-52 support. -export const copyToClipboard = async (text: string): Promise => { +export const copyToClipboard = async ( + text: string, + settings?: Settings, +): Promise => { if (!text) return; const tty = await pickTty(); - if (shouldUseOsc52(tty)) { + if (shouldUseOsc52(tty, settings)) { const osc = buildOsc52(text); const payload = inTmux() ? wrapForTmux(osc) diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 7847323ea2..abe8e97de0 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -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" },