Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions docs/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ wsh editconfig
| term:cursorblink <VersionBadge version="v0.14" /> | bool | when enabled, terminal cursor blinks (default false) |
| term:bellsound <VersionBadge version="v0.14" /> | bool | when enabled, plays the system beep sound when the terminal bell (BEL character) is received (default false) |
| term:bellindicator <VersionBadge version="v0.14" /> | bool | when enabled, shows a visual indicator in the tab when the terminal bell is received (default false) |
| term:osc52 <VersionBadge version="v0.14" /> | string | controls OSC 52 clipboard behavior: `always` (default, allows OSC 52 at any time) or `focus` (requires focused window and focused block) |
| term:durable <VersionBadge version="v0.14" /> | 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 |
Expand Down Expand Up @@ -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": "always",
"term:cursor": "block",
"term:cursorblink": false,
"term:copyonselect": true,
Expand Down
107 changes: 107 additions & 0 deletions frontend/app/view/term/osc-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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>) => {
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Test mock may not accurately represent the actual atom behavior

The isFocused field is being set to an empty object {}, but in the actual implementation (line 127 of osc-handlers.ts), globalStore.get(termWrap.nodeModel.isFocused) expects an atom. The test currently passes because the mock's default return value is false, not because it's properly testing an unfocused atom.

Consider creating a proper mock atom that explicitly returns false when accessed via globalStore.get() to make the test more robust and clear about what it's testing.


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");
});
});
21 changes: 16 additions & 5 deletions frontend/app/view/term/osc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -114,10 +122,13 @@ export function handleOsc52Command(data: string, blockId: string, loaded: boolea
if (!loaded) {
return true;
}
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;
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");
Expand Down
2 changes: 2 additions & 0 deletions frontend/types/gotypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions pkg/waveobj/metaconsts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions pkg/waveobj/wtypemeta.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
1 change: 1 addition & 0 deletions pkg/wconfig/defaultconfig/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"telemetry:enabled": true,
"term:bellsound": false,
"term:bellindicator": false,
"term:osc52": "always",
"term:cursor": "block",
"term:cursorblink": false,
"term:copyonselect": true,
Expand Down
1 change: 1 addition & 0 deletions pkg/wconfig/metaconsts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions pkg/wconfig/settingsconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
7 changes: 7 additions & 0 deletions schema/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@
"term:bellindicator": {
"type": "boolean"
},
"term:osc52": {
"type": "string",
"enum": [
"focus",
"always"
]
},
"term:durable": {
"type": "boolean"
},
Expand Down
Loading