mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
feat(desktop): add UI scale setting to appearance settings
This commit is contained in:
parent
4c3a388cba
commit
8ce3c2f991
15 changed files with 192 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
34
apps/desktop/electron/zoom.cjs
Normal file
34
apps/desktop/electron/zoom.cjs
Normal file
|
|
@ -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
|
||||
}
|
||||
54
apps/desktop/electron/zoom.test.cjs
Normal file
54
apps/desktop/electron/zoom.test.cjs
Normal file
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
BIN
apps/desktop/pr-assets/ui-scale-175.png
Normal file
BIN
apps/desktop/pr-assets/ui-scale-175.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
apps/desktop/pr-assets/ui-scale-default.png
Normal file
BIN
apps/desktop/pr-assets/ui-scale-default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
|
|
@ -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">
|
||||
|
|
|
|||
5
apps/desktop/src/global.d.ts
vendored
5
apps/desktop/src/global.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: 'インライン埋め込み',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '內嵌預覽',
|
||||
|
|
|
|||
|
|
@ -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: '内嵌预览',
|
||||
|
|
|
|||
22
apps/desktop/src/store/zoom.ts
Normal file
22
apps/desktop/src/store/zoom.ts
Normal file
|
|
@ -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))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue