Skip to content

Commit a92a483

Browse files
authored
add OSC 52 clipboard support for terminal applications (#2725)
## Summary - Implements OSC 52 escape sequence handling for clipboard operations - Allows terminal apps like zellij, tmux, and neovim to copy to system clipboard - Fixes #2140 ## Features - Write-only clipboard access (read queries blocked for security) - 75KB size limit matching common terminal implementations - Base64 whitespace handling per RFC 4648 - Tested with zellij mouse selection
1 parent e61dbcf commit a92a483

2 files changed

Lines changed: 92 additions & 14 deletions

File tree

frontend/app/view/term/term.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) =>
286286
keydownHandler: model.handleTerminalKeydown.bind(model),
287287
useWebGl: !termSettings?.["term:disablewebgl"],
288288
sendDataHandler: model.sendDataToController.bind(model),
289+
nodeModel: model.nodeModel,
289290
}
290291
);
291292
(window as any).term = termWrap;

frontend/app/view/term/termwrap.ts

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,12 @@
11
// Copyright 2025, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import type { BlockNodeModel } from "@/app/block/blocktypes";
45
import { getFileSubject } from "@/app/store/wps";
56
import { sendWSCommand } from "@/app/store/ws";
67
import { RpcApi } from "@/app/store/wshclientapi";
78
import { TabRpcClient } from "@/app/store/wshrpcutil";
8-
import {
9-
WOS,
10-
atoms,
11-
fetchWaveFile,
12-
getApi,
13-
getSettingsKeyAtom,
14-
globalStore,
15-
openLink,
16-
recordTEvent,
17-
} from "@/store/global";
9+
import { WOS, fetchWaveFile, getApi, getSettingsKeyAtom, globalStore, openLink, recordTEvent } from "@/store/global";
1810
import * as services from "@/store/services";
1911
import { PLATFORM, PlatformMacOS } from "@/util/platformutil";
2012
import { base64ToArray, base64ToString, fireAndForget } from "@/util/util";
@@ -35,6 +27,8 @@ const dlog = debug("wave:termwrap");
3527
const TermFileName = "term";
3628
const TermCacheFileName = "cache:term:full";
3729
const MinDataProcessedForCache = 100 * 1024;
30+
const Osc52MaxDecodedSize = 75 * 1024; // max clipboard size for OSC 52 (matches common terminal implementations)
31+
const Osc52MaxRawLength = 128 * 1024; // includes selector + base64 + whitespace (rough check)
3832
export const SupportsImageInput = true;
3933

4034
// detect webgl support
@@ -55,6 +49,7 @@ type TermWrapOptions = {
5549
keydownHandler?: (e: KeyboardEvent) => boolean;
5650
useWebGl?: boolean;
5751
sendDataHandler?: (data: string) => void;
52+
nodeModel?: BlockNodeModel;
5853
};
5954

6055
function handleOscWaveCommand(data: string, blockId: string, loaded: boolean): boolean {
@@ -119,6 +114,83 @@ function handleOscWaveCommand(data: string, blockId: string, loaded: boolean): b
119114
return true;
120115
}
121116

