diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 2690ae450f7..69d5d5f715b 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -4167,17 +4167,15 @@ function installPreviewShortcut(window) { // survives reloads/restarts) rather than a main-process JSON file. The main // process owns setZoomLevel, so we mirror each change into localStorage and // read it back on did-finish-load to re-apply after reloads or crash recovery. -const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' - -function clampZoomLevel(value) { - if (!Number.isFinite(value)) return 0 - return Math.min(Math.max(value, -9), 9) -} +const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs') function setAndPersistZoomLevel(window, zoomLevel) { if (!window || window.isDestroyed()) return const next = clampZoomLevel(zoomLevel) window.webContents.setZoomLevel(next) + // Keep any open settings UI in sync, including changes made via the + // keyboard shortcuts or the View menu. + window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) }) window.webContents .executeJavaScript( `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}` @@ -6158,6 +6156,21 @@ ipcMain.handle('hermes:window:openNewSession', async () => { return { ok: true } }) +// --- Text size (zoom) ------------------------------------------------------- +// The settings UI drives the same clamped zoom scale as the Ctrl/Cmd +// shortcuts and the View menu. Reads and writes target the asking window. +ipcMain.handle('hermes:zoom:get', event => { + const window = BrowserWindow.fromWebContents(event.sender) + const level = window && !window.isDestroyed() ? window.webContents.getZoomLevel() : 0 + + return { level, percent: zoomLevelToPercent(level) } +}) +ipcMain.on('hermes:zoom:set-percent', (event, percent) => { + const window = BrowserWindow.fromWebContents(event.sender) + if (!window || window.isDestroyed()) return + setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent))) +}) + // --- Pet overlay (pop-out mascot) ----------------------------------------- // `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space // bounds (screen=false): convert to screen space by adding the main window's diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index aa8bcc16128..0a9c4fd7921 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -78,6 +78,18 @@ contextBridge.exposeInMainWorld('hermesDesktop', { setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir), pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick') }, + zoom: { + // Current zoom of this window, as { level, percent }. + get: () => ipcRenderer.invoke('hermes:zoom:get'), + setPercent: percent => ipcRenderer.send('hermes:zoom:set-percent', percent), + // Fires on every zoom change, including the Ctrl/Cmd +/-/0 shortcuts, + // so the settings UI can stay in sync with the keyboard. + onChanged: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:zoom:changed', listener) + return () => ipcRenderer.removeListener('hermes:zoom:changed', listener) + } + }, revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'), getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'), readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath), diff --git a/apps/desktop/electron/zoom.cjs b/apps/desktop/electron/zoom.cjs new file mode 100644 index 00000000000..41477f41b42 --- /dev/null +++ b/apps/desktop/electron/zoom.cjs @@ -0,0 +1,34 @@ +/** + * Pure helpers for window zoom. The main process owns webContents.setZoomLevel, + * so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel + * through this one clamped scale. Percent is the user-facing unit (100 = the + * default size); Chromium's internal unit is the zoom level, where + * factor = 1.2 ^ level. + */ + +const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' + +const ZOOM_FACTOR_BASE = 1.2 +const MIN_ZOOM_LEVEL = -9 +const MAX_ZOOM_LEVEL = 9 + +function clampZoomLevel(value) { + if (!Number.isFinite(value)) return 0 + return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) +} + +function zoomLevelToPercent(level) { + return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100) +} + +function percentToZoomLevel(percent) { + if (!Number.isFinite(percent) || percent <= 0) return 0 + return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) +} + +module.exports = { + ZOOM_STORAGE_KEY, + clampZoomLevel, + percentToZoomLevel, + zoomLevelToPercent +} diff --git a/apps/desktop/electron/zoom.test.cjs b/apps/desktop/electron/zoom.test.cjs new file mode 100644 index 00000000000..da104a52630 --- /dev/null +++ b/apps/desktop/electron/zoom.test.cjs @@ -0,0 +1,54 @@ +/** + * Unit tests for the pure zoom helpers: clamping garbage input, the + * percent <-> zoom-level conversion the settings UI relies on, and the + * roundtrip stability of the preset percentages. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs') + +test('storage key stays stable so persisted zoom survives upgrades', () => { + assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel') +}) + +test('clampZoomLevel rejects garbage and enforces bounds', () => { + assert.equal(clampZoomLevel(NaN), 0) + assert.equal(clampZoomLevel(Infinity), 0) + assert.equal(clampZoomLevel(undefined), 0) + assert.equal(clampZoomLevel('2'), 0) + assert.equal(clampZoomLevel(0.3), 0.3) + assert.equal(clampZoomLevel(-42), -9) + assert.equal(clampZoomLevel(42), 9) +}) + +test('level 0 is exactly 100 percent', () => { + assert.equal(zoomLevelToPercent(0), 100) + assert.equal(percentToZoomLevel(100), 0) +}) + +test('percentToZoomLevel rejects garbage', () => { + assert.equal(percentToZoomLevel(NaN), 0) + assert.equal(percentToZoomLevel(0), 0) + assert.equal(percentToZoomLevel(-50), 0) + assert.equal(percentToZoomLevel(undefined), 0) +}) + +test('preset percentages roundtrip within rounding', () => { + for (const percent of [90, 100, 110, 125, 150, 175]) { + assert.equal(zoomLevelToPercent(percentToZoomLevel(percent)), percent) + } +}) + +test('conversion is monotonic across the preset range', () => { + const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel) + for (let i = 1; i < levels.length; i++) { + assert.ok(levels[i] > levels[i - 1]) + } +}) + +test('extreme percentages clamp to the level bounds', () => { + assert.equal(percentToZoomLevel(1), -9) + assert.equal(percentToZoomLevel(1_000_000), 9) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f5aac36ba42..c3daba00e2b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs electron/oauth-session-request.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/zoom.test.cjs electron/windows-hermes-resolution.test.cjs electron/oauth-session-request.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", diff --git a/apps/desktop/pr-assets/ui-scale-175.png b/apps/desktop/pr-assets/ui-scale-175.png new file mode 100644 index 00000000000..d4ac0cba84a Binary files /dev/null and b/apps/desktop/pr-assets/ui-scale-175.png differ diff --git a/apps/desktop/pr-assets/ui-scale-default.png b/apps/desktop/pr-assets/ui-scale-default.png new file mode 100644 index 00000000000..ef2688c317e Binary files /dev/null and b/apps/desktop/pr-assets/ui-scale-default.png differ diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index d0494db1467..559978e4156 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -16,6 +16,7 @@ import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedM import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' import { $translucency, setTranslucency } from '@/store/translucency' +import { $zoomPercent, setZoomPercent } from '@/store/zoom' import { getBaseColors, useTheme } from '@/themes/context' import { installVscodeThemeFromMarketplace } from '@/themes/install' import type { DesktopTheme } from '@/themes/types' @@ -62,6 +63,18 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) ) } +// UI scale presets, as zoom percentages. 100 is the browser-default size; +// the ids double as the percent values sent to the main process. A Cmd/Ctrl +// +/- step landing between presets highlights nothing, and the row +// description keeps showing the exact current percent. +const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const + +type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] + +function matchUiScalePreset(percent: number): UiScalePreset | null { + return UI_SCALE_PRESETS.find(preset => Number(preset) === percent) ?? null +} + function useDebounced(value: T, delayMs: number): T { const [debounced, setDebounced] = useState(value) @@ -231,6 +244,7 @@ export function AppearanceSettings() { const { t, isSavingLocale } = useI18n() const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) + const zoomPercent = useStore($zoomPercent) const embedMode = useStore($embedMode) const embedAllowed = useStore($embedAllowed) const translucency = useStore($translucency) @@ -277,6 +291,10 @@ export function AppearanceSettings() { { id: 'off', label: a.embedsOff } ] as const satisfies readonly { id: EmbedMode; label: string }[] + const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` })) + + const matchedScalePreset = matchUiScalePreset(zoomPercent) + return (
@@ -392,6 +410,21 @@ export function AppearanceSettings() { wide /> + { + triggerHaptic('selection') + setZoomPercent(Number(id)) + }} + options={uiScaleOptions} + value={matchedScalePreset ?? ('' as UiScalePreset)} + /> + } + description={a.uiScaleDesc(zoomPercent)} + title={a.uiScaleTitle} + /> + diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 870d3311837..f9c5e34f541 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -89,6 +89,11 @@ declare global { pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }> setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }> } + zoom?: { + get: () => Promise<{ level: number; percent: number }> + setPercent: (percent: number) => void + onChanged: (callback: (payload: { level: number; percent: number }) => void) => () => void + } revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }> getRecentLogs: () => Promise<{ path: string; lines: string[] }> readDir: (path: string) => Promise diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index dcfccaed707..cd046f8f098 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -381,6 +381,9 @@ export const en: Translations = { colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.', toolViewTitle: 'Tool Call Display', toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.', + uiScaleTitle: 'UI Scale', + uiScaleDesc: (percent: number) => + `Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`, translucencyTitle: 'Window Translucency', translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.', embedsTitle: 'Inline Embeds', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 9f4e3075699..22792ea8fed 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -287,6 +287,9 @@ export const ja = defineLocale({ colorModeDesc: '固定モードを選ぶか、Hermes をシステム設定に合わせます。', toolViewTitle: 'ツール呼び出しの表示', toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。', + uiScaleTitle: 'UI スケール', + uiScaleDesc: (percent: number) => + `アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`, translucencyTitle: 'ウィンドウの透過', translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', embedsTitle: 'インライン埋め込み', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 2d14c02d848..b4e41e29ec5 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -302,6 +302,8 @@ export interface Translations { colorModeDesc: string toolViewTitle: string toolViewDesc: string + uiScaleTitle: string + uiScaleDesc: (percent: number) => string translucencyTitle: string translucencyDesc: string embedsTitle: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 0b14885c4ef..bcfed891ddf 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -279,6 +279,8 @@ export const zhHant = defineLocale({ colorModeDesc: '選擇固定模式,或讓 Hermes 跟隨系統設定。', toolViewTitle: '工具呼叫顯示', toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。', + uiScaleTitle: '介面縮放', + uiScaleDesc: (percent: number) => `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, translucencyTitle: '視窗透明', translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', embedsTitle: '內嵌預覽', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 6f6cebef790..5e688a5c320 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -370,6 +370,8 @@ export const zh: Translations = { colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。', toolViewTitle: '工具调用显示', toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。', + uiScaleTitle: '界面缩放', + uiScaleDesc: (percent: number) => `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, translucencyTitle: '窗口透明', translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', embedsTitle: '内嵌预览', diff --git a/apps/desktop/src/store/zoom.ts b/apps/desktop/src/store/zoom.ts new file mode 100644 index 00000000000..804f32c68a7 --- /dev/null +++ b/apps/desktop/src/store/zoom.ts @@ -0,0 +1,22 @@ +/** + * Window text size (zoom). + * + * The main process owns the zoom level and persists it (see electron/zoom.cjs + * for the scale). The renderer only mirrors the current percent for the + * settings UI: preset clicks go to the main process over IPC, and every + * change comes back through onChanged, including ones made with the + * Ctrl/Cmd +/-/0 shortcuts or the View menu, so the UI never drifts. + */ + +import { atom } from 'nanostores' + +export const $zoomPercent = atom(100) + +export function setZoomPercent(percent: number): void { + window.hermesDesktop?.zoom?.setPercent(percent) +} + +if (typeof window !== 'undefined' && window.hermesDesktop?.zoom) { + void window.hermesDesktop.zoom.get().then(({ percent }) => $zoomPercent.set(percent)) + window.hermesDesktop.zoom.onChanged(({ percent }) => $zoomPercent.set(percent)) +}