diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx
index 867522f22..2416241ad 100644
--- a/packages/app/src/components/terminal-emulator.tsx
+++ b/packages/app/src/components/terminal-emulator.tsx
@@ -565,6 +565,18 @@ export default function TerminalEmulator({
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
+ const handleContextMenu = () => {
+ const showContextMenu = window.paseoDesktop?.menu?.showContextMenu;
+ if (typeof showContextMenu !== "function") {
+ return;
+ }
+
+ const hasSelection = Boolean(window.getSelection()?.toString());
+ void showContextMenu({
+ kind: "terminal",
+ hasSelection,
+ });
+ };
return (
{
runtimeRef.current?.focus();
}}
+ onContextMenu={(event) => {
+ event.preventDefault();
+ handleContextMenu();
+ }}
>
Promise;
}
+export interface DesktopMenuBridge {
+ showContextMenu?: (input?: {
+ kind?: "terminal";
+ hasSelection?: boolean;
+ }) => Promise;
+}
+
export interface DesktopWindowBridge {
label?: string;
startMove?: (screenX: number, screenY: number) => void;
@@ -73,6 +80,7 @@ export interface DesktopHostBridge {
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
+ menu?: DesktopMenuBridge;
}
declare global {
diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
index bf766c052..e2a4b06f0 100644
--- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
+++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
@@ -8,6 +8,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
+import { openExternalUrl } from "@/utils/open-external-url";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
@@ -168,7 +169,12 @@ export class TerminalEmulatorRuntime {
let webglAddon: WebglAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
- terminal.loadAddon(new WebLinksAddon());
+ terminal.loadAddon(
+ new WebLinksAddon((event, uri) => {
+ event.preventDefault();
+ void openExternalUrl(uri);
+ }),
+ );
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
terminal.loadAddon(new ClipboardAddon());
try {
diff --git a/packages/desktop/src/features/menu.ts b/packages/desktop/src/features/menu.ts
index 6bb67006d..0ccb119a9 100644
--- a/packages/desktop/src/features/menu.ts
+++ b/packages/desktop/src/features/menu.ts
@@ -1,4 +1,9 @@
-import { app, Menu, BrowserWindow } from "electron";
+import { app, Menu, BrowserWindow, ipcMain } from "electron";
+
+type ShowContextMenuInput = {
+ kind?: "terminal";
+ hasSelection?: boolean;
+};
function withBrowserWindow(
callback: (win: BrowserWindow) => void,
@@ -89,4 +94,36 @@ export function setupApplicationMenu(): void {
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
+
+ ipcMain.handle("paseo:menu:showContextMenu", (event, input?: ShowContextMenuInput) => {
+ const win = BrowserWindow.fromWebContents(event.sender);
+ if (!win) {
+ return;
+ }
+
+ if (input?.kind !== "terminal") {
+ return;
+ }
+
+ const menu = Menu.buildFromTemplate([
+ {
+ label: "Copy",
+ role: "copy",
+ enabled: input.hasSelection === true,
+ },
+ {
+ label: "Paste",
+ role: "paste",
+ },
+ {
+ type: "separator",
+ },
+ {
+ label: "Select All",
+ role: "selectAll",
+ },
+ ]);
+
+ menu.popup({ window: win });
+ });
}
diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts
index e79096815..54b429362 100644
--- a/packages/desktop/src/preload.ts
+++ b/packages/desktop/src/preload.ts
@@ -51,4 +51,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
opener: {
openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url),
},
+ menu: {
+ showContextMenu: (input?: Record) =>
+ ipcRenderer.invoke("paseo:menu:showContextMenu", input),
+ },
});