117+
// for xterm OSC handlers, we return true always because we "own" the OSC number.
118+
// even if data is invalid we don't want to propagate to other handlers.
119+
function handleOsc52Command(data: string, blockId: string, loaded: boolean, termWrap: TermWrap): boolean {
120+
if (!loaded) {
121+
return true;
122+
}
123+
const isBlockFocused = termWrap.nodeModel ? globalStore.get(termWrap.nodeModel.isFocused) : false;
124+
if (!document.hasFocus() || !isBlockFocused) {
125+
console.log("OSC 52: rejected, window or block not focused");
126+
return true;
127+
}
128+
if (!data || data.length === 0) {
129+
console.log("OSC 52: empty data received");
130+
return true;
131+
}
132+
if (data.length > Osc52MaxRawLength) {
133+
console.log("OSC 52: raw data too large", data.length);
134+
return true;
135+
}
136+
137+
const semicolonIndex = data.indexOf(";");
138+
if (semicolonIndex === -1) {
139+
console.log("OSC 52: invalid format (no semicolon)", data.substring(0, 50));
140+
return true;
141+
}
142+
143+
const clipboardSelection = data.substring(0, semicolonIndex);
144+
const base64Data = data.substring(semicolonIndex + 1);
145+
146+
// clipboard query ("?") is not supported for security (prevents clipboard theft)
147+
if (base64Data === "?") {
148+
console.log("OSC 52: clipboard query not supported");
149+
return true;
150+
}
151+
152+
if (base64Data.length === 0) {
153+
return true;
154+
}
155+
156+
if (clipboardSelection.length > 10) {
157+
console.log("OSC 52: clipboard selection too long", clipboardSelection);
158+
return true;
159+
}
160+
161+
const estimatedDecodedSize = Math.ceil(base64Data.length * 0.75);
162+
if (estimatedDecodedSize > Osc52MaxDecodedSize) {
163+
console.log("OSC 52: data too large", estimatedDecodedSize, "bytes");
164+
return true;
165+
}
166+
167+
try {
168+
// strip whitespace from base64 data (some terminals chunk with newlines per RFC 4648)
169+
const cleanBase64Data = base64Data.replace(/\s+/g, "");
170+
const decodedText = base64ToString(cleanBase64Data);
171+
172+
// validate actual decoded size (base64 estimate can be off for multi-byte UTF-8)
173+
const actualByteSize = new TextEncoder().encode(decodedText).length;
174+
if (actualByteSize > Osc52MaxDecodedSize) {
175+
console.log("OSC 52: decoded text too large", actualByteSize, "bytes");
176+
return true;
177+
}
178+
179+
fireAndForget(async () => {
180+
try {
181+
await navigator.clipboard.writeText(decodedText);
182+
dlog("OSC 52: copied", decodedText.length, "characters to clipboard");
183+
} catch (err) {
184+
console.error("OSC 52: clipboard write failed:", err);
185+
}
186+
});
187+
} catch (e) {
188+
console.error("OSC 52: base64 decode error:", e);
189+
}
190+
191+
return true;
192+
}
193+
122194
// for xterm handlers, we return true always because we "own" OSC 7.
123195
// even if it is invalid we dont want to propagate to other handlers
124196
function handleOsc7Command(data: string, blockId: string, loaded: boolean): boolean {
@@ -386,6 +458,7 @@ export class TermWrap {
386458
promptMarkers: TermTypes.IMarker[] = [];
387459
shellIntegrationStatusAtom: jotai.PrimitiveAtom<"ready" | "running-command" | null>;
388460
lastCommandAtom: jotai.PrimitiveAtom<string | null>;
461+
nodeModel: BlockNodeModel; // this can be null
389462

390463
// IME composition state tracking
391464
// Prevents duplicate input when switching input methods during composition (e.g., using Capslock)
@@ -412,6 +485,7 @@ export class TermWrap {
412485
this.tabId = tabId;
413486
this.blockId = blockId;
414487
this.sendDataHandler = waveOptions.sendDataHandler;
488+
this.nodeModel = waveOptions.nodeModel;
415489
this.ptyOffset = 0;
416490
this.dataBytesProcessed = 0;
417491
this.hasResized = false;
@@ -457,13 +531,16 @@ export class TermWrap {
457531
loggedWebGL = true;
458532
}
459533
}
460-
// Register OSC 9283 handler
461-
this.terminal.parser.registerOscHandler(9283, (data: string) => {
462-
return handleOscWaveCommand(data, this.blockId, this.loaded);
463-
});
534+
// Register OSC handlers
464535
this.terminal.parser.registerOscHandler(7, (data: string) => {
465536
return handleOsc7Command(data, this.blockId, this.loaded);
466537
});
538+
this.terminal.parser.registerOscHandler(52, (data: string) => {
539+
return handleOsc52Command(data, this.blockId, this.loaded, this);
540+
});
541+
this.terminal.parser.registerOscHandler(9283, (data: string) => {
542+
return handleOscWaveCommand(data, this.blockId, this.loaded);
543+
});
467544
this.terminal.parser.registerOscHandler(16162, (data: string) => {
468545
return handleOsc16162Command(data, this.blockId, this.loaded, this);
469546
});

0 commit comments

Comments
 (0)