diff --git a/packages/app/src/components/windows-app-menu.tsx b/packages/app/src/components/windows-app-menu.tsx
new file mode 100644
index 0000000000..27754074ff
--- /dev/null
+++ b/packages/app/src/components/windows-app-menu.tsx
@@ -0,0 +1,106 @@
+import { Show, type JSX } from "solid-js"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+
+import { useCommand } from "@/context/command"
+import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
+import { usePlatform } from "@/context/platform"
+
+export function WindowsAppMenu(props: { command: ReturnType; platform: ReturnType }) {
+ let lastFocused: HTMLElement | undefined
+
+ const rememberFocus = () => {
+ const active = document.activeElement
+ lastFocused = active instanceof HTMLElement ? active : undefined
+ }
+ const commandDisabled = (id: string) => {
+ const option = props.command.options.find((option) => option.id === id)
+ if (!option) return true
+ return option.disabled ?? false
+ }
+ const runCommand = (id: string) => {
+ if (commandDisabled(id)) return
+ props.command.trigger(id)
+ }
+ const runAction = (action: DesktopMenuAction) => {
+ if (action.startsWith("edit.") && lastFocused?.isConnected) lastFocused.focus({ preventScroll: true })
+ void props.platform.runDesktopMenuAction?.(action)
+ }
+ const runEntry = (entry: DesktopMenuEntry) => {
+ if (entry.type === "separator") return
+ if (entry.command) {
+ runCommand(entry.command)
+ return
+ }
+ if (entry.action) {
+ runAction(entry.action)
+ return
+ }
+ if (entry.href) props.platform.openLink(entry.href)
+ }
+
+ return (
+
+
+
+
+
+
+ )
+}
+
+function DesktopMenuSubmenu(props: { label: string; children: JSX.Element }) {
+ return (
+
+
+ {props.label}
+
+
+
+
+
+
+
+
+ )
+}
+
+function DesktopMenuItem(props: { label: string; keybind?: string; disabled?: boolean; onSelect: () => void }) {
+ return (
+
+ {props.label}
+
+ {props.keybind}
+
+
+ )
+}
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index fd89bf51ba..1ded3a7f1a 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -1,6 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
+import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null
@@ -82,6 +83,9 @@ export type Platform = {
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor
+ /** Run a desktop-only menu action from the app chrome */
+ runDesktopMenuAction?(action: DesktopMenuAction): Promise | void
+
/** Check if an editor app exists (desktop only) */
checkAppExists?(appName: string): Promise
diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts
new file mode 100644
index 0000000000..076045e385
--- /dev/null
+++ b/packages/app/src/desktop-menu.ts
@@ -0,0 +1,213 @@
+export type DesktopMenuPlatform = "macos" | "windows"
+
+export type DesktopMenuAction =
+ | "app.checkForUpdates"
+ | "app.relaunch"
+ | "edit.undo"
+ | "edit.redo"
+ | "edit.cut"
+ | "edit.copy"
+ | "edit.paste"
+ | "edit.delete"
+ | "edit.selectAll"
+ | "view.reload"
+ | "view.toggleDevTools"
+ | "view.resetZoom"
+ | "view.zoomIn"
+ | "view.zoomOut"
+ | "view.toggleFullscreen"
+ | "window.new"
+ | "window.close"
+ | "window.minimize"
+ | "window.toggleMaximize"
+
+export type DesktopMenuRole =
+ | "about"
+ | "close"
+ | "copy"
+ | "cut"
+ | "hide"
+ | "hideOthers"
+ | "paste"
+ | "quit"
+ | "redo"
+ | "reload"
+ | "resetZoom"
+ | "selectAll"
+ | "toggleDevTools"
+ | "togglefullscreen"
+ | "undo"
+ | "unhide"
+ | "windowMenu"
+ | "zoomIn"
+ | "zoomOut"
+
+export type DesktopMenuItem = {
+ type: "item"
+ label?: string
+ command?: string
+ action?: DesktopMenuAction
+ role?: DesktopMenuRole
+ href?: string
+ accelerator?: Partial>
+ enabled?: "updater"
+ platforms?: DesktopMenuPlatform[]
+}
+
+export type DesktopMenuSeparator = {
+ type: "separator"
+ platforms?: DesktopMenuPlatform[]
+}
+
+export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
+
+export type DesktopMenu = {
+ id: string
+ label: string
+ role?: DesktopMenuRole
+ items?: DesktopMenuEntry[]
+ platforms?: DesktopMenuPlatform[]
+}
+
+export const DESKTOP_MENU: DesktopMenu[] = [
+ {
+ id: "app",
+ label: "OpenCode",
+ platforms: ["macos"],
+ items: [
+ { type: "item", role: "about" },
+ { type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" },
+ { type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
+ { type: "item", label: "Reload Webview", action: "view.reload" },
+ { type: "item", label: "Restart", action: "app.relaunch" },
+ { type: "separator" },
+ { type: "item", role: "hide" },
+ { type: "item", role: "hideOthers" },
+ { type: "item", role: "unhide" },
+ { type: "separator" },
+ { type: "item", role: "quit" },
+ ],
+ },
+ {
+ id: "file",
+ label: "File",
+ items: [
+ {
+ type: "item",
+ label: "New Session",
+ command: "session.new",
+ accelerator: { macos: "Shift+Cmd+S" },
+ },
+ { type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } },
+ {
+ type: "item",
+ label: "Settings",
+ command: "settings.open",
+ accelerator: { windows: "Ctrl+," },
+ platforms: ["windows"],
+ },
+ {
+ type: "item",
+ label: "New Window",
+ action: "window.new",
+ accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" },
+ },
+ { type: "separator" },
+ { type: "item", label: "Close Window", action: "window.close", role: "close" },
+ ],
+ },
+ {
+ id: "edit",
+ label: "Edit",
+ items: [
+ { type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
+ { type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
+ { type: "separator" },
+ { type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } },
+ { type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } },
+ { type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } },
+ { type: "item", label: "Delete", action: "edit.delete" },
+ {
+ type: "item",
+ label: "Select All",
+ action: "edit.selectAll",
+ role: "selectAll",
+ accelerator: { windows: "Ctrl+A" },
+ },
+ ],
+ },
+ {
+ id: "view",
+ label: "View",
+ items: [
+ { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } },
+ { type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
+ { type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
+ { type: "separator" },
+ { type: "item", label: "Reload", action: "view.reload", role: "reload" },
+ { type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" },
+ { type: "separator" },
+ {
+ type: "item",
+ label: "Actual Size",
+ action: "view.resetZoom",
+ role: "resetZoom",
+ accelerator: { windows: "Ctrl+0" },
+ },
+ { type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } },
+ { type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } },
+ { type: "separator" },
+ { type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" },
+ ],
+ },
+ {
+ id: "go",
+ label: "Go",
+ items: [
+ { type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
+ { type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
+ { type: "separator" },
+ { type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
+ { type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
+ { type: "separator" },
+ {
+ type: "item",
+ label: "Previous Project",
+ command: "project.previous",
+ accelerator: { macos: "Cmd+Option+Up" },
+ },
+ {
+ type: "item",
+ label: "Next Project",
+ command: "project.next",
+ accelerator: { macos: "Cmd+Option+Down" },
+ },
+ ],
+ },
+ {
+ id: "window",
+ label: "Window",
+ role: "windowMenu",
+ items: [
+ { type: "item", label: "Minimize", action: "window.minimize" },
+ { type: "item", label: "Maximize", action: "window.toggleMaximize" },
+ { type: "separator" },
+ { type: "item", label: "Close Window", action: "window.close" },
+ ],
+ },
+ {
+ id: "help",
+ label: "Help",
+ items: [
+ { type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
+ { type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
+ { type: "separator" },
+ { type: "item", label: "Share Feedback", href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml" },
+ { type: "item", label: "Report a Bug", href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml" },
+ ],
+ },
+]
+
+export function desktopMenuVisible(item: { platforms?: DesktopMenuPlatform[] }, platform: DesktopMenuPlatform) {
+ return !item.platforms || item.platforms.includes(platform)
+}
diff --git a/packages/app/src/index.css b/packages/app/src/index.css
index 8db576dd83..79641cae5d 100644
--- a/packages/app/src/index.css
+++ b/packages/app/src/index.css
@@ -53,6 +53,63 @@
container-name: getting-started;
}
+ [data-component="dropdown-menu-content"].desktop-app-menu,
+ [data-component="dropdown-menu-sub-content"].desktop-app-menu {
+ min-width: 160px;
+ padding: 2px;
+ }
+
+ [data-component="dropdown-menu-content"].desktop-app-menu {
+ width: 160px;
+ }
+
+ [data-component="dropdown-menu-sub-content"].desktop-app-menu {
+ width: max-content;
+ min-width: 240px;
+ max-width: min(320px, calc(100vw - 24px));
+ }
+
+ [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-group-label"] {
+ display: flex;
+ align-items: center;
+ height: 28px;
+ padding: 0 12px;
+ font-size: var(--font-size-x-small);
+ font-weight: var(--font-weight-medium);
+ line-height: 1;
+ color: var(--text-weak);
+ }
+
+ [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
+ [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"],
+ [data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
+ [data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"] {
+ min-height: 28px;
+ padding: 0 12px;
+ gap: 8px;
+ font-weight: var(--font-weight-regular);
+ line-height: 1;
+ }
+
+ [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"],
+ [data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"] {
+ white-space: nowrap;
+ }
+
+ [data-slot="desktop-app-menu-keybind"] {
+ margin-left: auto;
+ color: var(--text-weak);
+ font-size: var(--font-size-x-small);
+ font-weight: var(--font-weight-regular);
+ white-space: nowrap;
+ }
+
+ [data-slot="desktop-app-menu-chevron"] {
+ display: flex;
+ margin-left: auto;
+ color: var(--icon-base);
+ }
+
[data-component="getting-started-actions"] {
display: flex;
flex-direction: column;
diff --git a/packages/desktop/src/main/desktop-menu-actions.ts b/packages/desktop/src/main/desktop-menu-actions.ts
new file mode 100644
index 0000000000..aa15e05e27
--- /dev/null
+++ b/packages/desktop/src/main/desktop-menu-actions.ts
@@ -0,0 +1,84 @@
+import { BrowserWindow } from "electron"
+import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
+import { createMainWindow, updateTitlebar } from "./windows"
+
+export type DesktopMenuActionHandlers = Partial<{
+ checkForUpdates: () => void
+ relaunch: () => void
+}>
+
+export function runDesktopMenuAction(
+ win: BrowserWindow | null,
+ action: DesktopMenuAction,
+ handlers: DesktopMenuActionHandlers = {},
+) {
+ switch (action) {
+ case "app.checkForUpdates":
+ handlers.checkForUpdates?.()
+ return
+ case "app.relaunch":
+ handlers.relaunch?.()
+ return
+ case "window.new":
+ createMainWindow()
+ return
+ case "window.close":
+ win?.close()
+ return
+ case "window.minimize":
+ win?.minimize()
+ return
+ case "window.toggleMaximize":
+ if (win?.isMaximized()) {
+ win.unmaximize()
+ return
+ }
+ win?.maximize()
+ return
+ case "view.reload":
+ win?.reload()
+ return
+ case "view.toggleDevTools":
+ win?.webContents.toggleDevTools()
+ return
+ case "view.resetZoom":
+ setZoom(win, 1)
+ return
+ case "view.zoomIn":
+ setZoom(win, (win?.webContents.getZoomFactor() ?? 1) + 0.2)
+ return
+ case "view.zoomOut":
+ setZoom(win, (win?.webContents.getZoomFactor() ?? 1) - 0.2)
+ return
+ case "view.toggleFullscreen":
+ win?.setFullScreen(!win.isFullScreen())
+ return
+ case "edit.undo":
+ win?.webContents.undo()
+ return
+ case "edit.redo":
+ win?.webContents.redo()
+ return
+ case "edit.cut":
+ win?.webContents.cut()
+ return
+ case "edit.copy":
+ win?.webContents.copy()
+ return
+ case "edit.paste":
+ win?.webContents.paste()
+ return
+ case "edit.delete":
+ win?.webContents.delete()
+ return
+ case "edit.selectAll":
+ win?.webContents.selectAll()
+ return
+ }
+}
+
+function setZoom(win: BrowserWindow | null, value: number) {
+ if (!win) return
+ win.webContents.setZoomFactor(Math.min(Math.max(value, 0.2), 10))
+ updateTitlebar(win)
+}
diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts
index 23f2d7027a..a14c6a383c 100644
--- a/packages/desktop/src/main/index.ts
+++ b/packages/desktop/src/main/index.ts
@@ -345,11 +345,13 @@ const main = Effect.gen(function* () {
mainWindow = createMainWindow()
if (mainWindow) {
createMenu({
- trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
+ trigger: (id) => {
+ const win = BrowserWindow.getFocusedWindow() ?? mainWindow
+ if (win) sendMenuCommand(win, id)
+ },
checkForUpdates: () => {
void checkForUpdates(true, killSidecar)
},
- reload: () => mainWindow?.reload(),
relaunch: () => {
void killSidecar().finally(() => {
app.relaunch()
diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts
index dbcd4239dc..40c54c0856 100644
--- a/packages/desktop/src/main/ipc.ts
+++ b/packages/desktop/src/main/ipc.ts
@@ -1,6 +1,7 @@
import { execFile } from "node:child_process"
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
+import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type {
InitStep,
@@ -10,6 +11,7 @@ import type {
WindowConfig,
WslConfig,
} from "../preload/types"
+import { runDesktopMenuAction } from "./desktop-menu-actions"
import { getStore } from "./store"
import { setTitlebar, updateTitlebar } from "./windows"
@@ -198,6 +200,9 @@ export function registerIpcHandlers(deps: Deps) {
if (!win) return
setTitlebar(win, theme)
})
+ ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => {
+ runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action)
+ })
}
export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {
diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts
index 2d5a900f39..d8746d2ac6 100644
--- a/packages/desktop/src/main/menu.ts
+++ b/packages/desktop/src/main/menu.ts
@@ -1,141 +1,60 @@
-import { Menu, shell } from "electron"
+import { BrowserWindow, Menu, shell } from "electron"
+import type { MenuItemConstructorOptions } from "electron"
+import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuEntry, type DesktopMenuRole } from "@opencode-ai/app/desktop-menu"
import { UPDATER_ENABLED } from "./constants"
-import { createMainWindow } from "./windows"
+import { runDesktopMenuAction } from "./desktop-menu-actions"
type Deps = {
trigger: (id: string) => void
checkForUpdates: () => void
- reload: () => void
relaunch: () => void
}
export function createMenu(deps: Deps) {
if (process.platform !== "darwin") return
- const template: Electron.MenuItemConstructorOptions[] = [
- {
- label: "OpenCode",
- submenu: [
- { role: "about" },
- {
- label: "Check for Updates...",
- enabled: UPDATER_ENABLED,
- click: () => deps.checkForUpdates(),
- },
- {
- label: "Settings",
- accelerator: "Cmd+,",
- click: () => deps.trigger("settings.open"),
- },
- {
- label: "Reload Webview",
- click: () => deps.reload(),
- },
- {
- label: "Restart",
- click: () => deps.relaunch(),
- },
- { type: "separator" },
- { role: "hide" },
- { role: "hideOthers" },
- { role: "unhide" },
- { type: "separator" },
- { role: "quit" },
- ],
- },
- {
- label: "File",
- submenu: [
- { label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") },
- { label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") },
- {
- label: "New Window",
- accelerator: "Cmd+Shift+N",
- click: () => createMainWindow(),
- },
- { type: "separator" },
- { role: "close" },
- ],
- },
- {
- label: "Edit",
- submenu: [
- { role: "undo" },
- { role: "redo" },
- { type: "separator" },
- { role: "cut" },
- { role: "copy" },
- { role: "paste" },
- { role: "selectAll" },
- ],
- },
- {
- label: "View",
- submenu: [
- { label: "Toggle Sidebar", accelerator: "Cmd+B", click: () => deps.trigger("sidebar.toggle") },
- { label: "Toggle Terminal", accelerator: "Ctrl+`", click: () => deps.trigger("terminal.toggle") },
- { label: "Toggle File Tree", click: () => deps.trigger("fileTree.toggle") },
- { type: "separator" },
- { role: "reload" },
- { role: "toggleDevTools" },
- { type: "separator" },
- { role: "resetZoom" },
- { role: "zoomIn" },
- { role: "zoomOut" },
- { type: "separator" },
- { role: "togglefullscreen" },
- ],
- },
- {
- label: "Go",
- submenu: [
- { label: "Back", accelerator: "Cmd+[", click: () => deps.trigger("common.goBack") },
- { label: "Forward", accelerator: "Cmd+]", click: () => deps.trigger("common.goForward") },
- { type: "separator" },
- {
- label: "Previous Session",
- accelerator: "Option+Up",
- click: () => deps.trigger("session.previous"),
- },
- {
- label: "Next Session",
- accelerator: "Option+Down",
- click: () => deps.trigger("session.next"),
- },
- { type: "separator" },
- {
- label: "Previous Project",
- accelerator: "Cmd+Option+Up",
- click: () => deps.trigger("project.previous"),
- },
- {
- label: "Next Project",
- accelerator: "Cmd+Option+Down",
- click: () => deps.trigger("project.next"),
- },
- ],
- },
- { role: "windowMenu" },
- {
- label: "Help",
- submenu: [
- { label: "OpenCode Documentation", click: () => shell.openExternal("https://opencode.ai/docs") },
- { label: "Support Forum", click: () => shell.openExternal("https://discord.com/invite/opencode") },
- { type: "separator" },
- { type: "separator" },
- {
- label: "Share Feedback",
- click: () =>
- shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml"),
- },
- {
- label: "Report a Bug",
- click: () => shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml"),
- },
- ],
- },
- ]
+ const template = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "macos")).map((menu) => {
+ if (menu.role) return { role: nativeRole(menu.role) }
+ return {
+ label: menu.label,
+ submenu: menu.items?.filter((entry) => desktopMenuVisible(entry, "macos")).map((entry) => nativeItem(entry, deps)),
+ }
+ })
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
+
+function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
+ if (entry.type === "separator") return { type: "separator" }
+ if (entry.role) return { role: nativeRole(entry.role) }
+
+ const item: MenuItemConstructorOptions = {
+ label: entry.label,
+ accelerator: entry.accelerator?.macos,
+ enabled: entry.enabled === "updater" ? UPDATER_ENABLED : undefined,
+ }
+
+ if (entry.command) {
+ const command = entry.command
+ item.click = () => deps.trigger(command)
+ }
+ if (entry.action) {
+ const action = entry.action
+ item.click = () =>
+ runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, {
+ checkForUpdates: deps.checkForUpdates,
+ relaunch: deps.relaunch,
+ })
+ }
+ if (entry.href) {
+ const href = entry.href
+ item.click = () => shell.openExternal(href)
+ }
+
+ return item
+}
+
+function nativeRole(role: DesktopMenuRole) {
+ return role as NonNullable
+}
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index 6261419ca5..4adbfb62aa 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/packages/desktop/src/preload/index.ts
@@ -61,6 +61,7 @@ const api: ElectronAPI = {
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
+ runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
checkUpdate: () => ipcRenderer.invoke("check-update"),
diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts
index 6e22954d18..ce931a1d9e 100644
--- a/packages/desktop/src/preload/types.ts
+++ b/packages/desktop/src/preload/types.ts
@@ -1,3 +1,5 @@
+import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
+
export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }
export type ServerReadyData = {
@@ -14,7 +16,6 @@ export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
mode: "light" | "dark"
}
-
export type WindowConfig = {
updaterEnabled: boolean
}
@@ -71,6 +72,7 @@ export type ElectronAPI = {
getZoomFactor: () => Promise
setZoomFactor: (factor: number) => Promise
setTitlebar: (theme: TitlebarTheme) => Promise
+ runDesktopMenuAction: (action: DesktopMenuAction) => Promise
loadingWindowComplete: () => void
runUpdater: (alertOnFail: boolean) => Promise
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index f9114c7550..16cc41842f 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -21,7 +21,7 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
-import { webviewZoom } from "./webview-zoom"
+import { resetZoom, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
import "./styles.css"
import { useTheme } from "@opencode-ai/ui/theme"
@@ -100,6 +100,22 @@ const createPlatform = (): Platform => {
return window.api.wslPath(result, "linux").catch(() => result) as any
}
+ const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
+ switch (action) {
+ case "view.resetZoom":
+ resetZoom()
+ return
+ case "view.zoomIn":
+ zoomIn()
+ return
+ case "view.zoomOut":
+ zoomOut()
+ return
+ }
+
+ return window.api.runDesktopMenuAction(action)
+ }
+
const storage = (() => {
const cache = new Map()
@@ -254,6 +270,8 @@ const createPlatform = (): Platform => {
webviewZoom,
+ runDesktopMenuAction,
+
checkAppExists: async (appName: string) => {
return window.api.checkAppExists(appName)
},
diff --git a/packages/desktop/src/renderer/webview-zoom.ts b/packages/desktop/src/renderer/webview-zoom.ts
index cb4b5a4481..843be46378 100644
--- a/packages/desktop/src/renderer/webview-zoom.ts
+++ b/packages/desktop/src/renderer/webview-zoom.ts
@@ -33,23 +33,27 @@ const applyZoom = (next: number) => {
})
}
+const resetZoom = () => applyZoom(1)
+const zoomIn = () => applyZoom(clamp(requestedZoom + 0.2))
+const zoomOut = () => applyZoom(clamp(requestedZoom - 0.2))
+
window.addEventListener("keydown", (event) => {
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
if (event.key === "-") {
event.preventDefault()
- applyZoom(clamp(requestedZoom - 0.2))
+ zoomOut()
return
}
if (event.key === "=" || event.key === "+") {
event.preventDefault()
- applyZoom(clamp(requestedZoom + 0.2))
+ zoomIn()
return
}
if (event.key === "0") {
event.preventDefault()
- applyZoom(1)
+ resetZoom()
}
})
-export { webviewZoom }
+export { webviewZoom, resetZoom, zoomIn, zoomOut }