diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx
index 535bd72064..f0d1968493 100644
--- a/packages/app/src/components/settings-general.tsx
+++ b/packages/app/src/components/settings-general.tsx
@@ -193,6 +193,12 @@ export const SettingsGeneral: Component = () => {
{ initialValue: null as DisplayBackend | null },
)
+ const [pinchZoom, { mutate: setPinchZoom }] = createResource(
+ () => (desktop() && platform.getPinchZoomEnabled ? true : false),
+ () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
+ { initialValue: false },
+ )
+
onMount(() => {
void theme.loadThemes()
})
@@ -239,6 +245,13 @@ export const SettingsGeneral: Component = () => {
})
}
+ const onPinchZoomChange = (checked: boolean) => {
+ setPinchZoom(checked)
+ const update = platform.setPinchZoomEnabled?.(checked)
+ if (!update) return
+ void update.catch(() => setPinchZoom(!checked))
+ }
+
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
@@ -729,6 +742,48 @@ export const SettingsGeneral: Component = () => {
)
+ const DisplaySection = () => (
+
+
+
{language.t("settings.general.section.display")}
+
+
+
+
+
+
+
+
+
+
+ {language.t("settings.general.row.wayland.title")}
+
+
+
+
+
+
+ }
+ description={language.t("settings.general.row.wayland.description")}
+ >
+
+
+
+
+
+
+
+
+ )
+
console.log(import.meta.env)
return (
-
+
diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx
index b77006b7f1..4ffcedd7c8 100644
--- a/packages/app/src/components/titlebar.tsx
+++ b/packages/app/src/components/titlebar.tsx
@@ -22,6 +22,7 @@ import { iife } from "@opencode-ai/core/util/iife"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
+import { makeEventListener } from "@solid-primitives/event-listener"
type TauriDesktopWindow = {
startDragging?: () => Promise
@@ -298,6 +299,34 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
+ const currentSessionTab = () => {
+ if (!params.dir || !params.id) return
+ const href = makeSessionHref(params.dir, params.id)
+ if (!tabsStore.some((tab) => tab.href === href)) return
+ return href
+ }
+
+ const closeCurrentSessionTab = () => {
+ const href = currentSessionTab()
+ if (!href) return false
+ tabsStoreActions.removeTab(href)
+ return true
+ }
+
+ makeEventListener(
+ document,
+ "keydown",
+ (event) => {
+ if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
+ if (event.key.toLowerCase() !== "w") return
+ if (!closeCurrentSessionTab()) return
+
+ event.preventDefault()
+ event.stopPropagation()
+ },
+ { capture: true },
+ )
+
const tabsEnriched = iife(() => {
const base = mapArray(
() => tabsStore,
@@ -578,15 +607,15 @@ function TabNavItem(props: {
const isActive = () => !!match()
return (
- {props.title}
+ {props.title}
@@ -624,7 +653,7 @@ function ProjectTabAvatar(props: { project?: LocalProject; directory: string })
function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
return (
-
+
+ /** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */
+ getPinchZoomEnabled?(): Promise | boolean
+
+ /** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
+ setPinchZoomEnabled?(enabled: boolean): Promise | void
+
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise | void
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index c6b6152f2a..bc4bf07b1f 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -784,6 +784,8 @@ export const dict = {
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
"settings.general.row.showSessionProgressBar.description":
"Display the animated progress bar at the top of the session when the agent is working",
+ "settings.general.row.pinchZoom.title": "Pinch to zoom",
+ "settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
"settings.general.row.wayland.title": "Use native Wayland",
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
diff --git a/packages/desktop/src/main/constants.ts b/packages/desktop/src/main/constants.ts
index 1e21661c1a..d127794e9c 100644
--- a/packages/desktop/src/main/constants.ts
+++ b/packages/desktop/src/main/constants.ts
@@ -7,4 +7,5 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_ENABLED_KEY = "wslEnabled"
+export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts
index 21bc17294e..a1bdfa3ddf 100644
--- a/packages/desktop/src/main/ipc.ts
+++ b/packages/desktop/src/main/ipc.ts
@@ -14,7 +14,7 @@ import type {
} from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { getStore } from "./store"
-import { setTitlebar, updateTitlebar } from "./windows"
+import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
@@ -202,6 +202,10 @@ export function registerIpcHandlers(deps: Deps) {
if (!win) return
updateTitlebar(win)
})
+ ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled())
+ ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => {
+ setPinchZoomEnabled(enabled)
+ })
ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts
index 052e1ee502..3852970cb2 100644
--- a/packages/desktop/src/main/windows.ts
+++ b/packages/desktop/src/main/windows.ts
@@ -3,7 +3,9 @@ import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } f
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
+import { PINCH_ZOOM_ENABLED_KEY } from "./constants"
import { exportDebugLogs, write as writeLog } from "./logging"
+import { getStore } from "./store"
import { createUnresponsiveSampler } from "./unresponsive"
const root = dirname(fileURLToPath(import.meta.url))
@@ -33,7 +35,10 @@ let relaunchHandler = () => {
app.exit(0)
}
const titlebarThemes = new WeakMap>()
+const pinchZoomEnabled = new WeakMap()
const titlebarHeight = 40
+const maxZoomLevel = 10
+const minZoomLevel = 0.2
export function setRelaunchHandler(handler: () => void) {
relaunchHandler = handler
@@ -79,6 +84,20 @@ export function updateTitlebar(win: BrowserWindow) {
win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor()))
}
+export function setPinchZoomEnabled(enabled: boolean) {
+ getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
+ for (const win of BrowserWindow.getAllWindows()) {
+ pinchZoomEnabled.set(win, enabled)
+ win.webContents.send("pinch-zoom-enabled-changed", enabled)
+ if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
+ updateZoom(win)
+ }
+}
+
+export function getPinchZoomEnabled() {
+ return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true
+}
+
export function setDockIcon() {
if (process.platform !== "darwin") return
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
@@ -392,13 +411,31 @@ function isRendererUrl(value?: string, html = false) {
}
function wireZoom(win: BrowserWindow) {
+ pinchZoomEnabled.set(win, getPinchZoomEnabled())
win.webContents.setZoomFactor(1)
- win.webContents.on("zoom-changed", () => {
- win.webContents.setZoomFactor(1)
- updateTitlebar(win)
+ win.webContents.on("zoom-changed", (event, zoomDirection) => {
+ event.preventDefault()
+ if (pinchZoomEnabled.get(win)) {
+ win.webContents.setZoomFactor(
+ clampZoom(win.webContents.getZoomFactor() + (zoomDirection === "in" ? 0.2 : -0.2)),
+ )
+ updateZoom(win)
+ return
+ }
+ if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
+ updateZoom(win)
})
}
+function clampZoom(value: number) {
+ return Math.min(Math.max(value, minZoomLevel), maxZoomLevel)
+}
+
+function updateZoom(win: BrowserWindow) {
+ updateTitlebar(win)
+ win.webContents.send("zoom-factor-changed", win.webContents.getZoomFactor())
+}
+
function upsertKeyValue(obj: Record, keyToChange: string, value: any) {
const keyToChangeLower = keyToChange.toLowerCase()
for (const key of Object.keys(obj)) {
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index ac69632037..f6d83a270f 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/packages/desktop/src/preload/index.ts
@@ -60,6 +60,18 @@ const api: ElectronAPI = {
relaunch: () => ipcRenderer.send("relaunch"),
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
+ getPinchZoomEnabled: () => ipcRenderer.invoke("get-pinch-zoom-enabled"),
+ setPinchZoomEnabled: (enabled) => ipcRenderer.invoke("set-pinch-zoom-enabled", enabled),
+ onPinchZoomEnabledChanged: (cb) => {
+ const handler = (_: unknown, enabled: boolean) => cb(enabled)
+ ipcRenderer.on("pinch-zoom-enabled-changed", handler)
+ return () => ipcRenderer.removeListener("pinch-zoom-enabled-changed", handler)
+ },
+ onZoomFactorChanged: (cb) => {
+ const handler = (_: unknown, factor: number) => cb(factor)
+ ipcRenderer.on("zoom-factor-changed", handler)
+ return () => ipcRenderer.removeListener("zoom-factor-changed", handler)
+ },
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts
index 055f8589b3..081659d2b6 100644
--- a/packages/desktop/src/preload/types.ts
+++ b/packages/desktop/src/preload/types.ts
@@ -79,6 +79,10 @@ export type ElectronAPI = {
relaunch: () => void
getZoomFactor: () => Promise
setZoomFactor: (factor: number) => Promise
+ getPinchZoomEnabled: () => Promise
+ setPinchZoomEnabled: (enabled: boolean) => Promise
+ onPinchZoomEnabledChanged: (cb: (enabled: boolean) => void) => () => void
+ onZoomFactorChanged: (cb: (factor: number) => void) => () => void
setTitlebar: (theme: TitlebarTheme) => Promise
runDesktopMenuAction: (action: DesktopMenuAction) => Promise
loadingWindowComplete: () => void
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index e0b0ad2bbf..3ccd34596e 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 { resetZoom, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
+import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
import "./styles.css"
import { useTheme } from "@opencode-ai/ui/theme"
@@ -274,6 +274,10 @@ const createPlatform = (): Platform => {
webviewZoom,
+ getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(),
+
+ setPinchZoomEnabled,
+
runDesktopMenuAction,
checkAppExists: async (appName: string) => {
diff --git a/packages/desktop/src/renderer/webview-zoom.ts b/packages/desktop/src/renderer/webview-zoom.ts
index 843be46378..7993bf5ee2 100644
--- a/packages/desktop/src/renderer/webview-zoom.ts
+++ b/packages/desktop/src/renderer/webview-zoom.ts
@@ -13,9 +13,21 @@ const OS_NAME = (() => {
const [webviewZoom, setWebviewZoom] = createSignal(1)
let requestedZoom = 1
+let pinchZoomEnabled = false
+let wheelPinch = undefined as
+ | {
+ active: boolean
+ startZoom: number
+ totalDelta: number
+ timeout: ReturnType | undefined
+ }
+ | undefined
const MAX_ZOOM_LEVEL = 10
const MIN_ZOOM_LEVEL = 0.2
+const WHEEL_PINCH_THRESHOLD = 20
+const WHEEL_PINCH_STEP = 0.2
+const WHEEL_PINCH_END_DELAY = 160
const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
@@ -33,10 +45,77 @@ const applyZoom = (next: number) => {
})
}
+window.api.onZoomFactorChanged((factor) => {
+ requestedZoom = clamp(factor)
+ setWebviewZoom(requestedZoom)
+})
+
+void window.api.getPinchZoomEnabled().then((enabled) => {
+ pinchZoomEnabled = enabled
+})
+
+window.api.onPinchZoomEnabledChanged((enabled) => {
+ pinchZoomEnabled = enabled
+ resetWheelPinch()
+})
+
+const setPinchZoomEnabled = (enabled: boolean) => {
+ pinchZoomEnabled = enabled
+ resetWheelPinch()
+ return window.api.setPinchZoomEnabled(enabled)
+}
+
const resetZoom = () => applyZoom(1)
const zoomIn = () => applyZoom(clamp(requestedZoom + 0.2))
const zoomOut = () => applyZoom(clamp(requestedZoom - 0.2))
+const resetWheelPinch = () => {
+ clearTimeout(wheelPinch?.timeout)
+ wheelPinch = undefined
+}
+
+const normalizeWheelDelta = (event: WheelEvent) => {
+ if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * 16
+ if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return event.deltaY * window.innerHeight
+ return event.deltaY
+}
+
+const updateWheelPinch = (event: WheelEvent) => {
+ wheelPinch ??= {
+ active: false,
+ startZoom: requestedZoom,
+ totalDelta: 0,
+ timeout: undefined,
+ }
+
+ clearTimeout(wheelPinch.timeout)
+ wheelPinch.timeout = setTimeout(resetWheelPinch, WHEEL_PINCH_END_DELAY)
+ wheelPinch.totalDelta += normalizeWheelDelta(event)
+
+ if (!wheelPinch.active && Math.abs(wheelPinch.totalDelta) < WHEEL_PINCH_THRESHOLD) return
+ if (!wheelPinch.active) {
+ wheelPinch.active = true
+ wheelPinch.startZoom = requestedZoom
+ wheelPinch.totalDelta = 0
+ return
+ }
+
+ wheelPinch.active = true
+ applyZoom(clamp(wheelPinch.startZoom - (wheelPinch.totalDelta / WHEEL_PINCH_THRESHOLD) * WHEEL_PINCH_STEP))
+}
+
+window.addEventListener(
+ "wheel",
+ (event) => {
+ if (!pinchZoomEnabled) return
+ if (!event.ctrlKey) return
+
+ event.preventDefault()
+ updateWheelPinch(event)
+ },
+ { passive: false },
+)
+
window.addEventListener("keydown", (event) => {
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
@@ -56,4 +135,4 @@ window.addEventListener("keydown", (event) => {
}
})
-export { webviewZoom, resetZoom, zoomIn, zoomOut }
+export { webviewZoom, resetZoom, setPinchZoomEnabled, zoomIn, zoomOut }