{"repo":"NousResearch/hermes-agent","public_id":"60248","display_label":"UI scale setting in desktop appearance options","summary":"These issues and pull requests concern adding a UI scale control to the desktop application's appearance settings. Two of the entries appear to be pull request attempts at implementing the same feature, with one noted as a salvage of a prior PR.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60457,"merged_at":"2026-07-07T20:04:33+00:00"},"resolution":{"state":"resolved","by":"pr-60457"},"freshness":{"state":"verified","last_verified_at":"2026-07-07T20:04:33+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":40166,"pr_number":60457,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-07T20:04:33+00:00"},{"issue_number":47499,"pr_number":60457,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-07T20:04:33+00:00"},{"issue_number":48336,"pr_number":60457,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-07T20:04:33+00:00"},{"issue_number":53411,"pr_number":60457,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-07T20:04:33+00:00"},{"issue_number":60248,"pr_number":60457,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-07T20:04:33+00:00"}],"members":[{"kind":"issue","number":60248,"title":"[Feature]: Add a UI scale control to desktop settings","url":null,"state":"closed"},{"kind":"pr","number":60249,"title":"feat(desktop): add UI scale setting to appearance settings","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60457","number":60457}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":60457,"title":"feat(desktop): add UI scale setting to appearance settings (salvage #60249)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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.ts"],"fix_hunks":[{"file":"apps/desktop/electron/main.cjs","hunks":["@@ -4167,17 +4167,15 @@ function installPreviewShortcut(window) {\n // survives reloads/restarts) rather than a main-process JSON file. The main\n // process owns setZoomLevel, so we mirror each change into localStorage and\n // read it back on did-finish-load to re-apply after reloads or crash recovery.\n-const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'\n-\n-function clampZoomLevel(value) {\n-  if (!Number.isFinite(value)) return 0\n-  return Math.min(Math.max(value, -9), 9)\n-}\n+const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs')\n \n function setAndPersistZoomLevel(window, zoomLevel) {\n   if (!window || window.isDestroyed()) return\n   const next = clampZoomLevel(zoomLevel)\n   window.webContents.setZoomLevel(next)\n+  // Keep any open settings UI in sync, including changes made via the\n+  // keyboard shortcuts or the View menu.\n+  window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) })\n   window.webContents\n     .executeJavaScript(\n       `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`","@@ -6158,6 +6156,21 @@ ipcMain.handle('hermes:window:openNewSession', async () => {\n   return { ok: true }\n })\n \n+// --- Text size (zoom) -------------------------------------------------------\n+// The settings UI drives the same clamped zoom scale as the Ctrl/Cmd\n+// shortcuts and the View menu. Reads and writes target the asking window.\n+ipcMain.handle('hermes:zoom:get', event => {\n+  const window = BrowserWindow.fromWebContents(event.sender)\n+  const level = window && !window.isDestroyed() ? window.webContents.getZoomLevel() : 0\n+\n+  return { level, percent: zoomLevelToPercent(level) }\n+})\n+ipcMain.on('hermes:zoom:set-percent', (event, percent) => {\n+  const window = BrowserWindow.fromWebContents(event.sender)\n+  if (!window || window.isDestroyed()) return\n+  setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent)))\n+})\n+\n // --- Pet overlay (pop-out mascot) -----------------------------------------\n // `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space\n // bounds (screen=false): convert to screen space by adding the main window's"]},{"file":"apps/desktop/electron/preload.cjs","hunks":["@@ -78,6 +78,18 @@ contextBridge.exposeInMainWorld('hermesDesktop', {\n     setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),\n     pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')\n   },\n+  zoom: {\n+    // Current zoom of this window, as { level, percent }.\n+    get: () => ipcRenderer.invoke('hermes:zoom:get'),\n+    setPercent: percent => ipcRenderer.send('hermes:zoom:set-percent', percent),\n+    // Fires on every zoom change, including the Ctrl/Cmd +/-/0 shortcuts,\n+    // so the settings UI can stay in sync with the keyboard.\n+    onChanged: callback => {\n+      const listener = (_event, payload) => callback(payload)\n+      ipcRenderer.on('hermes:zoom:changed', listener)\n+      return () => ipcRenderer.removeListener('hermes:zoom:changed', listener)\n+    }\n+  },\n   revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),\n   getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),\n   readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),"]},{"file":"apps/desktop/electron/zoom.cjs","hunks":["@@ -0,0 +1,34 @@\n+/**\n+ * Pure helpers for window zoom. The main process owns webContents.setZoomLevel,\n+ * so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel\n+ * through this one clamped scale. Percent is the user-facing unit (100 = the\n+ * default size); Chromium's internal unit is the zoom level, where\n+ * factor = 1.2 ^ level.\n+ */\n+\n+const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'\n+\n+const ZOOM_FACTOR_BASE = 1.2\n+const MIN_ZOOM_LEVEL = -9\n+const MAX_ZOOM_LEVEL = 9\n+\n+function clampZoomLevel(value) {\n+  if (!Number.isFinite(value)) return 0\n+  return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)\n+}\n+\n+function zoomLevelToPercent(level) {\n+  return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100)\n+}\n+\n+function percentToZoomLevel(percent) {\n+  if (!Number.isFinite(percent) || percent <= 0) return 0\n+  return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))\n+}\n+\n+module.exports = {\n+  ZOOM_STORAGE_KEY,\n+  clampZoomLevel,\n+  percentToZoomLevel,\n+  zoomLevelToPercent\n+}"]},{"file":"apps/desktop/electron/zoom.test.cjs","hunks":["@@ -0,0 +1,54 @@\n+/**\n+ * Unit tests for the pure zoom helpers: clamping garbage input, the\n+ * percent <-> zoom-level conversion the settings UI relies on, and the\n+ * roundtrip stability of the preset percentages.\n+ */\n+\n+const test = require('node:test')\n+const assert = require('node:assert/strict')\n+\n+const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs')\n+\n+test('storage key stays stable so persisted zoom survives upgrades', () => {\n+  assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel')\n+})\n+\n+test('clampZoomLevel rejects garbage and enforces bounds', () => {\n+  assert.equal(clampZoomLevel(NaN), 0)\n+  assert.equal(clampZoomLevel(Infinity), 0)\n+  assert.equal(clampZoomLevel(undefined), 0)\n+  assert.equal(clampZoomLevel('2'), 0)\n+  assert.equal(clampZoomLevel(0.3), 0.3)\n+  assert.equal(clampZoomLevel(-42), -9)\n+  assert.equal(clampZoomLevel(42), 9)\n+})\n+\n+test('level 0 is exactly 100 percent', () => {\n+  assert.equal(zoomLevelToPercent(0), 100)\n+  assert.equal(percentToZoomLevel(100), 0)\n+})\n+\n+test('percentToZoomLevel rejects garbage', () => {\n+  assert.equal(percentToZoomLevel(NaN), 0)\n+  assert.equal(percentToZoomLevel(0), 0)\n+  assert.equal(percentToZoomLevel(-50), 0)\n+  assert.equal(percentToZoomLevel(undefined), 0)\n+})\n+\n+test('preset percentages roundtrip within rounding', () => {\n+  for (const percent of [90, 100, 110, 125, 150, 175]) {\n+    assert.equal(zoomLevelToPercent(percentToZoomLevel(percent)), percent)\n+  }\n+})\n+\n+test('conversion is monotonic across the preset range', () => {\n+  const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel)\n+  for (let i = 1; i < levels.length; i++) {\n+    assert.ok(levels[i] > levels[i - 1])\n+  }\n+})\n+\n+test('extreme percentages clamp to the level bounds', () => {\n+  assert.equal(percentToZoomLevel(1), -9)\n+  assert.equal(percentToZoomLevel(1_000_000), 9)\n+})"]},{"file":"apps/desktop/package.json","hunks":["@@ -37,7 +37,7 @@\n     \"test:desktop:nsis\": \"node scripts/test-desktop.mjs nsis\",\n     \"test:desktop:existing\": \"node scripts/test-desktop.mjs existing\",\n     \"test:desktop:fresh\": \"node scripts/test-desktop.mjs fresh\",\n-    \"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\",\n+    \"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\",\n     \"typecheck\": \"tsc -p . --noEmit\",\n     \"lint\": \"eslint src/ electron/\",\n     \"lint:fix\": \"eslint src/ electron/ --fix\","]},{"file":"apps/desktop/src/app/settings/appearance-settings.tsx","hunks":["@@ -16,6 +16,7 @@ import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedM\n import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'\n import { $toolViewMode, setToolViewMode } from '@/store/tool-view'\n import { $translucency, setTranslucency } from '@/store/translucency'\n+import { $zoomPercent, setZoomPercent } from '@/store/zoom'\n import { getBaseColors, useTheme } from '@/themes/context'\n import { installVscodeThemeFromMarketplace } from '@/themes/install'\n import type { DesktopTheme } from '@/themes/types'","@@ -62,6 +63,18 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' })\n   )\n }\n \n+// UI scale presets, as zoom percentages. 100 is the browser-default size;\n+// the ids double as the percent values sent to the main process. A Cmd/Ctrl\n+// +/- step landing between presets highlights nothing, and the row\n+// description keeps showing the exact current percent.\n+const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const\n+\n+type UiScalePreset = (typeof UI_SCALE_PRESETS)[number]\n+\n+function matchUiScalePreset(percent: number): UiScalePreset | null {\n+  return UI_SCALE_PRESETS.find(preset => Number(preset) === percent) ?? null\n+}\n+\n function useDebounced<T>(value: T, delayMs: number): T {\n   const [debounced, setDebounced] = useState(value)\n ","@@ -231,6 +244,7 @@ export function AppearanceSettings() {\n   const { t, isSavingLocale } = useI18n()\n   const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()\n   const toolViewMode = useStore($toolViewMode)\n+  const zoomPercent = useStore($zoomPercent)\n   const embedMode = useStore($embedMode)\n   const embedAllowed = useStore($embedAllowed)\n   const translucency = useStore($translucency)","@@ -277,6 +291,10 @@ export function AppearanceSettings() {\n     { id: 'off', label: a.embedsOff }\n   ] as const satisfies readonly { id: EmbedMode; label: string }[]\n \n+  const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` }))\n+\n+  const matchedScalePreset = matchUiScalePreset(zoomPercent)\n+\n   return (\n     <SettingsContent>\n       <div>","@@ -392,6 +410,21 @@ export function AppearanceSettings() {\n             wide\n           />\n \n+          <ListRow\n+            action={\n+              <SegmentedControl\n+                onChange={id => {\n+                  triggerHaptic('selection')\n+                  setZoomPercent(Number(id))\n+                }}\n+                options={uiScaleOptions}\n+                value={matchedScalePreset ?? ('' as UiScalePreset)}\n+              />\n+            }\n+            description={a.uiScaleDesc(zoomPercent)}\n+            title={a.uiScaleTitle}\n+          />\n+\n           <ListRow\n             action={\n               <div className=\"flex items-center gap-3\">"]},{"file":"apps/desktop/src/global.d.ts","hunks":["@@ -89,6 +89,11 @@ declare global {\n         pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>\n         setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>\n       }\n+      zoom?: {\n+        get: () => Promise<{ level: number; percent: number }>\n+        setPercent: (percent: number) => void\n+        onChanged: (callback: (payload: { level: number; percent: number }) => void) => () => void\n+      }\n       revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }>\n       getRecentLogs: () => Promise<{ path: string; lines: string[] }>\n       readDir: (path: string) => Promise<HermesReadDirResult>"]},{"file":"apps/desktop/src/i18n/en.ts","hunks":["@@ -381,6 +381,9 @@ export const en: Translations = {\n       colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.',\n       toolViewTitle: 'Tool Call Display',\n       toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',\n+      uiScaleTitle: 'UI Scale',\n+      uiScaleDesc: (percent: number) =>\n+        `Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`,\n       translucencyTitle: 'Window Translucency',\n       translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',\n       embedsTitle: 'Inline Embeds',"]},{"file":"apps/desktop/src/i18n/ja.ts","hunks":["@@ -287,6 +287,9 @@ export const ja = defineLocale({\n       colorModeDesc: '固定モードを選ぶか、Hermes をシステム設定に合わせます。',\n       toolViewTitle: 'ツール呼び出しの表示',\n       toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。',\n+      uiScaleTitle: 'UI スケール',\n+      uiScaleDesc: (percent: number) =>\n+        `アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`,\n       translucencyTitle: 'ウィンドウの透過',\n       translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',\n       embedsTitle: 'インライン埋め込み',"]},{"file":"apps/desktop/src/i18n/types.ts","hunks":["@@ -302,6 +302,8 @@ export interface Translations {\n       colorModeDesc: string\n       toolViewTitle: string\n       toolViewDesc: string\n+      uiScaleTitle: string\n+      uiScaleDesc: (percent: number) => string\n       translucencyTitle: string\n       translucencyDesc: string\n       embedsTitle: string"]},{"file":"apps/desktop/src/i18n/zh-hant.ts","hunks":["@@ -279,6 +279,8 @@ export const zhHant = defineLocale({\n       colorModeDesc: '選擇固定模式，或讓 Hermes 跟隨系統設定。',\n       toolViewTitle: '工具呼叫顯示',\n       toolViewDesc: '產品模式會隱藏原始工具 payload；技術模式會顯示完整輸入/輸出。',\n+      uiScaleTitle: '介面縮放',\n+      uiScaleDesc: (percent: number) => `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前：${percent}%`,\n       translucencyTitle: '視窗透明',\n       translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',\n       embedsTitle: '內嵌預覽',"]},{"file":"apps/desktop/src/i18n/zh.ts","hunks":["@@ -370,6 +370,8 @@ export const zh: Translations = {\n       colorModeDesc: '选择固定模式，或让 Hermes 跟随系统设置。',\n       toolViewTitle: '工具调用显示',\n       toolViewDesc: '产品模式隐藏原始工具数据；技术模式显示完整输入/输出。',\n+      uiScaleTitle: '界面缩放',\n+      uiScaleDesc: (percent: number) => `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前：${percent}%`,\n       translucencyTitle: '窗口透明',\n       translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',\n       embedsTitle: '内嵌预览',"]},{"file":"apps/desktop/src/store/zoom.ts","hunks":["@@ -0,0 +1,22 @@\n+/**\n+ * Window text size (zoom).\n+ *\n+ * The main process owns the zoom level and persists it (see electron/zoom.cjs\n+ * for the scale). The renderer only mirrors the current percent for the\n+ * settings UI: preset clicks go to the main process over IPC, and every\n+ * change comes back through onChanged, including ones made with the\n+ * Ctrl/Cmd +/-/0 shortcuts or the View menu, so the UI never drifts.\n+ */\n+\n+import { atom } from 'nanostores'\n+\n+export const $zoomPercent = atom<number>(100)\n+\n+export function setZoomPercent(percent: number): void {\n+  window.hermesDesktop?.zoom?.setPercent(percent)\n+}\n+\n+if (typeof window !== 'undefined' && window.hermesDesktop?.zoom) {\n+  void window.hermesDesktop.zoom.get().then(({ percent }) => $zoomPercent.set(percent))\n+  window.hermesDesktop.zoom.onChanged(({ percent }) => $zoomPercent.set(percent))\n+}\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}