From f85ec455472294a95292786f28f4af2d94899e4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 06:34:53 +0000 Subject: [PATCH 1/4] Initial plan From 107b294bfb5f1e6ce6050b2dbc3dee7669c81fb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 06:41:29 +0000 Subject: [PATCH 2/4] feat: add term:osc52 focus/always config override Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- docs/docs/config.mdx | 2 + frontend/app/view/term/osc-handlers.test.ts | 71 +++++++++++++++++++++ frontend/app/view/term/osc-handlers.ts | 13 +++- frontend/types/gotypes.d.ts | 2 + pkg/waveobj/metaconsts.go | 1 + pkg/waveobj/wtypemeta.go | 1 + pkg/wconfig/defaultconfig/settings.json | 1 + pkg/wconfig/metaconsts.go | 1 + pkg/wconfig/settingsconfig.go | 1 + schema/settings.json | 7 ++ 10 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 frontend/app/view/term/osc-handlers.test.ts diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 6cdd59cdfa..7465261f4f 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -73,6 +73,7 @@ wsh editconfig | term:cursorblink | bool | when enabled, terminal cursor blinks (default false) | | term:bellsound | bool | when enabled, plays the system beep sound when the terminal bell (BEL character) is received (default false) | | term:bellindicator | bool | when enabled, shows a visual indicator in the tab when the terminal bell is received (default false) | +| term:osc52 | string | controls OSC 52 clipboard behavior: `focus` (default, requires focused block) or `always` (allows OSC 52 even when block is not focused) | | term:durable | bool | makes remote terminal sessions durable across network disconnects (defaults to false) | | editor:minimapenabled | bool | set to false to disable editor minimap | | editor:stickyscrollenabled | bool | enables monaco editor's stickyScroll feature (pinning headers of current context, e.g. class names, method names, etc.), defaults to false | @@ -147,6 +148,7 @@ For reference, this is the current default configuration (v0.14.0): "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": false, + "term:osc52": "focus", "term:cursor": "block", "term:cursorblink": false, "term:copyonselect": true, diff --git a/frontend/app/view/term/osc-handlers.test.ts b/frontend/app/view/term/osc-handlers.test.ts new file mode 100644 index 0000000000..c7edc7b5dd --- /dev/null +++ b/frontend/app/view/term/osc-handlers.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockWriteText, mockGet } = vi.hoisted(() => ({ + mockWriteText: vi.fn(), + mockGet: vi.fn(), +})); + +vi.mock("@/app/store/wshclientapi", () => ({ RpcApi: {} })); +vi.mock("@/app/store/wshrpcutil", () => ({ TabRpcClient: {} })); +vi.mock("@/store/services", () => ({})); +vi.mock("@/store/global", () => ({ + getApi: vi.fn(), + getBlockMetaKeyAtom: vi.fn(), + getBlockTermDurableAtom: vi.fn(), + getOverrideConfigAtom: vi.fn((_blockId: string, key: string) => ({ key })), + globalStore: { get: mockGet }, + recordTEvent: vi.fn(), + WOS: {}, +})); +vi.mock("@/util/util", () => ({ + base64ToString: (data: string) => Buffer.from(data, "base64").toString("utf8"), + fireAndForget: (fn: () => Promise) => { + void fn(); + }, + isSshConnName: vi.fn(), + isWslConnName: vi.fn(), +})); + +import { handleOsc52Command } from "./osc-handlers"; + +describe("handleOsc52Command", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockWriteText.mockResolvedValue(undefined); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { clipboard: { writeText: mockWriteText } }, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { hasFocus: () => true }, + }); + }); + + it("rejects unfocused block when term:osc52 is focus", () => { + mockGet.mockImplementation((atom: { key?: string } | undefined) => { + if (atom?.key === "term:osc52") { + return "focus"; + } + return false; + }); + + handleOsc52Command("c;SGVsbG8=", "block-1", true, { nodeModel: { isFocused: {} } } as any); + + expect(mockWriteText).not.toHaveBeenCalled(); + }); + + it("allows unfocused block when term:osc52 is always", async () => { + mockGet.mockImplementation((atom: { key?: string } | undefined) => { + if (atom?.key === "term:osc52") { + return "always"; + } + return false; + }); + + handleOsc52Command("c;SGVsbG8=", "block-1", true, { nodeModel: { isFocused: {} } } as any); + await Promise.resolve(); + + expect(mockWriteText).toHaveBeenCalledWith("Hello"); + }); +}); diff --git a/frontend/app/view/term/osc-handlers.ts b/frontend/app/view/term/osc-handlers.ts index dd8021ae1d..fde76095b5 100644 --- a/frontend/app/view/term/osc-handlers.ts +++ b/frontend/app/view/term/osc-handlers.ts @@ -3,7 +3,15 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { getApi, getBlockMetaKeyAtom, getBlockTermDurableAtom, globalStore, recordTEvent, WOS } from "@/store/global"; +import { + getApi, + getBlockMetaKeyAtom, + getBlockTermDurableAtom, + getOverrideConfigAtom, + globalStore, + recordTEvent, + WOS, +} from "@/store/global"; import * as services from "@/store/services"; import { base64ToString, fireAndForget, isSshConnName, isWslConnName } from "@/util/util"; import debug from "debug"; @@ -114,8 +122,9 @@ export function handleOsc52Command(data: string, blockId: string, loaded: boolea if (!loaded) { return true; } + const osc52Mode = globalStore.get(getOverrideConfigAtom(blockId, "term:osc52")) ?? "focus"; const isBlockFocused = termWrap.nodeModel ? globalStore.get(termWrap.nodeModel.isFocused) : false; - if (!document.hasFocus() || !isBlockFocused) { + if (!document.hasFocus() || (osc52Mode !== "always" && !isBlockFocused)) { console.log("OSC 52: rejected, window or block not focused"); return true; } diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 2d155a919c..6070f46bef 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1119,6 +1119,7 @@ declare global { "term:conndebug"?: string; "term:bellsound"?: boolean; "term:bellindicator"?: boolean; + "term:osc52"?: string; "term:durable"?: boolean; "web:zoom"?: number; "web:hidenav"?: boolean; @@ -1313,6 +1314,7 @@ declare global { "term:cursorblink"?: boolean; "term:bellsound"?: boolean; "term:bellindicator"?: boolean; + "term:osc52"?: string; "term:durable"?: boolean; "editor:minimapenabled"?: boolean; "editor:stickyscrollenabled"?: boolean; diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 8399340cfe..873732de9d 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -121,6 +121,7 @@ const ( MetaKey_TermConnDebug = "term:conndebug" MetaKey_TermBellSound = "term:bellsound" MetaKey_TermBellIndicator = "term:bellindicator" + MetaKey_TermOsc52 = "term:osc52" MetaKey_TermDurable = "term:durable" MetaKey_WebZoom = "web:zoom" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index da73892365..8cd5ed1d9b 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -125,6 +125,7 @@ type MetaTSType struct { TermConnDebug string `json:"term:conndebug,omitempty"` // null, info, debug TermBellSound *bool `json:"term:bellsound,omitempty"` TermBellIndicator *bool `json:"term:bellindicator,omitempty"` + TermOsc52 string `json:"term:osc52,omitempty"` TermDurable *bool `json:"term:durable,omitempty"` WebZoom float64 `json:"web:zoom,omitempty"` diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index aead19efbe..5c81bb9291 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -30,6 +30,7 @@ "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": false, + "term:osc52": "focus", "term:cursor": "block", "term:cursorblink": false, "term:copyonselect": true, diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 52dfa4514c..e031a493ea 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -56,6 +56,7 @@ const ( ConfigKey_TermCursorBlink = "term:cursorblink" ConfigKey_TermBellSound = "term:bellsound" ConfigKey_TermBellIndicator = "term:bellindicator" + ConfigKey_TermOsc52 = "term:osc52" ConfigKey_TermDurable = "term:durable" ConfigKey_EditorMinimapEnabled = "editor:minimapenabled" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 387598e899..69c531eb77 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -107,6 +107,7 @@ type SettingsType struct { TermCursorBlink *bool `json:"term:cursorblink,omitempty"` TermBellSound *bool `json:"term:bellsound,omitempty"` TermBellIndicator *bool `json:"term:bellindicator,omitempty"` + TermOsc52 string `json:"term:osc52,omitempty" jsonschema:"enum=focus,enum=always"` TermDurable *bool `json:"term:durable,omitempty"` EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` diff --git a/schema/settings.json b/schema/settings.json index ad4cd83155..d60367bea3 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -151,6 +151,13 @@ "term:bellindicator": { "type": "boolean" }, + "term:osc52": { + "type": "string", + "enum": [ + "focus", + "always" + ] + }, "term:durable": { "type": "boolean" }, From ac855da93ee1506b6c7ae0c24a4a8b6c24f1ae15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:49:35 +0000 Subject: [PATCH 3/4] feat: default osc52 to always and skip focus checks in always mode Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- docs/docs/config.mdx | 4 +-- frontend/app/view/term/osc-handlers.test.ts | 36 +++++++++++++++++++++ frontend/app/view/term/osc-handlers.ts | 12 ++++--- pkg/wconfig/defaultconfig/settings.json | 2 +- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 7465261f4f..cd29f8a7f4 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -73,7 +73,7 @@ wsh editconfig | term:cursorblink | bool | when enabled, terminal cursor blinks (default false) | | term:bellsound | bool | when enabled, plays the system beep sound when the terminal bell (BEL character) is received (default false) | | term:bellindicator | bool | when enabled, shows a visual indicator in the tab when the terminal bell is received (default false) | -| term:osc52 | string | controls OSC 52 clipboard behavior: `focus` (default, requires focused block) or `always` (allows OSC 52 even when block is not focused) | +| term:osc52 | string | controls OSC 52 clipboard behavior: `always` (default, allows OSC 52 at any time) or `focus` (requires focused window and focused block) | | term:durable | bool | makes remote terminal sessions durable across network disconnects (defaults to false) | | editor:minimapenabled | bool | set to false to disable editor minimap | | editor:stickyscrollenabled | bool | enables monaco editor's stickyScroll feature (pinning headers of current context, e.g. class names, method names, etc.), defaults to false | @@ -148,7 +148,7 @@ For reference, this is the current default configuration (v0.14.0): "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": false, - "term:osc52": "focus", + "term:osc52": "always", "term:cursor": "block", "term:cursorblink": false, "term:copyonselect": true, diff --git a/frontend/app/view/term/osc-handlers.test.ts b/frontend/app/view/term/osc-handlers.test.ts index c7edc7b5dd..42101a12c8 100644 --- a/frontend/app/view/term/osc-handlers.test.ts +++ b/frontend/app/view/term/osc-handlers.test.ts @@ -68,4 +68,40 @@ describe("handleOsc52Command", () => { expect(mockWriteText).toHaveBeenCalledWith("Hello"); }); + + it("allows write when term:osc52 is always and window is unfocused", async () => { + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { hasFocus: () => false }, + }); + mockGet.mockImplementation((atom: { key?: string } | undefined) => { + if (atom?.key === "term:osc52") { + return "always"; + } + return false; + }); + + handleOsc52Command("c;SGVsbG8=", "block-1", true, { nodeModel: { isFocused: {} } } as any); + await Promise.resolve(); + + expect(mockWriteText).toHaveBeenCalledWith("Hello"); + }); + + it("defaults term:osc52 to always when unset", async () => { + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { hasFocus: () => false }, + }); + mockGet.mockImplementation((atom: { key?: string } | undefined) => { + if (atom?.key === "term:osc52") { + return undefined; + } + return false; + }); + + handleOsc52Command("c;SGVsbG8=", "block-1", true, { nodeModel: { isFocused: {} } } as any); + await Promise.resolve(); + + expect(mockWriteText).toHaveBeenCalledWith("Hello"); + }); }); diff --git a/frontend/app/view/term/osc-handlers.ts b/frontend/app/view/term/osc-handlers.ts index fde76095b5..25fdf0e89b 100644 --- a/frontend/app/view/term/osc-handlers.ts +++ b/frontend/app/view/term/osc-handlers.ts @@ -122,11 +122,13 @@ export function handleOsc52Command(data: string, blockId: string, loaded: boolea if (!loaded) { return true; } - const osc52Mode = globalStore.get(getOverrideConfigAtom(blockId, "term:osc52")) ?? "focus"; - const isBlockFocused = termWrap.nodeModel ? globalStore.get(termWrap.nodeModel.isFocused) : false; - if (!document.hasFocus() || (osc52Mode !== "always" && !isBlockFocused)) { - console.log("OSC 52: rejected, window or block not focused"); - return true; + const osc52Mode = globalStore.get(getOverrideConfigAtom(blockId, "term:osc52")) ?? "always"; + if (osc52Mode === "focus") { + const isBlockFocused = termWrap.nodeModel ? globalStore.get(termWrap.nodeModel.isFocused) : false; + if (!document.hasFocus() || !isBlockFocused) { + console.log("OSC 52: rejected, window or block not focused"); + return true; + } } if (!data || data.length === 0) { console.log("OSC 52: empty data received"); diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index 5c81bb9291..2de1974716 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -30,7 +30,7 @@ "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": false, - "term:osc52": "focus", + "term:osc52": "always", "term:cursor": "block", "term:cursorblink": false, "term:copyonselect": true, From e70a9e9a23b793e47a9aa85665706f134108d32a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:22:17 +0000 Subject: [PATCH 4/4] test: remove osc52 vitest coverage Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/app/view/term/osc-handlers.test.ts | 107 -------------------- 1 file changed, 107 deletions(-) delete mode 100644 frontend/app/view/term/osc-handlers.test.ts diff --git a/frontend/app/view/term/osc-handlers.test.ts b/frontend/app/view/term/osc-handlers.test.ts deleted file mode 100644 index 42101a12c8..0000000000 --- a/frontend/app/view/term/osc-handlers.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { mockWriteText, mockGet } = vi.hoisted(() => ({ - mockWriteText: vi.fn(), - mockGet: vi.fn(), -})); - -vi.mock("@/app/store/wshclientapi", () => ({ RpcApi: {} })); -vi.mock("@/app/store/wshrpcutil", () => ({ TabRpcClient: {} })); -vi.mock("@/store/services", () => ({})); -vi.mock("@/store/global", () => ({ - getApi: vi.fn(), - getBlockMetaKeyAtom: vi.fn(), - getBlockTermDurableAtom: vi.fn(), - getOverrideConfigAtom: vi.fn((_blockId: string, key: string) => ({ key })), - globalStore: { get: mockGet }, - recordTEvent: vi.fn(), - WOS: {}, -})); -vi.mock("@/util/util", () => ({ - base64ToString: (data: string) => Buffer.from(data, "base64").toString("utf8"), - fireAndForget: (fn: () => Promise) => { - void fn(); - }, - isSshConnName: vi.fn(), - isWslConnName: vi.fn(), -})); - -import { handleOsc52Command } from "./osc-handlers"; - -describe("handleOsc52Command", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockWriteText.mockResolvedValue(undefined); - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { clipboard: { writeText: mockWriteText } }, - }); - Object.defineProperty(globalThis, "document", { - configurable: true, - value: { hasFocus: () => true }, - }); - }); - - it("rejects unfocused block when term:osc52 is focus", () => { - mockGet.mockImplementation((atom: { key?: string } | undefined) => { - if (atom?.key === "term:osc52") { - return "focus"; - } - return false; - }); - - handleOsc52Command("c;SGVsbG8=", "block-1", true, { nodeModel: { isFocused: {} } } as any); - - expect(mockWriteText).not.toHaveBeenCalled(); - }); - - it("allows unfocused block when term:osc52 is always", async () => { - mockGet.mockImplementation((atom: { key?: string } | undefined) => { - if (atom?.key === "term:osc52") { - return "always"; - } - return false; - }); - - handleOsc52Command("c;SGVsbG8=", "block-1", true, { nodeModel: { isFocused: {} } } as any); - await Promise.resolve(); - - expect(mockWriteText).toHaveBeenCalledWith("Hello"); - }); - - it("allows write when term:osc52 is always and window is unfocused", async () => { - Object.defineProperty(globalThis, "document", { - configurable: true, - value: { hasFocus: () => false }, - }); - mockGet.mockImplementation((atom: { key?: string } | undefined) => { - if (atom?.key === "term:osc52") { - return "always"; - } - return false; - }); - - handleOsc52Command("c;SGVsbG8=", "block-1", true, { nodeModel: { isFocused: {} } } as any); - await Promise.resolve(); - - expect(mockWriteText).toHaveBeenCalledWith("Hello"); - }); - - it("defaults term:osc52 to always when unset", async () => { - Object.defineProperty(globalThis, "document", { - configurable: true, - value: { hasFocus: () => false }, - }); - mockGet.mockImplementation((atom: { key?: string } | undefined) => { - if (atom?.key === "term:osc52") { - return undefined; - } - return false; - }); - - handleOsc52Command("c;SGVsbG8=", "block-1", true, { nodeModel: { isFocused: {} } } as any); - await Promise.resolve(); - - expect(mockWriteText).toHaveBeenCalledWith("Hello"); - }); -});