Desktop UI zoom, font size, and scaling controls
NousResearch/hermes-agent · cluster 37619 · apps/desktop
Data as of
4 open items. Fix merged in #60457. Check it covers your case.
The short answer
- Known?
- Yes. 24 related items grouped here, 16 issues already closed.
- Fix event
- A fix was merged to main in PR #60457. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 2 issues and 2 PRs.
Next step: read #60457, then check whether your report is one of the 2 still open.
Read #60457Pick up a Lab taskThese issues and pull requests collectively address the lack of zoom, font size, and UI scaling support in the desktop application, including keyboard shortcuts, appearance settings controls, and persistence of zoom state across sessions.
Open issues2
Open PRs2
Fixes on record
-
#60457 feat(desktop): add UI scale setting to appearance settings (salvage #60249)
files:
apps/desktop/electron/main.cjs,apps/desktop/electron/preload.cjs,apps/desktop/electron/zoom.cjs,apps/desktop/electron/zoom.test.cjs,apps/desktop/package.json,apps/desktop/src/app/settings/appearance-settings.tsx,apps/desktop/src/global.d.ts,apps/desktop/src/i18n/en.ts,apps/desktop/src/i18n/ja.ts,apps/desktop/src/i18n/types.ts,apps/desktop/src/i18n/zh-hant.ts,apps/desktop/src/i18n/zh.ts,apps/desktop/src/store/zoom.tsShow the diff
-
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 -
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), -
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 +} -
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) +}) -
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", -
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<T>(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 ( <SettingsContent> <div> @@ -392,6 +410,21 @@ export function AppearanceSettings() { wide /> + <ListRow + action={ + <SegmentedControl + onChange={id => { + triggerHaptic('selection') + setZoomPercent(Number(id)) + }} + options={uiScaleOptions} + value={matchedScalePreset ?? ('' as UiScalePreset)} + /> + } + description={a.uiScaleDesc(zoomPercent)} + title={a.uiScaleTitle} + /> + <ListRow action={ <div className="flex items-center gap-3"> -
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<HermesReadDirResult> -
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', -
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: 'インライン埋め込み', -
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 -
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: '內嵌預覽', -
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: '内嵌预览', -
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<number>(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)) +}
-
3 PRs closed without merging
From the close comments: done elsewhere 1 · merged via another change 1 · other 1
Closed issues · 16
- Closed#37619Windows desktop app: add zoom / UI scaling support
- Closed#37917Desktop (Windows): Ctrl +/- zoom (text size) does nothing — application menu is null on non-mac with no fallback
- Closed#40166[Desktop app]: add font size / zoom control
- Closed#40768[Desktop][Windows] Supplement to #40399: font size, chat text size, and density controls in Appearance
- Closed#41879Feature: Add font size scaling option for Hermes Desktop UI
- Closed#45736Feature: adjustable font size in Hermes desktop GUI (or CLI / config flag)
- Closed#46097Feature request: Desktop font size setting
- Closed#47499Feature request: UI zoom/scale controls
- Closed#48336Hermes Desktop: No UI zoom / font scaling support (Ctrl+/- ineffective, no config option)
- Closed#48474[Feature]: Default font size in settings
- Closed#51918[Feature]: Font size / zoom setting in Desktop UI
- Closed#53010[Feature Request] Add font size settings for conversation area (accessibility & health)
- Closed#53411[Feature]: Can the UI font in the Desktop version be adjusted?
- Closed#58161[Feature]: I hope the desktop app adds a feature to adjust the interface font size.
- Closed#60039feat(desktop): increase default font size in chat interface (reference Codex Desktop)
- Closed#64790[Feature]: Add a font selector (family, size, color) to Desktop Appearance settings
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.11: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- apps/desktop
- Merged PRs
- #60457
- Closed issues
- 16
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 7 Jul 2026