Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,18 @@ function writeSystemMessage(
terminal.write(`\r\n[terminal] ${message}\r\n`);
}

function copyTerminalProgramText(text: string): void {
void writeTextToClipboard(text, "terminal output").catch((error: unknown) => {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to copy terminal output",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
});
}

export function writeTerminalOutputUpdate(
terminal: Pick<GhosttyTerminalSurface, "resetAndWrite" | "write">,
update: TerminalOutputUpdate,
Expand Down Expand Up @@ -731,6 +743,7 @@ function TerminalSessionViewport({
canActivateLink: (text) => canActivateTerminalLink(text),
validateLink: (text) => validateTerminalLink(text),
onLinkHover: (text) => setLinkPath(text !== null && !isTerminalUrl(text) ? text : null),
onClipboardWrite: copyTerminalProgramText,
// Surface listeners start before WASM loading finishes and the terminal handler exists.
onContextMenu: (event) => {
if (terminalRef.current) void showTerminalContextMenu(event);
Expand Down
115 changes: 115 additions & 0 deletions apps/web/src/terminal/ghostty/osc52.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// libghostty-vt has no clipboard callback, so OSC 52 writes are framed here,
// alongside the bytes Ghostty parses. Reads (`?`) are never answered: replying
// would send this client's clipboard to whatever process asked for it.

// Base64 characters, about 768 KiB of copied text. Longer sequences are dropped
// instead of buffered so a broken stream cannot grow memory without bound.
const MAX_OSC52_PAYLOAD_LENGTH = 1024 * 1024;

const PREFIX = "52;";
// BEL ends the OSC, ESC may start ST, CAN and SUB abort it.
const OSC_CONTROL = /[\x07\x18\x1a\x1b]/g;
// The browser has one clipboard, so the system and both X11 selections map to it.
const SELECTION = /^[cps0-7]*$/;
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });

function decodeClipboardWrite(body: string): string | null {
const separator = body.indexOf(";", PREFIX.length);
if (separator === -1) return null;
const selection = body.slice(PREFIX.length, separator);
if (!SELECTION.test(selection) || (selection !== "" && !/[cps]/.test(selection))) return null;
const encoded = body.slice(separator + 1);
if (encoded === "" || encoded === "?") return null;
try {
const binary = atob(encoded);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
const text = decoder.decode(bytes);
return text.length > 0 ? text : null;
} catch {
return null;
}
}

export class Osc52Scanner {
private state: "ground" | "escape" | "osc" | "oscEscape" = "ground";
// The OSC body so far, or null once it can no longer be a clipboard write.
private body: string | null = null;
private readonly onCopy: (text: string) => void;

constructor(onCopy: (text: string) => void) {
this.onCopy = onCopy;
}

reset(): void {
this.state = "ground";
this.body = null;
}

write(data: string): void {
let index = 0;
while (index < data.length) {
switch (this.state) {
case "ground": {
const escape = data.indexOf("\x1b", index);
if (escape === -1) return;
this.state = "escape";
index = escape + 1;
break;
}
case "escape": {
const char = data[index];
index += 1;
if (char === "]") {
this.state = "osc";
this.body = "";
} else if (char !== "\x1b") {
this.state = "ground";
}
break;
}
case "osc": {
OSC_CONTROL.lastIndex = index;
const control = OSC_CONTROL.exec(data);
const end = control?.index ?? data.length;
this.append(data.slice(index, end));
if (!control) return;
index = end + 1;
if (control[0] === "\x07") this.finish();
else if (control[0] === "\x1b") this.state = "oscEscape";
else this.reset();
break;
}
case "oscEscape": {
if (data[index] === "\\") {
index += 1;
this.finish();
} else {
// Any other escape aborts the OSC and begins a new sequence.
this.state = "escape";
this.body = null;
}
break;
}
}
}
}

private append(text: string): void {
if (this.body === null || text.length === 0) return;
if (this.body.length + text.length > MAX_OSC52_PAYLOAD_LENGTH) {
this.body = null;
return;
}
const checkPrefix = this.body.length < PREFIX.length;
this.body += text;
if (checkPrefix && !PREFIX.startsWith(this.body.slice(0, PREFIX.length))) this.body = null;
}

private finish(): void {
const body = this.body;
this.reset();
const text = body === null ? null : decodeClipboardWrite(body);
if (text !== null) this.onCopy(text);
}
}
51 changes: 51 additions & 0 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ describe("GhosttyTerminalSurface visibility", () => {
}),
};
vi.stubGlobal("document", {
hasFocus: () => true,
createElement: (tag: string) => (tag === "canvas" ? canvas : new TerminalTestElement()),
fonts: Object.assign(new EventTarget(), { load: async () => [], add() {} }),
});
Expand Down Expand Up @@ -393,6 +394,56 @@ describe("GhosttyTerminalSurface visibility", () => {
expect(harness.onData).not.toHaveBeenCalled();
});

it("copies OSC 52 writes only from focused, visible live output", async () => {
const harness = createHarness();
const copy = vi.fn();
const surface = await harness.create({ onClipboardWrite: copy });
const sequence = "\x1b]52;c;aGVsbG8=\x07";

surface.write(sequence);
surface.focus();
surface.resetAndWrite(sequence);
surface.write("\x1b]52;c;aGVs");
surface.resetAndWrite("history");
surface.write("bG8=\x07");
surface.write("\x1b]52;c;?\x07");
surface.setVisible(false);
surface.write(sequence);
expect(copy).not.toHaveBeenCalled();
expect(harness.onData).not.toHaveBeenCalled();

surface.setVisible(true);
surface.focus();
for (const char of sequence) surface.write(char);
surface.write("\x1b]52;;aMOpbGxvIPCfkYs=\x1b\\");
expect(copy.mock.calls).toEqual([["hello"], ["héllo 👋"]]);
using _focus = vi.spyOn(document, "hasFocus").mockReturnValue(false);
surface.write(sequence);
expect(copy).toHaveBeenCalledTimes(2);
});

it("ignores malformed, cancelled and oversized OSC 52 writes and recovers", async () => {
const harness = createHarness();
const copy = vi.fn();
const surface = await harness.create({ onClipboardWrite: copy });
surface.focus();
for (const data of [
"\x1b]0;title\x07",
"\x1b]52;c;?\x07",
"\x1b]52;x;aGVsbG8=\x07",
"\x1b]52;c;!invalid!\x07",
"\x1b]52;c;/w==\x07",
"\x1b]52;c;aGVsbG8=\x18",
"\x1b]52;c;aGVsbG8=\x1a",
"\x1b]52;c;" + "A".repeat(1024 * 1024) + "\x07",
])
surface.write(data);
expect(copy).not.toHaveBeenCalled();
expect(harness.onData).not.toHaveBeenCalled();
surface.write("\x1b]52;c;aGVsbG8=\x07");
expect(copy.mock.calls).toEqual([["hello"]]);
});

function paste(surface: GhosttyTerminalSurface, text: string) {
surface.input.dispatchEvent(
Object.assign(new Event("paste", { cancelable: true }), {
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type GhosttyCellRange,
type GhosttyCellMetrics,
} from "./renderer";
import { Osc52Scanner } from "./osc52";
import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";
import { isMonospaceFamily } from "../../appearanceFonts";
import { observeResize } from "../../lib/observeResize";
Expand Down Expand Up @@ -531,6 +532,7 @@ export interface GhosttyTerminalSurfaceOptions {
readonly onLinkHover?: (text: string | null) => void;
/** The host must prevent the default menu; native Paste cannot reach a canvas terminal. */
readonly onContextMenu?: (event: MouseEvent) => void;
readonly onClipboardWrite?: (text: string) => void;
}

export class GhosttyTerminalSurface {
Expand Down Expand Up @@ -608,6 +610,11 @@ export class GhosttyTerminalSurface {
private selectionMoved = false;
private composing = false;
private focused = false;
private readonly osc52 = new Osc52Scanner((text) => {
if (this.focused && this.visible && document.hasFocus()) {
this.options.onClipboardWrite?.(text);
}
});
private resizeNotified = false;
private canvasConfigured = false;
private canvasDevicePixelRatio = 0;
Expand Down Expand Up @@ -770,6 +777,7 @@ export class GhosttyTerminalSurface {
? undefined
: () => this.cancelSynchronizedRenderTimer(),
);
if (this.options.onClipboardWrite) this.osc52.write(data);
this.synchronizeMouseTrackingState();
this.cursorOn = true;
this.scrollbarDirty = true;
Expand All @@ -780,6 +788,7 @@ export class GhosttyTerminalSurface {
if (this.disposed) return;
this.cancelSynchronizedRenderTimer();
this.lastMouseMotionData = "";
this.osc52.reset();
this.core.resetAndWrite(data);
this.synchronizeMouseTrackingState();
this.cursorOn = true;
Expand Down
Loading